flowent 0.1.3 → 0.1.5
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/backend/pyproject.toml +1 -1
- package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/approval.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/compact.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
- package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
- package/backend/src/flowent/agent.py +23 -1
- package/backend/src/flowent/approval.py +148 -0
- package/backend/src/flowent/cli.py +16 -2
- package/backend/src/flowent/compact.py +183 -0
- package/backend/src/flowent/context.py +19 -1
- package/backend/src/flowent/llm.py +51 -11
- package/backend/src/flowent/logging.py +60 -0
- package/backend/src/flowent/main.py +696 -192
- package/backend/src/flowent/mcp.py +3 -1
- package/backend/src/flowent/patch.py +55 -31
- package/backend/src/flowent/paths.py +12 -0
- package/backend/src/flowent/permissions.py +185 -42
- package/backend/src/flowent/sandbox.py +146 -13
- package/backend/src/flowent/static/assets/index-Cl20cARb.css +2 -0
- package/backend/src/flowent/static/assets/index-dsDDsEym.js +81 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +257 -9
- package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_approval.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_patch.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
- package/backend/tests/test_agent_tools.py +312 -1
- package/backend/tests/test_approval.py +283 -0
- package/backend/tests/test_llm_providers.py +216 -0
- package/backend/tests/test_logging.py +30 -0
- package/backend/tests/test_mcp.py +76 -10
- package/backend/tests/test_patch.py +112 -0
- package/backend/tests/test_permissions.py +198 -53
- package/backend/tests/test_persistence.py +78 -0
- package/backend/tests/test_startup_requirements.py +96 -0
- package/backend/tests/test_workspace_chat.py +1265 -144
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-Cl20cARb.css +2 -0
- package/dist/frontend/assets/index-dsDDsEym.js +81 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +2 -2
- package/backend/src/flowent/static/assets/index-DjF2KBwE.js +0 -81
- package/backend/src/flowent/static/assets/index-P-bBpJG8.css +0 -2
- package/dist/frontend/assets/index-DjF2KBwE.js +0 -81
- package/dist/frontend/assets/index-P-bBpJG8.css +0 -2
|
@@ -2,7 +2,7 @@ import asyncio
|
|
|
2
2
|
import json
|
|
3
3
|
import logging
|
|
4
4
|
import os
|
|
5
|
-
from collections.abc import AsyncIterator
|
|
5
|
+
from collections.abc import AsyncIterator, Mapping, Sequence
|
|
6
6
|
from contextlib import asynccontextmanager
|
|
7
7
|
from dataclasses import dataclass, field
|
|
8
8
|
from pathlib import Path
|
|
@@ -15,21 +15,35 @@ from fastapi.staticfiles import StaticFiles
|
|
|
15
15
|
from pydantic import BaseModel, ConfigDict
|
|
16
16
|
|
|
17
17
|
from flowent._version import __version__
|
|
18
|
-
from flowent.agent import run_agent_stream
|
|
18
|
+
from flowent.agent import AgentContextUpdate, run_agent_stream
|
|
19
|
+
from flowent.approval import (
|
|
20
|
+
ApprovalReviewRequest,
|
|
21
|
+
ApprovalTranscriptEntry,
|
|
22
|
+
review_approval_request,
|
|
23
|
+
)
|
|
19
24
|
from flowent.channels import TelegramBotManager, TelegramTransport
|
|
25
|
+
from flowent.compact import (
|
|
26
|
+
CompactInput,
|
|
27
|
+
LocalSummaryCompactProvider,
|
|
28
|
+
transcript_messages_after,
|
|
29
|
+
)
|
|
20
30
|
from flowent.context import runtime_context_messages
|
|
21
31
|
from flowent.llm import (
|
|
22
32
|
ChatMessage,
|
|
23
33
|
CompletionCallable,
|
|
24
34
|
ProviderConnection,
|
|
25
35
|
ProviderFormat,
|
|
26
|
-
complete_chat,
|
|
27
36
|
list_provider_models,
|
|
28
37
|
)
|
|
29
|
-
from flowent.logging import
|
|
38
|
+
from flowent.logging import (
|
|
39
|
+
TRACE_LEVEL,
|
|
40
|
+
ensure_logging_configured,
|
|
41
|
+
redact_diagnostic_value,
|
|
42
|
+
)
|
|
30
43
|
from flowent.mcp import McpManager, McpTransport
|
|
31
44
|
from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
|
|
32
|
-
from flowent.
|
|
45
|
+
from flowent.paths import resolve_workdir
|
|
46
|
+
from flowent.permissions import run_tool_with_path_permissions
|
|
33
47
|
from flowent.sandbox import ensure_sandbox_available
|
|
34
48
|
from flowent.skills import (
|
|
35
49
|
discover_skills,
|
|
@@ -38,6 +52,9 @@ from flowent.skills import (
|
|
|
38
52
|
)
|
|
39
53
|
from flowent.storage import (
|
|
40
54
|
StateStore,
|
|
55
|
+
StoredAssistantOutputGroup,
|
|
56
|
+
StoredCompactionCheckpoint,
|
|
57
|
+
StoredErrorOutputItem,
|
|
41
58
|
StoredMcpServer,
|
|
42
59
|
StoredMessage,
|
|
43
60
|
StoredProvider,
|
|
@@ -46,7 +63,10 @@ from flowent.storage import (
|
|
|
46
63
|
StoredState,
|
|
47
64
|
StoredTelegramBot,
|
|
48
65
|
StoredTelegramSession,
|
|
66
|
+
StoredTextOutputItem,
|
|
67
|
+
StoredThinkingOutputItem,
|
|
49
68
|
StoredToolItem,
|
|
69
|
+
StoredToolOutputItem,
|
|
50
70
|
StoredWritablePath,
|
|
51
71
|
)
|
|
52
72
|
from flowent.tools import ToolContext
|
|
@@ -56,7 +76,11 @@ logger = logging.getLogger("flowent.main")
|
|
|
56
76
|
|
|
57
77
|
DEFAULT_STATIC_DIR = Path(__file__).parent / "static"
|
|
58
78
|
COMPACTED_CONTEXT_MARKER = "Context compacted"
|
|
59
|
-
|
|
79
|
+
OPTIMIZED_CONTEXT_MARKER = "Context optimized"
|
|
80
|
+
DEFAULT_AUTO_COMPACT_TOKEN_LIMIT = 120_000
|
|
81
|
+
AUTO_COMPACT_RETAINED_MESSAGE_TOKEN_BUDGET = 20_000
|
|
82
|
+
APPROVAL_TRANSCRIPT_MESSAGE_LIMIT = 12
|
|
83
|
+
APPROVAL_TRANSCRIPT_TEXT_LIMIT = 2_000
|
|
60
84
|
|
|
61
85
|
|
|
62
86
|
class ProviderModelsRequest(BaseModel):
|
|
@@ -138,19 +162,6 @@ class WritablePathListResponse(BaseModel):
|
|
|
138
162
|
writable_paths: list[StoredWritablePath]
|
|
139
163
|
|
|
140
164
|
|
|
141
|
-
class WorkspacePermissionDecisionRequest(BaseModel):
|
|
142
|
-
model_config = ConfigDict(extra="forbid")
|
|
143
|
-
|
|
144
|
-
decision: Literal["allow_once", "always_allow", "deny"]
|
|
145
|
-
id: str
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
@dataclass
|
|
149
|
-
class PendingWorkspacePermission:
|
|
150
|
-
future: asyncio.Future[WritablePathDecision]
|
|
151
|
-
path: Path
|
|
152
|
-
|
|
153
|
-
|
|
154
165
|
@dataclass
|
|
155
166
|
class WorkspaceRun:
|
|
156
167
|
condition: asyncio.Condition
|
|
@@ -158,9 +169,6 @@ class WorkspaceRun:
|
|
|
158
169
|
events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
|
|
159
170
|
id: str = field(default_factory=lambda: str(uuid4()))
|
|
160
171
|
is_done: bool = False
|
|
161
|
-
pending_permissions: dict[str, PendingWorkspacePermission] = field(
|
|
162
|
-
default_factory=dict
|
|
163
|
-
)
|
|
164
172
|
task: asyncio.Task[None] | None = None
|
|
165
173
|
|
|
166
174
|
@property
|
|
@@ -181,6 +189,265 @@ def append_or_replace_message(
|
|
|
181
189
|
]
|
|
182
190
|
|
|
183
191
|
|
|
192
|
+
USER_VISIBLE_RUN_ERROR_TITLE = "Request failed"
|
|
193
|
+
USER_VISIBLE_RUN_ERROR_MESSAGE = "Check the model connection settings and try again."
|
|
194
|
+
USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE = "Context could not be optimized."
|
|
195
|
+
EMPTY_MODEL_RESPONSE_DETAIL = "The model did not return a response."
|
|
196
|
+
|
|
197
|
+
|
|
198
|
+
def user_visible_run_error_message(detail: str) -> str:
|
|
199
|
+
if detail.strip() == USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE:
|
|
200
|
+
return USER_VISIBLE_CONTEXT_OPTIMIZATION_ERROR_MESSAGE
|
|
201
|
+
return USER_VISIBLE_RUN_ERROR_MESSAGE
|
|
202
|
+
|
|
203
|
+
|
|
204
|
+
def run_error_output_item(
|
|
205
|
+
assistant_id: str,
|
|
206
|
+
detail: str,
|
|
207
|
+
index: int = 1,
|
|
208
|
+
) -> StoredErrorOutputItem:
|
|
209
|
+
redacted_detail = redact_diagnostic_value(detail.strip())
|
|
210
|
+
message = user_visible_run_error_message(redacted_detail)
|
|
211
|
+
return StoredErrorOutputItem(
|
|
212
|
+
detail="" if redacted_detail == message else redacted_detail,
|
|
213
|
+
id=f"{assistant_id}-error-{index}",
|
|
214
|
+
message=message,
|
|
215
|
+
title=USER_VISIBLE_RUN_ERROR_TITLE,
|
|
216
|
+
type="error",
|
|
217
|
+
)
|
|
218
|
+
|
|
219
|
+
|
|
220
|
+
def run_error_event_data(error: StoredErrorOutputItem) -> dict[str, object]:
|
|
221
|
+
return {
|
|
222
|
+
"error": error.model_dump(exclude_none=True),
|
|
223
|
+
"message": error.message,
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
|
|
227
|
+
def message_error_items(message: StoredMessage) -> list[StoredErrorOutputItem]:
|
|
228
|
+
return [
|
|
229
|
+
item for group in message.groups for item in group.items if item.type == "error"
|
|
230
|
+
]
|
|
231
|
+
|
|
232
|
+
|
|
233
|
+
def error_context_summary(error: StoredErrorOutputItem) -> str:
|
|
234
|
+
parts = [f"Previous response failed: {error.title}.", error.message]
|
|
235
|
+
if error.detail and error.detail != error.message:
|
|
236
|
+
parts.append(f"Detail: {error.detail}")
|
|
237
|
+
return " ".join(part.strip() for part in parts if part.strip())
|
|
238
|
+
|
|
239
|
+
|
|
240
|
+
def approval_transcript_text(content: str | None) -> str:
|
|
241
|
+
text = (content or "").strip()
|
|
242
|
+
if len(text) <= APPROVAL_TRANSCRIPT_TEXT_LIMIT:
|
|
243
|
+
return text
|
|
244
|
+
return f"{text[:APPROVAL_TRANSCRIPT_TEXT_LIMIT]}\n[truncated]"
|
|
245
|
+
|
|
246
|
+
|
|
247
|
+
def approval_transcript(
|
|
248
|
+
messages: Sequence[StoredMessage],
|
|
249
|
+
) -> list[ApprovalTranscriptEntry]:
|
|
250
|
+
entries: list[ApprovalTranscriptEntry] = []
|
|
251
|
+
for message in messages[-APPROVAL_TRANSCRIPT_MESSAGE_LIMIT:]:
|
|
252
|
+
if message.author in ("user", "assistant"):
|
|
253
|
+
role: Literal["user", "assistant"] = (
|
|
254
|
+
"user" if message.author == "user" else "assistant"
|
|
255
|
+
)
|
|
256
|
+
content = approval_transcript_text(message.content)
|
|
257
|
+
if content:
|
|
258
|
+
entries.append(ApprovalTranscriptEntry(role=role, content=content))
|
|
259
|
+
for tool in message.tools:
|
|
260
|
+
tool_content = approval_transcript_text(tool.content)
|
|
261
|
+
if tool_content:
|
|
262
|
+
entries.append(
|
|
263
|
+
ApprovalTranscriptEntry(
|
|
264
|
+
role="tool",
|
|
265
|
+
content=tool_content,
|
|
266
|
+
name=tool.name,
|
|
267
|
+
)
|
|
268
|
+
)
|
|
269
|
+
return entries
|
|
270
|
+
|
|
271
|
+
|
|
272
|
+
class AssistantOutputBuilder:
|
|
273
|
+
def __init__(self, assistant_id: str = "") -> None:
|
|
274
|
+
self.assistant_id = assistant_id
|
|
275
|
+
self.content = ""
|
|
276
|
+
self.groups: list[StoredAssistantOutputGroup] = []
|
|
277
|
+
self.text_item_index = 0
|
|
278
|
+
self.text_item_id = ""
|
|
279
|
+
self.thinking = ""
|
|
280
|
+
self.thinking_item_index = 0
|
|
281
|
+
self.thinking_item_id = ""
|
|
282
|
+
self.error_item_index = 0
|
|
283
|
+
self.tools: dict[str, StoredToolItem] = {}
|
|
284
|
+
|
|
285
|
+
def set_assistant_id(self, assistant_id: str) -> None:
|
|
286
|
+
self.assistant_id = assistant_id
|
|
287
|
+
|
|
288
|
+
def start_group(self, index: int) -> None:
|
|
289
|
+
group_id = f"{self.assistant_id or 'assistant'}-group-{index}"
|
|
290
|
+
if self.groups and self.groups[-1].id == group_id:
|
|
291
|
+
return
|
|
292
|
+
self.text_item_id = ""
|
|
293
|
+
self.thinking_item_id = ""
|
|
294
|
+
self.groups.append(StoredAssistantOutputGroup(id=group_id, items=[]))
|
|
295
|
+
|
|
296
|
+
def append_text(self, content: str) -> None:
|
|
297
|
+
if not content:
|
|
298
|
+
return
|
|
299
|
+
self._ensure_group()
|
|
300
|
+
if not self.text_item_id:
|
|
301
|
+
self.text_item_index += 1
|
|
302
|
+
self.text_item_id = f"{self.assistant_id}-text-{self.text_item_index}"
|
|
303
|
+
self._append_current_item(
|
|
304
|
+
StoredTextOutputItem(content="", id=self.text_item_id, type="text")
|
|
305
|
+
)
|
|
306
|
+
self.content += content
|
|
307
|
+
self.groups[-1] = self.groups[-1].model_copy(
|
|
308
|
+
update={
|
|
309
|
+
"items": [
|
|
310
|
+
item.model_copy(update={"content": item.content + content})
|
|
311
|
+
if item.type == "text" and item.id == self.text_item_id
|
|
312
|
+
else item
|
|
313
|
+
for item in self.groups[-1].items
|
|
314
|
+
]
|
|
315
|
+
}
|
|
316
|
+
)
|
|
317
|
+
|
|
318
|
+
def append_thinking(self, content: str) -> None:
|
|
319
|
+
if not content:
|
|
320
|
+
return
|
|
321
|
+
self._ensure_group()
|
|
322
|
+
if not self.thinking_item_id:
|
|
323
|
+
self.thinking_item_index += 1
|
|
324
|
+
self.thinking_item_id = (
|
|
325
|
+
f"{self.assistant_id}-thinking-{self.thinking_item_index}"
|
|
326
|
+
)
|
|
327
|
+
self._append_current_item(
|
|
328
|
+
StoredThinkingOutputItem(
|
|
329
|
+
content="", id=self.thinking_item_id, type="thinking"
|
|
330
|
+
)
|
|
331
|
+
)
|
|
332
|
+
self.thinking += content
|
|
333
|
+
self.groups[-1] = self.groups[-1].model_copy(
|
|
334
|
+
update={
|
|
335
|
+
"items": [
|
|
336
|
+
item.model_copy(update={"content": item.content + content})
|
|
337
|
+
if item.type == "thinking" and item.id == self.thinking_item_id
|
|
338
|
+
else item
|
|
339
|
+
for item in self.groups[-1].items
|
|
340
|
+
]
|
|
341
|
+
}
|
|
342
|
+
)
|
|
343
|
+
|
|
344
|
+
def start_tool(self, tool: StoredToolItem) -> None:
|
|
345
|
+
self._ensure_group()
|
|
346
|
+
self.text_item_id = ""
|
|
347
|
+
self.thinking_item_id = ""
|
|
348
|
+
self.tools[tool.id] = tool
|
|
349
|
+
self._append_current_item(
|
|
350
|
+
StoredToolOutputItem(id=f"tool-{tool.id}", tool=tool, type="tool")
|
|
351
|
+
)
|
|
352
|
+
|
|
353
|
+
def update_tool(self, tool_id: str, data: dict[str, object]) -> None:
|
|
354
|
+
current_tool = self.tools.get(tool_id)
|
|
355
|
+
if current_tool is None:
|
|
356
|
+
return
|
|
357
|
+
updated_tool = StoredToolItem.model_validate(
|
|
358
|
+
{**current_tool.model_dump(exclude_none=True), **data}
|
|
359
|
+
)
|
|
360
|
+
self.tools[tool_id] = updated_tool
|
|
361
|
+
self.groups = [
|
|
362
|
+
group.model_copy(
|
|
363
|
+
update={
|
|
364
|
+
"items": [
|
|
365
|
+
item.model_copy(update={"tool": updated_tool})
|
|
366
|
+
if item.type == "tool" and item.tool.id == tool_id
|
|
367
|
+
else item
|
|
368
|
+
for item in group.items
|
|
369
|
+
]
|
|
370
|
+
}
|
|
371
|
+
)
|
|
372
|
+
for group in self.groups
|
|
373
|
+
]
|
|
374
|
+
|
|
375
|
+
def append_error(self, error: StoredErrorOutputItem) -> StoredErrorOutputItem:
|
|
376
|
+
self.error_item_index += 1
|
|
377
|
+
if not error.id:
|
|
378
|
+
error = error.model_copy(
|
|
379
|
+
update={"id": f"{self.assistant_id}-error-{self.error_item_index}"}
|
|
380
|
+
)
|
|
381
|
+
error_group_id = f"{self.assistant_id}-errors"
|
|
382
|
+
if self.groups and self.groups[-1].id == error_group_id:
|
|
383
|
+
self.groups[-1] = self.groups[-1].model_copy(
|
|
384
|
+
update={"items": [*self.groups[-1].items, error]}
|
|
385
|
+
)
|
|
386
|
+
else:
|
|
387
|
+
self.groups.append(
|
|
388
|
+
StoredAssistantOutputGroup(id=error_group_id, items=[error])
|
|
389
|
+
)
|
|
390
|
+
return error
|
|
391
|
+
|
|
392
|
+
def has_output(self) -> bool:
|
|
393
|
+
return any(group.items for group in self.groups)
|
|
394
|
+
|
|
395
|
+
def apply_done_message(self, message: dict[str, object]) -> None:
|
|
396
|
+
final_content = str(message.get("content") or self.content)
|
|
397
|
+
final_thinking = str(message.get("thinking") or self.thinking)
|
|
398
|
+
self._append_missing_done_text(final_content)
|
|
399
|
+
self._append_missing_done_thinking(final_thinking)
|
|
400
|
+
self.content = final_content
|
|
401
|
+
self.thinking = final_thinking
|
|
402
|
+
|
|
403
|
+
def _append_missing_done_text(self, final_content: str) -> None:
|
|
404
|
+
streamed_text = "".join(
|
|
405
|
+
item.content
|
|
406
|
+
for group in self.groups
|
|
407
|
+
for item in group.items
|
|
408
|
+
if item.type == "text"
|
|
409
|
+
)
|
|
410
|
+
if not final_content or streamed_text == final_content:
|
|
411
|
+
return
|
|
412
|
+
missing_text = (
|
|
413
|
+
final_content[len(streamed_text) :]
|
|
414
|
+
if final_content.startswith(streamed_text)
|
|
415
|
+
else final_content
|
|
416
|
+
)
|
|
417
|
+
self.append_text(missing_text)
|
|
418
|
+
|
|
419
|
+
def _append_missing_done_thinking(self, final_thinking: str) -> None:
|
|
420
|
+
streamed_thinking = "".join(
|
|
421
|
+
item.content
|
|
422
|
+
for group in self.groups
|
|
423
|
+
for item in group.items
|
|
424
|
+
if item.type == "thinking"
|
|
425
|
+
)
|
|
426
|
+
if not final_thinking or streamed_thinking == final_thinking:
|
|
427
|
+
return
|
|
428
|
+
missing_thinking = (
|
|
429
|
+
final_thinking[len(streamed_thinking) :]
|
|
430
|
+
if final_thinking.startswith(streamed_thinking)
|
|
431
|
+
else final_thinking
|
|
432
|
+
)
|
|
433
|
+
self.append_thinking(missing_thinking)
|
|
434
|
+
|
|
435
|
+
def _ensure_group(self) -> None:
|
|
436
|
+
if not self.groups:
|
|
437
|
+
self.start_group(1)
|
|
438
|
+
|
|
439
|
+
def _append_current_item(
|
|
440
|
+
self,
|
|
441
|
+
item: StoredTextOutputItem
|
|
442
|
+
| StoredThinkingOutputItem
|
|
443
|
+
| StoredErrorOutputItem
|
|
444
|
+
| StoredToolOutputItem,
|
|
445
|
+
) -> None:
|
|
446
|
+
self.groups[-1] = self.groups[-1].model_copy(
|
|
447
|
+
update={"items": [*self.groups[-1].items, item]}
|
|
448
|
+
)
|
|
449
|
+
|
|
450
|
+
|
|
184
451
|
def frontend_static_directory() -> Path:
|
|
185
452
|
configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
|
|
186
453
|
if configured_directory:
|
|
@@ -228,16 +495,71 @@ def selected_connection(state: StoredState) -> ProviderConnection:
|
|
|
228
495
|
def latest_compacted_context_index(messages: list[StoredMessage]) -> int | None:
|
|
229
496
|
for index in range(len(messages) - 1, -1, -1):
|
|
230
497
|
message = messages[index]
|
|
231
|
-
if message.author == "system" and message
|
|
498
|
+
if message.author == "system" and is_context_marker(message):
|
|
232
499
|
return index
|
|
233
500
|
return None
|
|
234
501
|
|
|
235
502
|
|
|
503
|
+
def is_context_marker(message: StoredMessage) -> bool:
|
|
504
|
+
return message.content in {COMPACTED_CONTEXT_MARKER, OPTIMIZED_CONTEXT_MARKER}
|
|
505
|
+
|
|
506
|
+
|
|
507
|
+
def auto_compact_token_limit() -> int:
|
|
508
|
+
raw_limit = os.environ.get("FLOWENT_AUTO_COMPACT_TOKEN_LIMIT", "")
|
|
509
|
+
try:
|
|
510
|
+
return max(0, int(raw_limit))
|
|
511
|
+
except ValueError:
|
|
512
|
+
return DEFAULT_AUTO_COMPACT_TOKEN_LIMIT
|
|
513
|
+
|
|
514
|
+
|
|
515
|
+
def should_auto_compact(messages: list[ChatMessage]) -> bool:
|
|
516
|
+
token_limit = auto_compact_token_limit()
|
|
517
|
+
if token_limit <= 0:
|
|
518
|
+
return False
|
|
519
|
+
return (
|
|
520
|
+
sum(max(1, (len(message.content) + 3) // 4) for message in messages)
|
|
521
|
+
>= token_limit
|
|
522
|
+
)
|
|
523
|
+
|
|
524
|
+
|
|
236
525
|
def workspace_chat_messages(
|
|
237
526
|
messages: list[StoredMessage],
|
|
238
527
|
compacted_context: str = "",
|
|
528
|
+
checkpoint: StoredCompactionCheckpoint | None = None,
|
|
239
529
|
) -> list[ChatMessage]:
|
|
240
530
|
chat_messages: list[ChatMessage] = []
|
|
531
|
+
|
|
532
|
+
if checkpoint is not None:
|
|
533
|
+
chat_messages.extend(checkpoint.replacement_history)
|
|
534
|
+
visible_messages = transcript_messages_after(
|
|
535
|
+
messages,
|
|
536
|
+
checkpoint.source_message_id,
|
|
537
|
+
)
|
|
538
|
+
for message in visible_messages:
|
|
539
|
+
if message.author == "system" and is_context_marker(message):
|
|
540
|
+
continue
|
|
541
|
+
if message.author not in ("user", "assistant"):
|
|
542
|
+
raise HTTPException(
|
|
543
|
+
status_code=400, detail="Message history is invalid."
|
|
544
|
+
)
|
|
545
|
+
if message.author == "assistant":
|
|
546
|
+
errors = message_error_items(message)
|
|
547
|
+
if errors:
|
|
548
|
+
chat_messages.extend(
|
|
549
|
+
ChatMessage(
|
|
550
|
+
role="assistant", content=error_context_summary(error)
|
|
551
|
+
)
|
|
552
|
+
for error in errors
|
|
553
|
+
)
|
|
554
|
+
continue
|
|
555
|
+
checkpoint_role: Literal["user", "assistant"] = (
|
|
556
|
+
"user" if message.author == "user" else "assistant"
|
|
557
|
+
)
|
|
558
|
+
chat_messages.append(
|
|
559
|
+
ChatMessage(role=checkpoint_role, content=message.content)
|
|
560
|
+
)
|
|
561
|
+
return chat_messages
|
|
562
|
+
|
|
241
563
|
marker_index = latest_compacted_context_index(messages)
|
|
242
564
|
visible_messages = messages
|
|
243
565
|
|
|
@@ -251,10 +573,18 @@ def workspace_chat_messages(
|
|
|
251
573
|
visible_messages = messages[marker_index + 1 :]
|
|
252
574
|
|
|
253
575
|
for message in visible_messages:
|
|
254
|
-
if message.author == "system" and message
|
|
576
|
+
if message.author == "system" and is_context_marker(message):
|
|
255
577
|
continue
|
|
256
578
|
if message.author not in ("user", "assistant"):
|
|
257
579
|
raise HTTPException(status_code=400, detail="Message history is invalid.")
|
|
580
|
+
if message.author == "assistant":
|
|
581
|
+
errors = message_error_items(message)
|
|
582
|
+
if errors:
|
|
583
|
+
chat_messages.extend(
|
|
584
|
+
ChatMessage(role="assistant", content=error_context_summary(error))
|
|
585
|
+
for error in errors
|
|
586
|
+
)
|
|
587
|
+
continue
|
|
258
588
|
role: Literal["user", "assistant"] = (
|
|
259
589
|
"user" if message.author == "user" else "assistant"
|
|
260
590
|
)
|
|
@@ -269,43 +599,20 @@ def normalized_request_path(path: str, cwd: Path) -> Path:
|
|
|
269
599
|
return raw_path.resolve(strict=False)
|
|
270
600
|
|
|
271
601
|
|
|
272
|
-
def compact_prompt_messages(
|
|
273
|
-
messages: list[StoredMessage],
|
|
274
|
-
compacted_context: str,
|
|
275
|
-
runtime_messages: list[ChatMessage] | None = None,
|
|
276
|
-
) -> list[ChatMessage]:
|
|
277
|
-
history_messages = [
|
|
278
|
-
*(runtime_messages or []),
|
|
279
|
-
*workspace_chat_messages(messages, compacted_context),
|
|
280
|
-
]
|
|
281
|
-
history = "\n\n".join(
|
|
282
|
-
f"{message.role}: {message.content}" for message in history_messages
|
|
283
|
-
)
|
|
284
|
-
return [
|
|
285
|
-
ChatMessage(role="system", content=COMPACT_SYSTEM_PROMPT),
|
|
286
|
-
ChatMessage(
|
|
287
|
-
role="user",
|
|
288
|
-
content=(
|
|
289
|
-
"Compact the current Flowent workspace context for the next turn.\n\n"
|
|
290
|
-
"Keep the details needed to continue accurately, including decisions, "
|
|
291
|
-
"constraints, pending work, and referenced facts.\n\n"
|
|
292
|
-
f"Conversation:\n{history}"
|
|
293
|
-
),
|
|
294
|
-
),
|
|
295
|
-
]
|
|
296
|
-
|
|
297
|
-
|
|
298
602
|
def create_app(
|
|
299
603
|
*,
|
|
300
604
|
serve_frontend: bool = True,
|
|
301
605
|
chat_completion: CompletionCallable | None = None,
|
|
302
606
|
mcp_transport: McpTransport | None = None,
|
|
303
607
|
telegram_transport: TelegramTransport | None = None,
|
|
608
|
+
workdir: Path | str | None = None,
|
|
304
609
|
) -> FastAPI:
|
|
305
610
|
ensure_logging_configured()
|
|
306
611
|
ensure_sandbox_available()
|
|
307
612
|
|
|
613
|
+
cwd = resolve_workdir(workdir)
|
|
308
614
|
store = StateStore()
|
|
615
|
+
compact_provider = LocalSummaryCompactProvider()
|
|
309
616
|
mcp_manager = McpManager(store=store, transport=mcp_transport)
|
|
310
617
|
telegram_bot_manager: TelegramBotManager | None = None
|
|
311
618
|
workspace_runs: dict[str, WorkspaceRun] = {}
|
|
@@ -313,12 +620,105 @@ def create_app(
|
|
|
313
620
|
|
|
314
621
|
static_dir = frontend_static_directory().resolve(strict=False)
|
|
315
622
|
logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
|
|
623
|
+
logger.info("Workdir: %s", cwd)
|
|
316
624
|
logger.info("Static directory: %s", static_dir)
|
|
317
625
|
|
|
626
|
+
def request_messages_for_content(
|
|
627
|
+
state: StoredState,
|
|
628
|
+
messages: list[StoredMessage],
|
|
629
|
+
content: str,
|
|
630
|
+
) -> list[dict[str, object]]:
|
|
631
|
+
compacted_context = store.read_compacted_context()
|
|
632
|
+
checkpoint = store.read_active_compaction_checkpoint()
|
|
633
|
+
chat_messages = workspace_chat_messages(
|
|
634
|
+
messages,
|
|
635
|
+
compacted_context,
|
|
636
|
+
checkpoint,
|
|
637
|
+
)
|
|
638
|
+
return [
|
|
639
|
+
message.model_dump()
|
|
640
|
+
for message in [
|
|
641
|
+
*runtime_context_messages(cwd, state.settings.agent_prompt),
|
|
642
|
+
*explicit_skill_messages(cwd, store, content),
|
|
643
|
+
*chat_messages,
|
|
644
|
+
]
|
|
645
|
+
]
|
|
646
|
+
|
|
647
|
+
async def save_context_checkpoint(
|
|
648
|
+
*,
|
|
649
|
+
connection: ProviderConnection,
|
|
650
|
+
messages: list[StoredMessage],
|
|
651
|
+
model_history: list[ChatMessage],
|
|
652
|
+
marker_content: str,
|
|
653
|
+
source_message_id: str | None = None,
|
|
654
|
+
trigger: Literal["manual", "auto"],
|
|
655
|
+
) -> tuple[StoredMessage, list[dict[str, object]]]:
|
|
656
|
+
marker = StoredMessage(
|
|
657
|
+
author="system",
|
|
658
|
+
content=marker_content,
|
|
659
|
+
id=str(uuid4()),
|
|
660
|
+
)
|
|
661
|
+
compact_result = await compact_provider.compact(
|
|
662
|
+
connection,
|
|
663
|
+
CompactInput(
|
|
664
|
+
messages=messages,
|
|
665
|
+
model_history=model_history,
|
|
666
|
+
retained_message_token_budget=AUTO_COMPACT_RETAINED_MESSAGE_TOKEN_BUDGET,
|
|
667
|
+
trigger=trigger,
|
|
668
|
+
),
|
|
669
|
+
completion=chat_completion,
|
|
670
|
+
)
|
|
671
|
+
store.save_compaction_checkpoint(
|
|
672
|
+
StoredCompactionCheckpoint(
|
|
673
|
+
id=str(uuid4()),
|
|
674
|
+
method=compact_result.method,
|
|
675
|
+
replacement_history=compact_result.replacement_history,
|
|
676
|
+
source_message_id=source_message_id or marker.id,
|
|
677
|
+
summary=compact_result.summary,
|
|
678
|
+
token_after=compact_result.token_after,
|
|
679
|
+
token_before=compact_result.token_before,
|
|
680
|
+
trigger=trigger,
|
|
681
|
+
)
|
|
682
|
+
)
|
|
683
|
+
logger.info(
|
|
684
|
+
"Workspace compact checkpoint saved trigger=%s method=%s summary_length=%s token_before=%s token_after=%s",
|
|
685
|
+
trigger,
|
|
686
|
+
compact_result.method,
|
|
687
|
+
len(compact_result.summary),
|
|
688
|
+
compact_result.token_before,
|
|
689
|
+
compact_result.token_after,
|
|
690
|
+
)
|
|
691
|
+
logger.log(TRACE_LEVEL, "Workspace compact summary=%r", compact_result.summary)
|
|
692
|
+
return marker, [
|
|
693
|
+
message.model_dump() for message in compact_result.replacement_history
|
|
694
|
+
]
|
|
695
|
+
|
|
696
|
+
async def auto_compact_workspace_messages(
|
|
697
|
+
*,
|
|
698
|
+
connection: ProviderConnection,
|
|
699
|
+
messages: list[StoredMessage],
|
|
700
|
+
model_history: list[ChatMessage],
|
|
701
|
+
source_message_id: str | None = None,
|
|
702
|
+
) -> tuple[StoredMessage, list[dict[str, object]]] | None:
|
|
703
|
+
if not should_auto_compact(model_history):
|
|
704
|
+
return None
|
|
705
|
+
logger.info("Workspace auto compact requested")
|
|
706
|
+
try:
|
|
707
|
+
return await save_context_checkpoint(
|
|
708
|
+
connection=connection,
|
|
709
|
+
marker_content=OPTIMIZED_CONTEXT_MARKER,
|
|
710
|
+
messages=messages,
|
|
711
|
+
model_history=model_history,
|
|
712
|
+
source_message_id=source_message_id,
|
|
713
|
+
trigger="auto",
|
|
714
|
+
)
|
|
715
|
+
except Exception as error:
|
|
716
|
+
logger.exception("Workspace auto compact failed")
|
|
717
|
+
raise RuntimeError("Context could not be optimized.") from error
|
|
718
|
+
|
|
318
719
|
async def run_workspace_turn(content: str) -> StoredMessage:
|
|
319
720
|
state = store.read_state()
|
|
320
721
|
connection = selected_connection(state)
|
|
321
|
-
cwd = Path.cwd()
|
|
322
722
|
user_message = StoredMessage(
|
|
323
723
|
author="user",
|
|
324
724
|
content=content,
|
|
@@ -326,23 +726,54 @@ def create_app(
|
|
|
326
726
|
)
|
|
327
727
|
next_messages = [*state.messages, user_message]
|
|
328
728
|
store.save_messages(next_messages)
|
|
329
|
-
|
|
330
|
-
|
|
331
|
-
|
|
332
|
-
|
|
333
|
-
|
|
334
|
-
|
|
335
|
-
|
|
336
|
-
for message in [
|
|
337
|
-
*runtime_context_messages(cwd),
|
|
338
|
-
*skill_messages,
|
|
339
|
-
*chat_messages,
|
|
340
|
-
]
|
|
729
|
+
model_history = [
|
|
730
|
+
*runtime_context_messages(cwd, state.settings.agent_prompt),
|
|
731
|
+
*workspace_chat_messages(
|
|
732
|
+
state.messages,
|
|
733
|
+
store.read_compacted_context(),
|
|
734
|
+
store.read_active_compaction_checkpoint(),
|
|
735
|
+
),
|
|
341
736
|
]
|
|
342
|
-
|
|
343
|
-
|
|
344
|
-
|
|
737
|
+
auto_compaction = await auto_compact_workspace_messages(
|
|
738
|
+
connection=connection,
|
|
739
|
+
messages=state.messages,
|
|
740
|
+
model_history=model_history,
|
|
741
|
+
source_message_id=None,
|
|
742
|
+
)
|
|
743
|
+
if auto_compaction is not None:
|
|
744
|
+
marker, _ = auto_compaction
|
|
745
|
+
next_messages = [*state.messages, marker, user_message]
|
|
746
|
+
store.save_messages(next_messages)
|
|
747
|
+
request_messages = request_messages_for_content(state, next_messages, content)
|
|
345
748
|
assistant_id = str(uuid4())
|
|
749
|
+
assistant_output = AssistantOutputBuilder(assistant_id)
|
|
750
|
+
|
|
751
|
+
async def review_tool_approval(request: ApprovalReviewRequest):
|
|
752
|
+
return await review_approval_request(
|
|
753
|
+
connection,
|
|
754
|
+
request.model_copy(
|
|
755
|
+
update={
|
|
756
|
+
"transcript": approval_transcript(next_messages),
|
|
757
|
+
"user_request": content,
|
|
758
|
+
}
|
|
759
|
+
),
|
|
760
|
+
completion=chat_completion,
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
async def tool_runner(
|
|
764
|
+
name: str,
|
|
765
|
+
arguments: dict[str, object],
|
|
766
|
+
context: ToolContext,
|
|
767
|
+
):
|
|
768
|
+
return await run_tool_with_path_permissions(
|
|
769
|
+
name,
|
|
770
|
+
arguments,
|
|
771
|
+
context,
|
|
772
|
+
review_approval=review_tool_approval,
|
|
773
|
+
writable_paths=[
|
|
774
|
+
Path(path.path) for path in store.read_writable_paths()
|
|
775
|
+
],
|
|
776
|
+
)
|
|
346
777
|
|
|
347
778
|
async for event in run_agent_stream(
|
|
348
779
|
completion=chat_completion,
|
|
@@ -352,40 +783,44 @@ def create_app(
|
|
|
352
783
|
extra_tool_specs=mcp_manager.tool_specs(),
|
|
353
784
|
extra_tool_title=mcp_manager.tool_title,
|
|
354
785
|
messages=request_messages,
|
|
786
|
+
tool_runner=tool_runner,
|
|
355
787
|
):
|
|
788
|
+
if event.event == "start":
|
|
789
|
+
event_id = event.data.get("id")
|
|
790
|
+
if isinstance(event_id, str):
|
|
791
|
+
assistant_id = event_id
|
|
792
|
+
assistant_output.set_assistant_id(event_id)
|
|
793
|
+
if event.event == "output_start":
|
|
794
|
+
index = event.data.get("index")
|
|
795
|
+
if isinstance(index, int):
|
|
796
|
+
assistant_output.start_group(index)
|
|
356
797
|
if event.event == "delta":
|
|
357
|
-
|
|
798
|
+
assistant_output.append_text(str(event.data.get("content") or ""))
|
|
358
799
|
if event.event == "thinking_delta":
|
|
359
|
-
|
|
800
|
+
assistant_output.append_thinking(str(event.data.get("content") or ""))
|
|
360
801
|
if event.event == "tool_start":
|
|
361
802
|
tool = event.data.get("tool")
|
|
362
803
|
if isinstance(tool, dict) and isinstance(tool.get("id"), str):
|
|
363
|
-
|
|
804
|
+
assistant_output.start_tool(StoredToolItem.model_validate(tool))
|
|
364
805
|
if event.event in {"tool_done", "tool_error"}:
|
|
365
806
|
tool_id = event.data.get("id")
|
|
366
|
-
if isinstance(tool_id, str)
|
|
367
|
-
|
|
368
|
-
{
|
|
369
|
-
**assistant_tools[tool_id].model_dump(exclude_none=True),
|
|
370
|
-
**event.data,
|
|
371
|
-
}
|
|
372
|
-
)
|
|
807
|
+
if isinstance(tool_id, str):
|
|
808
|
+
assistant_output.update_tool(tool_id, event.data)
|
|
373
809
|
if event.event == "done":
|
|
374
810
|
message = event.data.get("message")
|
|
375
811
|
if isinstance(message, dict):
|
|
376
812
|
assistant_id = str(message.get("id") or assistant_id)
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
message.get("thinking") or assistant_thinking
|
|
380
|
-
)
|
|
813
|
+
assistant_output.set_assistant_id(assistant_id)
|
|
814
|
+
assistant_output.apply_done_message(message)
|
|
381
815
|
|
|
382
816
|
assistant_message = StoredMessage(
|
|
383
817
|
author="assistant",
|
|
384
|
-
content=
|
|
818
|
+
content=assistant_output.content,
|
|
819
|
+
groups=assistant_output.groups,
|
|
385
820
|
id=assistant_id,
|
|
386
821
|
status="completed",
|
|
387
|
-
thinking=
|
|
388
|
-
tools=list(
|
|
822
|
+
thinking=assistant_output.thinking,
|
|
823
|
+
tools=list(assistant_output.tools.values()),
|
|
389
824
|
)
|
|
390
825
|
store.save_messages([*next_messages, assistant_message])
|
|
391
826
|
return assistant_message
|
|
@@ -437,7 +872,7 @@ def create_app(
|
|
|
437
872
|
if active_run and not active_run.is_done
|
|
438
873
|
else None,
|
|
439
874
|
"mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
|
|
440
|
-
"skills": discover_skills(
|
|
875
|
+
"skills": discover_skills(cwd, store),
|
|
441
876
|
}
|
|
442
877
|
if telegram_bot_manager is not None:
|
|
443
878
|
update["telegram_bot"] = telegram_bot_manager.bot_with_status(
|
|
@@ -462,12 +897,12 @@ def create_app(
|
|
|
462
897
|
async def preview_mcp_import(
|
|
463
898
|
request: McpImportPreviewRequest,
|
|
464
899
|
) -> McpImportDiscovery:
|
|
465
|
-
return discover_imported_mcp_servers(
|
|
900
|
+
return discover_imported_mcp_servers(cwd, source=request.source)
|
|
466
901
|
|
|
467
902
|
@app.post("/api/mcp/import")
|
|
468
903
|
async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
|
|
469
904
|
imported_servers = discover_imported_mcp_servers(
|
|
470
|
-
|
|
905
|
+
cwd,
|
|
471
906
|
source=request.source,
|
|
472
907
|
).servers
|
|
473
908
|
existing_servers = {server.id for server in store.read_mcp_servers()}
|
|
@@ -498,7 +933,7 @@ def create_app(
|
|
|
498
933
|
|
|
499
934
|
@app.post("/api/skills/reload")
|
|
500
935
|
async def reload_skills() -> list[StoredSkill]:
|
|
501
|
-
return discover_skills(
|
|
936
|
+
return discover_skills(cwd, store)
|
|
502
937
|
|
|
503
938
|
@app.put("/api/skills/{skill_id:path}")
|
|
504
939
|
async def save_skill_settings(
|
|
@@ -506,7 +941,7 @@ def create_app(
|
|
|
506
941
|
request: SkillSettingsRequest,
|
|
507
942
|
) -> StoredSkill:
|
|
508
943
|
try:
|
|
509
|
-
return update_skill_enabled(
|
|
944
|
+
return update_skill_enabled(cwd, store, skill_id, request.enabled)
|
|
510
945
|
except KeyError as error:
|
|
511
946
|
raise HTTPException(status_code=404, detail="Skill not found.") from error
|
|
512
947
|
|
|
@@ -548,9 +983,7 @@ def create_app(
|
|
|
548
983
|
async def save_writable_path(
|
|
549
984
|
request: WritablePathRequest,
|
|
550
985
|
) -> StoredWritablePath:
|
|
551
|
-
return store.save_writable_path(
|
|
552
|
-
normalized_request_path(request.path, Path.cwd())
|
|
553
|
-
)
|
|
986
|
+
return store.save_writable_path(normalized_request_path(request.path, cwd))
|
|
554
987
|
|
|
555
988
|
@app.delete("/api/permissions/writable-paths")
|
|
556
989
|
async def delete_writable_path(
|
|
@@ -558,29 +991,10 @@ def create_app(
|
|
|
558
991
|
) -> WritablePathListResponse:
|
|
559
992
|
return WritablePathListResponse(
|
|
560
993
|
writable_paths=store.delete_writable_path(
|
|
561
|
-
normalized_request_path(request.path,
|
|
994
|
+
normalized_request_path(request.path, cwd)
|
|
562
995
|
)
|
|
563
996
|
)
|
|
564
997
|
|
|
565
|
-
@app.post("/api/workspace/permissions/approve")
|
|
566
|
-
async def approve_workspace_permission(
|
|
567
|
-
request: WorkspacePermissionDecisionRequest,
|
|
568
|
-
) -> dict[str, bool]:
|
|
569
|
-
run = active_workspace_run()
|
|
570
|
-
if run is None:
|
|
571
|
-
raise HTTPException(status_code=404, detail="Request not found.")
|
|
572
|
-
pending = run.pending_permissions.pop(request.id, None)
|
|
573
|
-
if pending is None:
|
|
574
|
-
raise HTTPException(status_code=404, detail="Request not found.")
|
|
575
|
-
path = pending.path
|
|
576
|
-
if request.decision == "always_allow":
|
|
577
|
-
saved_path = store.save_writable_path(path)
|
|
578
|
-
path = Path(saved_path.path)
|
|
579
|
-
pending.future.set_result(
|
|
580
|
-
WritablePathDecision(decision=request.decision, path=path)
|
|
581
|
-
)
|
|
582
|
-
return {"ok": True}
|
|
583
|
-
|
|
584
998
|
@app.put("/api/workspace/messages")
|
|
585
999
|
async def save_workspace_messages(
|
|
586
1000
|
request: WorkspaceMessagesRequest,
|
|
@@ -610,7 +1024,6 @@ def create_app(
|
|
|
610
1024
|
nonlocal active_workspace_run_id
|
|
611
1025
|
state = store.read_state()
|
|
612
1026
|
connection = selected_connection(state)
|
|
613
|
-
cwd = Path.cwd()
|
|
614
1027
|
|
|
615
1028
|
user_message = StoredMessage(
|
|
616
1029
|
author="user",
|
|
@@ -619,43 +1032,31 @@ def create_app(
|
|
|
619
1032
|
)
|
|
620
1033
|
next_messages = [*state.messages, user_message]
|
|
621
1034
|
store.save_messages(next_messages)
|
|
622
|
-
chat_messages = workspace_chat_messages(
|
|
623
|
-
next_messages,
|
|
624
|
-
store.read_compacted_context(),
|
|
625
|
-
)
|
|
626
|
-
request_messages = [
|
|
627
|
-
message.model_dump()
|
|
628
|
-
for message in [
|
|
629
|
-
*runtime_context_messages(cwd),
|
|
630
|
-
*explicit_skill_messages(cwd, store, content),
|
|
631
|
-
*chat_messages,
|
|
632
|
-
]
|
|
633
|
-
]
|
|
634
1035
|
run = WorkspaceRun(condition=asyncio.Condition())
|
|
635
1036
|
workspace_runs[run.id] = run
|
|
636
1037
|
active_workspace_run_id = run.id
|
|
637
1038
|
|
|
638
1039
|
async def run_task() -> None:
|
|
639
1040
|
nonlocal active_workspace_run_id
|
|
640
|
-
|
|
1041
|
+
nonlocal next_messages
|
|
641
1042
|
assistant_message = StoredMessage(
|
|
642
1043
|
author="assistant",
|
|
643
1044
|
content="",
|
|
644
1045
|
id=str(uuid4()),
|
|
645
1046
|
status="running",
|
|
646
1047
|
)
|
|
647
|
-
|
|
648
|
-
assistant_thinking = ""
|
|
1048
|
+
assistant_output = AssistantOutputBuilder(assistant_message.id)
|
|
649
1049
|
|
|
650
1050
|
def persist_assistant(status: str = "running") -> None:
|
|
651
1051
|
nonlocal next_messages, assistant_message
|
|
652
1052
|
assistant_message = StoredMessage(
|
|
653
1053
|
author="assistant",
|
|
654
|
-
content=
|
|
1054
|
+
content=assistant_output.content,
|
|
1055
|
+
groups=assistant_output.groups,
|
|
655
1056
|
id=assistant_message.id,
|
|
656
1057
|
status=status,
|
|
657
|
-
thinking=
|
|
658
|
-
tools=list(
|
|
1058
|
+
thinking=assistant_output.thinking,
|
|
1059
|
+
tools=list(assistant_output.tools.values()),
|
|
659
1060
|
)
|
|
660
1061
|
next_messages = append_or_replace_message(
|
|
661
1062
|
next_messages, assistant_message
|
|
@@ -663,26 +1064,52 @@ def create_app(
|
|
|
663
1064
|
store.upsert_message(assistant_message)
|
|
664
1065
|
|
|
665
1066
|
try:
|
|
666
|
-
|
|
667
|
-
|
|
668
|
-
|
|
669
|
-
|
|
670
|
-
|
|
671
|
-
|
|
672
|
-
|
|
673
|
-
|
|
674
|
-
|
|
675
|
-
|
|
1067
|
+
current_tool_id: str | None = None
|
|
1068
|
+
current_request_messages = request_messages_for_content(
|
|
1069
|
+
state,
|
|
1070
|
+
next_messages,
|
|
1071
|
+
content,
|
|
1072
|
+
)
|
|
1073
|
+
pre_turn_request_messages = request_messages_for_content(
|
|
1074
|
+
state,
|
|
1075
|
+
state.messages,
|
|
1076
|
+
content,
|
|
1077
|
+
)
|
|
1078
|
+
auto_compaction = await auto_compact_workspace_messages(
|
|
1079
|
+
connection=connection,
|
|
1080
|
+
messages=state.messages,
|
|
1081
|
+
model_history=[
|
|
1082
|
+
ChatMessage.model_validate(message)
|
|
1083
|
+
for message in pre_turn_request_messages
|
|
1084
|
+
],
|
|
1085
|
+
source_message_id=None,
|
|
1086
|
+
)
|
|
1087
|
+
if auto_compaction is not None:
|
|
1088
|
+
marker, _ = auto_compaction
|
|
1089
|
+
next_messages = [*state.messages, marker, user_message]
|
|
1090
|
+
store.save_messages(next_messages)
|
|
676
1091
|
await append_run_event(
|
|
677
1092
|
run,
|
|
678
|
-
"
|
|
679
|
-
{
|
|
680
|
-
|
|
681
|
-
|
|
682
|
-
|
|
683
|
-
|
|
1093
|
+
"context_optimized",
|
|
1094
|
+
{"message": marker.model_dump()},
|
|
1095
|
+
)
|
|
1096
|
+
current_request_messages = request_messages_for_content(
|
|
1097
|
+
state,
|
|
1098
|
+
next_messages,
|
|
1099
|
+
content,
|
|
1100
|
+
)
|
|
1101
|
+
|
|
1102
|
+
async def review_tool_approval(request: ApprovalReviewRequest):
|
|
1103
|
+
return await review_approval_request(
|
|
1104
|
+
connection,
|
|
1105
|
+
request.model_copy(
|
|
1106
|
+
update={
|
|
1107
|
+
"transcript": approval_transcript(next_messages),
|
|
1108
|
+
"user_request": content,
|
|
1109
|
+
}
|
|
1110
|
+
),
|
|
1111
|
+
completion=chat_completion,
|
|
684
1112
|
)
|
|
685
|
-
return await future
|
|
686
1113
|
|
|
687
1114
|
async def tool_runner(
|
|
688
1115
|
name: str,
|
|
@@ -693,20 +1120,79 @@ def create_app(
|
|
|
693
1120
|
name,
|
|
694
1121
|
arguments,
|
|
695
1122
|
context,
|
|
696
|
-
|
|
1123
|
+
review_approval=review_tool_approval,
|
|
697
1124
|
writable_paths=[
|
|
698
1125
|
Path(path.path) for path in store.read_writable_paths()
|
|
699
1126
|
],
|
|
700
1127
|
)
|
|
701
1128
|
|
|
1129
|
+
async def context_compactor(
|
|
1130
|
+
conversation: Sequence[Mapping[str, object]],
|
|
1131
|
+
) -> AgentContextUpdate | None:
|
|
1132
|
+
nonlocal next_messages
|
|
1133
|
+
assistant_snapshot = StoredMessage(
|
|
1134
|
+
author="assistant",
|
|
1135
|
+
content=assistant_output.content,
|
|
1136
|
+
groups=assistant_output.groups,
|
|
1137
|
+
id=assistant_message.id,
|
|
1138
|
+
status="running",
|
|
1139
|
+
thinking=assistant_output.thinking,
|
|
1140
|
+
tools=list(assistant_output.tools.values()),
|
|
1141
|
+
)
|
|
1142
|
+
model_history: list[ChatMessage] = []
|
|
1143
|
+
for message in conversation:
|
|
1144
|
+
role_value = message.get("role")
|
|
1145
|
+
content = str(message.get("content") or "")
|
|
1146
|
+
if role_value == "system":
|
|
1147
|
+
model_history.append(
|
|
1148
|
+
ChatMessage(role="system", content=content)
|
|
1149
|
+
)
|
|
1150
|
+
if role_value == "user":
|
|
1151
|
+
model_history.append(
|
|
1152
|
+
ChatMessage(role="user", content=content)
|
|
1153
|
+
)
|
|
1154
|
+
if role_value == "assistant":
|
|
1155
|
+
model_history.append(
|
|
1156
|
+
ChatMessage(role="assistant", content=content)
|
|
1157
|
+
)
|
|
1158
|
+
if role_value == "tool":
|
|
1159
|
+
model_history.append(
|
|
1160
|
+
ChatMessage(
|
|
1161
|
+
role="user",
|
|
1162
|
+
content=f"Tool result: {content}",
|
|
1163
|
+
)
|
|
1164
|
+
)
|
|
1165
|
+
auto_result = await auto_compact_workspace_messages(
|
|
1166
|
+
connection=connection,
|
|
1167
|
+
messages=next_messages,
|
|
1168
|
+
model_history=model_history,
|
|
1169
|
+
source_message_id=assistant_snapshot.id,
|
|
1170
|
+
)
|
|
1171
|
+
if auto_result is None:
|
|
1172
|
+
return None
|
|
1173
|
+
marker, replacement_history = auto_result
|
|
1174
|
+
next_messages = append_or_replace_message(
|
|
1175
|
+
[*next_messages, marker], assistant_snapshot
|
|
1176
|
+
)
|
|
1177
|
+
store.save_messages(next_messages)
|
|
1178
|
+
compacted_conversation = [
|
|
1179
|
+
dict(conversation[0]),
|
|
1180
|
+
*replacement_history,
|
|
1181
|
+
]
|
|
1182
|
+
return AgentContextUpdate(
|
|
1183
|
+
conversation=compacted_conversation,
|
|
1184
|
+
message=marker.model_dump(),
|
|
1185
|
+
)
|
|
1186
|
+
|
|
702
1187
|
async for event in run_agent_stream(
|
|
703
1188
|
completion=chat_completion,
|
|
704
1189
|
connection=connection,
|
|
1190
|
+
context_compactor=context_compactor,
|
|
705
1191
|
cwd=cwd,
|
|
706
1192
|
extra_tool_runner=mcp_manager.run_tool,
|
|
707
1193
|
extra_tool_specs=mcp_manager.tool_specs(),
|
|
708
1194
|
extra_tool_title=mcp_manager.tool_title,
|
|
709
|
-
messages=
|
|
1195
|
+
messages=current_request_messages,
|
|
710
1196
|
tool_runner=tool_runner,
|
|
711
1197
|
):
|
|
712
1198
|
if event.event == "start":
|
|
@@ -715,31 +1201,41 @@ def create_app(
|
|
|
715
1201
|
assistant_message = assistant_message.model_copy(
|
|
716
1202
|
update={"id": event_id}
|
|
717
1203
|
)
|
|
1204
|
+
assistant_output.set_assistant_id(event_id)
|
|
1205
|
+
persist_assistant()
|
|
1206
|
+
if event.event == "output_start":
|
|
1207
|
+
index = event.data.get("index")
|
|
1208
|
+
if isinstance(index, int):
|
|
1209
|
+
assistant_output.start_group(index)
|
|
718
1210
|
persist_assistant()
|
|
719
1211
|
if event.event == "tool_start":
|
|
720
1212
|
tool = event.data.get("tool")
|
|
721
1213
|
if isinstance(tool, dict) and isinstance(tool.get("id"), str):
|
|
722
|
-
|
|
723
|
-
|
|
1214
|
+
current_tool_id = tool["id"]
|
|
1215
|
+
assistant_output.start_tool(
|
|
1216
|
+
StoredToolItem.model_validate(tool)
|
|
724
1217
|
)
|
|
725
1218
|
persist_assistant()
|
|
726
1219
|
if event.event in {"tool_done", "tool_error"}:
|
|
727
1220
|
tool_id = event.data.get("id")
|
|
728
|
-
if
|
|
729
|
-
|
|
730
|
-
|
|
731
|
-
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
**event.data,
|
|
735
|
-
}
|
|
1221
|
+
if (
|
|
1222
|
+
isinstance(tool_id, str)
|
|
1223
|
+
and tool_id in assistant_output.tools
|
|
1224
|
+
):
|
|
1225
|
+
current_tool_id = (
|
|
1226
|
+
None if current_tool_id == tool_id else current_tool_id
|
|
736
1227
|
)
|
|
1228
|
+
assistant_output.update_tool(tool_id, event.data)
|
|
737
1229
|
persist_assistant()
|
|
738
1230
|
if event.event == "delta":
|
|
739
|
-
|
|
1231
|
+
assistant_output.append_text(
|
|
1232
|
+
str(event.data.get("content") or "")
|
|
1233
|
+
)
|
|
740
1234
|
persist_assistant()
|
|
741
1235
|
if event.event == "thinking_delta":
|
|
742
|
-
|
|
1236
|
+
assistant_output.append_thinking(
|
|
1237
|
+
str(event.data.get("content") or "")
|
|
1238
|
+
)
|
|
743
1239
|
persist_assistant()
|
|
744
1240
|
logger.log(
|
|
745
1241
|
TRACE_LEVEL,
|
|
@@ -750,12 +1246,7 @@ def create_app(
|
|
|
750
1246
|
if event.event == "done":
|
|
751
1247
|
message = event.data.get("message")
|
|
752
1248
|
if isinstance(message, dict):
|
|
753
|
-
|
|
754
|
-
message.get("content") or assistant_content
|
|
755
|
-
)
|
|
756
|
-
assistant_thinking = str(
|
|
757
|
-
message.get("thinking") or assistant_thinking
|
|
758
|
-
)
|
|
1249
|
+
assistant_output.apply_done_message(message)
|
|
759
1250
|
persist_assistant("completed")
|
|
760
1251
|
await append_run_event(run, event.event, event.data)
|
|
761
1252
|
except asyncio.CancelledError:
|
|
@@ -770,12 +1261,23 @@ def create_app(
|
|
|
770
1261
|
raise
|
|
771
1262
|
except Exception as error:
|
|
772
1263
|
logger.exception("Workspace response failed")
|
|
773
|
-
|
|
774
|
-
|
|
775
|
-
|
|
776
|
-
"
|
|
777
|
-
|
|
1264
|
+
if (
|
|
1265
|
+
current_tool_id is not None
|
|
1266
|
+
and current_tool_id in assistant_output.tools
|
|
1267
|
+
and assistant_output.tools[current_tool_id].status == "running"
|
|
1268
|
+
):
|
|
1269
|
+
assistant_output.update_tool(
|
|
1270
|
+
current_tool_id,
|
|
1271
|
+
{"content": str(error) or "Tool failed.", "status": "failed"},
|
|
1272
|
+
)
|
|
1273
|
+
error_item = assistant_output.append_error(
|
|
1274
|
+
run_error_output_item(
|
|
1275
|
+
assistant_message.id,
|
|
1276
|
+
str(error) or EMPTY_MODEL_RESPONSE_DETAIL,
|
|
1277
|
+
)
|
|
778
1278
|
)
|
|
1279
|
+
persist_assistant("failed")
|
|
1280
|
+
await append_run_event(run, "error", run_error_event_data(error_item))
|
|
779
1281
|
finally:
|
|
780
1282
|
run.is_done = True
|
|
781
1283
|
async with run.condition:
|
|
@@ -843,21 +1345,32 @@ def create_app(
|
|
|
843
1345
|
|
|
844
1346
|
@app.post("/api/workspace/compact")
|
|
845
1347
|
async def compact_workspace() -> WorkspaceCompactResponse:
|
|
1348
|
+
if active_workspace_run() is not None:
|
|
1349
|
+
raise HTTPException(
|
|
1350
|
+
status_code=409,
|
|
1351
|
+
detail="Compact is unavailable while Flowent is responding.",
|
|
1352
|
+
)
|
|
846
1353
|
logger.info("Workspace compact requested")
|
|
847
1354
|
state = store.read_state()
|
|
848
1355
|
connection = selected_connection(state)
|
|
849
|
-
|
|
850
|
-
|
|
1356
|
+
checkpoint = store.read_active_compaction_checkpoint()
|
|
1357
|
+
model_history = [
|
|
1358
|
+
*runtime_context_messages(cwd, state.settings.agent_prompt),
|
|
1359
|
+
*workspace_chat_messages(
|
|
1360
|
+
state.messages,
|
|
1361
|
+
store.read_compacted_context(),
|
|
1362
|
+
checkpoint,
|
|
1363
|
+
),
|
|
1364
|
+
]
|
|
851
1365
|
|
|
852
1366
|
try:
|
|
853
|
-
|
|
854
|
-
connection,
|
|
855
|
-
|
|
856
|
-
|
|
857
|
-
|
|
858
|
-
|
|
859
|
-
|
|
860
|
-
completion=chat_completion,
|
|
1367
|
+
marker, _ = await save_context_checkpoint(
|
|
1368
|
+
connection=connection,
|
|
1369
|
+
marker_content=COMPACTED_CONTEXT_MARKER,
|
|
1370
|
+
messages=state.messages,
|
|
1371
|
+
model_history=model_history,
|
|
1372
|
+
source_message_id=None,
|
|
1373
|
+
trigger="manual",
|
|
861
1374
|
)
|
|
862
1375
|
except HTTPException:
|
|
863
1376
|
raise
|
|
@@ -868,17 +1381,8 @@ def create_app(
|
|
|
868
1381
|
detail="Context could not be compacted.",
|
|
869
1382
|
) from error
|
|
870
1383
|
|
|
871
|
-
marker = StoredMessage(
|
|
872
|
-
author="system",
|
|
873
|
-
content=COMPACTED_CONTEXT_MARKER,
|
|
874
|
-
id=str(uuid4()),
|
|
875
|
-
)
|
|
876
|
-
store.save_compacted_context(summary.content)
|
|
877
1384
|
store.save_messages([*state.messages, marker])
|
|
878
|
-
logger.info(
|
|
879
|
-
"Workspace compact completed summary_length=%s", len(summary.content)
|
|
880
|
-
)
|
|
881
|
-
logger.log(TRACE_LEVEL, "Workspace compact summary=%r", summary.content)
|
|
1385
|
+
logger.info("Workspace compact completed")
|
|
882
1386
|
return WorkspaceCompactResponse(message=marker)
|
|
883
1387
|
|
|
884
1388
|
@app.post("/api/workspace/respond")
|