flowent 0.1.4 → 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 +4 -2
- 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 +639 -210
- package/backend/src/flowent/patch.py +55 -31
- package/backend/src/flowent/permissions.py +185 -42
- package/backend/src/flowent/sandbox.py +55 -1
- 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 +113 -18
- 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 +77 -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_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 +54 -0
- package/backend/tests/test_workspace_chat.py +855 -41
- 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 +1 -1
- package/backend/src/flowent/static/assets/index-BREidonU.css +0 -2
- package/backend/src/flowent/static/assets/index-DSniOrhL.js +0 -81
- package/dist/frontend/assets/index-BREidonU.css +0 -2
- package/dist/frontend/assets/index-DSniOrhL.js +0 -81
|
@@ -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,7 +15,12 @@ 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
|
|
20
25
|
from flowent.compact import (
|
|
21
26
|
CompactInput,
|
|
@@ -30,11 +35,15 @@ from flowent.llm import (
|
|
|
30
35
|
ProviderFormat,
|
|
31
36
|
list_provider_models,
|
|
32
37
|
)
|
|
33
|
-
from flowent.logging import
|
|
38
|
+
from flowent.logging import (
|
|
39
|
+
TRACE_LEVEL,
|
|
40
|
+
ensure_logging_configured,
|
|
41
|
+
redact_diagnostic_value,
|
|
42
|
+
)
|
|
34
43
|
from flowent.mcp import McpManager, McpTransport
|
|
35
44
|
from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
|
|
36
45
|
from flowent.paths import resolve_workdir
|
|
37
|
-
from flowent.permissions import
|
|
46
|
+
from flowent.permissions import run_tool_with_path_permissions
|
|
38
47
|
from flowent.sandbox import ensure_sandbox_available
|
|
39
48
|
from flowent.skills import (
|
|
40
49
|
discover_skills,
|
|
@@ -43,17 +52,21 @@ from flowent.skills import (
|
|
|
43
52
|
)
|
|
44
53
|
from flowent.storage import (
|
|
45
54
|
StateStore,
|
|
55
|
+
StoredAssistantOutputGroup,
|
|
46
56
|
StoredCompactionCheckpoint,
|
|
57
|
+
StoredErrorOutputItem,
|
|
47
58
|
StoredMcpServer,
|
|
48
59
|
StoredMessage,
|
|
49
|
-
StoredPermissionRequest,
|
|
50
60
|
StoredProvider,
|
|
51
61
|
StoredSettings,
|
|
52
62
|
StoredSkill,
|
|
53
63
|
StoredState,
|
|
54
64
|
StoredTelegramBot,
|
|
55
65
|
StoredTelegramSession,
|
|
66
|
+
StoredTextOutputItem,
|
|
67
|
+
StoredThinkingOutputItem,
|
|
56
68
|
StoredToolItem,
|
|
69
|
+
StoredToolOutputItem,
|
|
57
70
|
StoredWritablePath,
|
|
58
71
|
)
|
|
59
72
|
from flowent.tools import ToolContext
|
|
@@ -63,6 +76,11 @@ logger = logging.getLogger("flowent.main")
|
|
|
63
76
|
|
|
64
77
|
DEFAULT_STATIC_DIR = Path(__file__).parent / "static"
|
|
65
78
|
COMPACTED_CONTEXT_MARKER = "Context compacted"
|
|
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
|
|
66
84
|
|
|
67
85
|
|
|
68
86
|
class ProviderModelsRequest(BaseModel):
|
|
@@ -144,21 +162,6 @@ class WritablePathListResponse(BaseModel):
|
|
|
144
162
|
writable_paths: list[StoredWritablePath]
|
|
145
163
|
|
|
146
164
|
|
|
147
|
-
class WorkspacePermissionDecisionRequest(BaseModel):
|
|
148
|
-
model_config = ConfigDict(extra="forbid")
|
|
149
|
-
|
|
150
|
-
decision: Literal["allow_once", "always_allow", "deny"]
|
|
151
|
-
id: str
|
|
152
|
-
|
|
153
|
-
|
|
154
|
-
@dataclass
|
|
155
|
-
class PendingWorkspacePermission:
|
|
156
|
-
future: asyncio.Future[WritablePathDecision]
|
|
157
|
-
path: Path
|
|
158
|
-
reason: str
|
|
159
|
-
tool_call_id: str | None = None
|
|
160
|
-
|
|
161
|
-
|
|
162
165
|
@dataclass
|
|
163
166
|
class WorkspaceRun:
|
|
164
167
|
condition: asyncio.Condition
|
|
@@ -166,26 +169,12 @@ class WorkspaceRun:
|
|
|
166
169
|
events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
|
|
167
170
|
id: str = field(default_factory=lambda: str(uuid4()))
|
|
168
171
|
is_done: bool = False
|
|
169
|
-
pending_permissions: dict[str, PendingWorkspacePermission] = field(
|
|
170
|
-
default_factory=dict
|
|
171
|
-
)
|
|
172
172
|
task: asyncio.Task[None] | None = None
|
|
173
173
|
|
|
174
174
|
@property
|
|
175
175
|
def latest_event_index(self) -> int:
|
|
176
176
|
return self.events[-1][0] if self.events else 0
|
|
177
177
|
|
|
178
|
-
def permission_requests(self) -> list[StoredPermissionRequest]:
|
|
179
|
-
return [
|
|
180
|
-
StoredPermissionRequest(
|
|
181
|
-
id=permission_id,
|
|
182
|
-
path=str(permission.path),
|
|
183
|
-
reason=permission.reason,
|
|
184
|
-
tool_call_id=permission.tool_call_id,
|
|
185
|
-
)
|
|
186
|
-
for permission_id, permission in self.pending_permissions.items()
|
|
187
|
-
]
|
|
188
|
-
|
|
189
178
|
|
|
190
179
|
def stream_event(event: str, data: dict[str, object]) -> str:
|
|
191
180
|
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
@@ -200,6 +189,265 @@ def append_or_replace_message(
|
|
|
200
189
|
]
|
|
201
190
|
|
|
202
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
|
+
|
|
203
451
|
def frontend_static_directory() -> Path:
|
|
204
452
|
configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
|
|
205
453
|
if configured_directory:
|
|
@@ -247,11 +495,33 @@ def selected_connection(state: StoredState) -> ProviderConnection:
|
|
|
247
495
|
def latest_compacted_context_index(messages: list[StoredMessage]) -> int | None:
|
|
248
496
|
for index in range(len(messages) - 1, -1, -1):
|
|
249
497
|
message = messages[index]
|
|
250
|
-
if message.author == "system" and message
|
|
498
|
+
if message.author == "system" and is_context_marker(message):
|
|
251
499
|
return index
|
|
252
500
|
return None
|
|
253
501
|
|
|
254
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
|
+
|
|
255
525
|
def workspace_chat_messages(
|
|
256
526
|
messages: list[StoredMessage],
|
|
257
527
|
compacted_context: str = "",
|
|
@@ -266,15 +536,22 @@ def workspace_chat_messages(
|
|
|
266
536
|
checkpoint.source_message_id,
|
|
267
537
|
)
|
|
268
538
|
for message in visible_messages:
|
|
269
|
-
if (
|
|
270
|
-
message.author == "system"
|
|
271
|
-
and message.content == COMPACTED_CONTEXT_MARKER
|
|
272
|
-
):
|
|
539
|
+
if message.author == "system" and is_context_marker(message):
|
|
273
540
|
continue
|
|
274
541
|
if message.author not in ("user", "assistant"):
|
|
275
542
|
raise HTTPException(
|
|
276
543
|
status_code=400, detail="Message history is invalid."
|
|
277
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
|
|
278
555
|
checkpoint_role: Literal["user", "assistant"] = (
|
|
279
556
|
"user" if message.author == "user" else "assistant"
|
|
280
557
|
)
|
|
@@ -296,10 +573,18 @@ def workspace_chat_messages(
|
|
|
296
573
|
visible_messages = messages[marker_index + 1 :]
|
|
297
574
|
|
|
298
575
|
for message in visible_messages:
|
|
299
|
-
if message.author == "system" and message
|
|
576
|
+
if message.author == "system" and is_context_marker(message):
|
|
300
577
|
continue
|
|
301
578
|
if message.author not in ("user", "assistant"):
|
|
302
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
|
|
303
588
|
role: Literal["user", "assistant"] = (
|
|
304
589
|
"user" if message.author == "user" else "assistant"
|
|
305
590
|
)
|
|
@@ -338,6 +623,99 @@ def create_app(
|
|
|
338
623
|
logger.info("Workdir: %s", cwd)
|
|
339
624
|
logger.info("Static directory: %s", static_dir)
|
|
340
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
|
+
|
|
341
719
|
async def run_workspace_turn(content: str) -> StoredMessage:
|
|
342
720
|
state = store.read_state()
|
|
343
721
|
connection = selected_connection(state)
|
|
@@ -348,24 +726,54 @@ def create_app(
|
|
|
348
726
|
)
|
|
349
727
|
next_messages = [*state.messages, user_message]
|
|
350
728
|
store.save_messages(next_messages)
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
message.model_dump()
|
|
359
|
-
for message in [
|
|
360
|
-
*runtime_context_messages(cwd),
|
|
361
|
-
*skill_messages,
|
|
362
|
-
*chat_messages,
|
|
363
|
-
]
|
|
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
|
+
),
|
|
364
736
|
]
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
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)
|
|
368
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
|
+
)
|
|
369
777
|
|
|
370
778
|
async for event in run_agent_stream(
|
|
371
779
|
completion=chat_completion,
|
|
@@ -375,40 +783,44 @@ def create_app(
|
|
|
375
783
|
extra_tool_specs=mcp_manager.tool_specs(),
|
|
376
784
|
extra_tool_title=mcp_manager.tool_title,
|
|
377
785
|
messages=request_messages,
|
|
786
|
+
tool_runner=tool_runner,
|
|
378
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)
|
|
379
797
|
if event.event == "delta":
|
|
380
|
-
|
|
798
|
+
assistant_output.append_text(str(event.data.get("content") or ""))
|
|
381
799
|
if event.event == "thinking_delta":
|
|
382
|
-
|
|
800
|
+
assistant_output.append_thinking(str(event.data.get("content") or ""))
|
|
383
801
|
if event.event == "tool_start":
|
|
384
802
|
tool = event.data.get("tool")
|
|
385
803
|
if isinstance(tool, dict) and isinstance(tool.get("id"), str):
|
|
386
|
-
|
|
804
|
+
assistant_output.start_tool(StoredToolItem.model_validate(tool))
|
|
387
805
|
if event.event in {"tool_done", "tool_error"}:
|
|
388
806
|
tool_id = event.data.get("id")
|
|
389
|
-
if isinstance(tool_id, str)
|
|
390
|
-
|
|
391
|
-
{
|
|
392
|
-
**assistant_tools[tool_id].model_dump(exclude_none=True),
|
|
393
|
-
**event.data,
|
|
394
|
-
}
|
|
395
|
-
)
|
|
807
|
+
if isinstance(tool_id, str):
|
|
808
|
+
assistant_output.update_tool(tool_id, event.data)
|
|
396
809
|
if event.event == "done":
|
|
397
810
|
message = event.data.get("message")
|
|
398
811
|
if isinstance(message, dict):
|
|
399
812
|
assistant_id = str(message.get("id") or assistant_id)
|
|
400
|
-
|
|
401
|
-
|
|
402
|
-
message.get("thinking") or assistant_thinking
|
|
403
|
-
)
|
|
813
|
+
assistant_output.set_assistant_id(assistant_id)
|
|
814
|
+
assistant_output.apply_done_message(message)
|
|
404
815
|
|
|
405
816
|
assistant_message = StoredMessage(
|
|
406
817
|
author="assistant",
|
|
407
|
-
content=
|
|
818
|
+
content=assistant_output.content,
|
|
819
|
+
groups=assistant_output.groups,
|
|
408
820
|
id=assistant_id,
|
|
409
821
|
status="completed",
|
|
410
|
-
thinking=
|
|
411
|
-
tools=list(
|
|
822
|
+
thinking=assistant_output.thinking,
|
|
823
|
+
tools=list(assistant_output.tools.values()),
|
|
412
824
|
)
|
|
413
825
|
store.save_messages([*next_messages, assistant_message])
|
|
414
826
|
return assistant_message
|
|
@@ -460,9 +872,6 @@ def create_app(
|
|
|
460
872
|
if active_run and not active_run.is_done
|
|
461
873
|
else None,
|
|
462
874
|
"mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
|
|
463
|
-
"permission_requests": active_run.permission_requests()
|
|
464
|
-
if active_run and not active_run.is_done
|
|
465
|
-
else [],
|
|
466
875
|
"skills": discover_skills(cwd, store),
|
|
467
876
|
}
|
|
468
877
|
if telegram_bot_manager is not None:
|
|
@@ -586,25 +995,6 @@ def create_app(
|
|
|
586
995
|
)
|
|
587
996
|
)
|
|
588
997
|
|
|
589
|
-
@app.post("/api/workspace/permissions/approve")
|
|
590
|
-
async def approve_workspace_permission(
|
|
591
|
-
request: WorkspacePermissionDecisionRequest,
|
|
592
|
-
) -> dict[str, bool]:
|
|
593
|
-
run = active_workspace_run()
|
|
594
|
-
if run is None:
|
|
595
|
-
raise HTTPException(status_code=404, detail="Request not found.")
|
|
596
|
-
pending = run.pending_permissions.pop(request.id, None)
|
|
597
|
-
if pending is None:
|
|
598
|
-
raise HTTPException(status_code=404, detail="Request not found.")
|
|
599
|
-
path = pending.path
|
|
600
|
-
if request.decision == "always_allow":
|
|
601
|
-
saved_path = store.save_writable_path(path)
|
|
602
|
-
path = Path(saved_path.path)
|
|
603
|
-
pending.future.set_result(
|
|
604
|
-
WritablePathDecision(decision=request.decision, path=path)
|
|
605
|
-
)
|
|
606
|
-
return {"ok": True}
|
|
607
|
-
|
|
608
998
|
@app.put("/api/workspace/messages")
|
|
609
999
|
async def save_workspace_messages(
|
|
610
1000
|
request: WorkspaceMessagesRequest,
|
|
@@ -642,44 +1032,31 @@ def create_app(
|
|
|
642
1032
|
)
|
|
643
1033
|
next_messages = [*state.messages, user_message]
|
|
644
1034
|
store.save_messages(next_messages)
|
|
645
|
-
chat_messages = workspace_chat_messages(
|
|
646
|
-
next_messages,
|
|
647
|
-
store.read_compacted_context(),
|
|
648
|
-
store.read_active_compaction_checkpoint(),
|
|
649
|
-
)
|
|
650
|
-
request_messages = [
|
|
651
|
-
message.model_dump()
|
|
652
|
-
for message in [
|
|
653
|
-
*runtime_context_messages(cwd),
|
|
654
|
-
*explicit_skill_messages(cwd, store, content),
|
|
655
|
-
*chat_messages,
|
|
656
|
-
]
|
|
657
|
-
]
|
|
658
1035
|
run = WorkspaceRun(condition=asyncio.Condition())
|
|
659
1036
|
workspace_runs[run.id] = run
|
|
660
1037
|
active_workspace_run_id = run.id
|
|
661
1038
|
|
|
662
1039
|
async def run_task() -> None:
|
|
663
1040
|
nonlocal active_workspace_run_id
|
|
664
|
-
|
|
1041
|
+
nonlocal next_messages
|
|
665
1042
|
assistant_message = StoredMessage(
|
|
666
1043
|
author="assistant",
|
|
667
1044
|
content="",
|
|
668
1045
|
id=str(uuid4()),
|
|
669
1046
|
status="running",
|
|
670
1047
|
)
|
|
671
|
-
|
|
672
|
-
assistant_thinking = ""
|
|
1048
|
+
assistant_output = AssistantOutputBuilder(assistant_message.id)
|
|
673
1049
|
|
|
674
1050
|
def persist_assistant(status: str = "running") -> None:
|
|
675
1051
|
nonlocal next_messages, assistant_message
|
|
676
1052
|
assistant_message = StoredMessage(
|
|
677
1053
|
author="assistant",
|
|
678
|
-
content=
|
|
1054
|
+
content=assistant_output.content,
|
|
1055
|
+
groups=assistant_output.groups,
|
|
679
1056
|
id=assistant_message.id,
|
|
680
1057
|
status=status,
|
|
681
|
-
thinking=
|
|
682
|
-
tools=list(
|
|
1058
|
+
thinking=assistant_output.thinking,
|
|
1059
|
+
tools=list(assistant_output.tools.values()),
|
|
683
1060
|
)
|
|
684
1061
|
next_messages = append_or_replace_message(
|
|
685
1062
|
next_messages, assistant_message
|
|
@@ -688,44 +1065,51 @@ def create_app(
|
|
|
688
1065
|
|
|
689
1066
|
try:
|
|
690
1067
|
current_tool_id: str | None = None
|
|
691
|
-
|
|
692
|
-
|
|
693
|
-
|
|
694
|
-
|
|
695
|
-
|
|
696
|
-
|
|
697
|
-
|
|
698
|
-
|
|
699
|
-
|
|
700
|
-
|
|
701
|
-
|
|
702
|
-
|
|
703
|
-
|
|
704
|
-
|
|
705
|
-
|
|
706
|
-
|
|
707
|
-
|
|
708
|
-
|
|
709
|
-
|
|
710
|
-
|
|
711
|
-
|
|
712
|
-
|
|
713
|
-
|
|
714
|
-
persist_assistant()
|
|
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)
|
|
715
1091
|
await append_run_event(
|
|
716
1092
|
run,
|
|
717
|
-
"
|
|
718
|
-
{
|
|
719
|
-
|
|
720
|
-
|
|
721
|
-
|
|
722
|
-
|
|
723
|
-
|
|
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,
|
|
724
1112
|
)
|
|
725
|
-
try:
|
|
726
|
-
return await future
|
|
727
|
-
finally:
|
|
728
|
-
run.pending_permissions.pop(permission_id, None)
|
|
729
1113
|
|
|
730
1114
|
async def tool_runner(
|
|
731
1115
|
name: str,
|
|
@@ -736,20 +1120,79 @@ def create_app(
|
|
|
736
1120
|
name,
|
|
737
1121
|
arguments,
|
|
738
1122
|
context,
|
|
739
|
-
|
|
1123
|
+
review_approval=review_tool_approval,
|
|
740
1124
|
writable_paths=[
|
|
741
1125
|
Path(path.path) for path in store.read_writable_paths()
|
|
742
1126
|
],
|
|
743
1127
|
)
|
|
744
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
|
+
|
|
745
1187
|
async for event in run_agent_stream(
|
|
746
1188
|
completion=chat_completion,
|
|
747
1189
|
connection=connection,
|
|
1190
|
+
context_compactor=context_compactor,
|
|
748
1191
|
cwd=cwd,
|
|
749
1192
|
extra_tool_runner=mcp_manager.run_tool,
|
|
750
1193
|
extra_tool_specs=mcp_manager.tool_specs(),
|
|
751
1194
|
extra_tool_title=mcp_manager.tool_title,
|
|
752
|
-
messages=
|
|
1195
|
+
messages=current_request_messages,
|
|
753
1196
|
tool_runner=tool_runner,
|
|
754
1197
|
):
|
|
755
1198
|
if event.event == "start":
|
|
@@ -758,35 +1201,41 @@ def create_app(
|
|
|
758
1201
|
assistant_message = assistant_message.model_copy(
|
|
759
1202
|
update={"id": event_id}
|
|
760
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)
|
|
761
1210
|
persist_assistant()
|
|
762
1211
|
if event.event == "tool_start":
|
|
763
1212
|
tool = event.data.get("tool")
|
|
764
1213
|
if isinstance(tool, dict) and isinstance(tool.get("id"), str):
|
|
765
1214
|
current_tool_id = tool["id"]
|
|
766
|
-
|
|
767
|
-
tool
|
|
1215
|
+
assistant_output.start_tool(
|
|
1216
|
+
StoredToolItem.model_validate(tool)
|
|
768
1217
|
)
|
|
769
1218
|
persist_assistant()
|
|
770
1219
|
if event.event in {"tool_done", "tool_error"}:
|
|
771
1220
|
tool_id = event.data.get("id")
|
|
772
|
-
if
|
|
1221
|
+
if (
|
|
1222
|
+
isinstance(tool_id, str)
|
|
1223
|
+
and tool_id in assistant_output.tools
|
|
1224
|
+
):
|
|
773
1225
|
current_tool_id = (
|
|
774
1226
|
None if current_tool_id == tool_id else current_tool_id
|
|
775
1227
|
)
|
|
776
|
-
|
|
777
|
-
{
|
|
778
|
-
**assistant_tools[tool_id].model_dump(
|
|
779
|
-
exclude_none=True
|
|
780
|
-
),
|
|
781
|
-
**event.data,
|
|
782
|
-
}
|
|
783
|
-
)
|
|
1228
|
+
assistant_output.update_tool(tool_id, event.data)
|
|
784
1229
|
persist_assistant()
|
|
785
1230
|
if event.event == "delta":
|
|
786
|
-
|
|
1231
|
+
assistant_output.append_text(
|
|
1232
|
+
str(event.data.get("content") or "")
|
|
1233
|
+
)
|
|
787
1234
|
persist_assistant()
|
|
788
1235
|
if event.event == "thinking_delta":
|
|
789
|
-
|
|
1236
|
+
assistant_output.append_thinking(
|
|
1237
|
+
str(event.data.get("content") or "")
|
|
1238
|
+
)
|
|
790
1239
|
persist_assistant()
|
|
791
1240
|
logger.log(
|
|
792
1241
|
TRACE_LEVEL,
|
|
@@ -797,12 +1246,7 @@ def create_app(
|
|
|
797
1246
|
if event.event == "done":
|
|
798
1247
|
message = event.data.get("message")
|
|
799
1248
|
if isinstance(message, dict):
|
|
800
|
-
|
|
801
|
-
message.get("content") or assistant_content
|
|
802
|
-
)
|
|
803
|
-
assistant_thinking = str(
|
|
804
|
-
message.get("thinking") or assistant_thinking
|
|
805
|
-
)
|
|
1249
|
+
assistant_output.apply_done_message(message)
|
|
806
1250
|
persist_assistant("completed")
|
|
807
1251
|
await append_run_event(run, event.event, event.data)
|
|
808
1252
|
except asyncio.CancelledError:
|
|
@@ -817,12 +1261,23 @@ def create_app(
|
|
|
817
1261
|
raise
|
|
818
1262
|
except Exception as error:
|
|
819
1263
|
logger.exception("Workspace response failed")
|
|
820
|
-
|
|
821
|
-
|
|
822
|
-
|
|
823
|
-
"
|
|
824
|
-
|
|
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
|
+
)
|
|
825
1278
|
)
|
|
1279
|
+
persist_assistant("failed")
|
|
1280
|
+
await append_run_event(run, "error", run_error_event_data(error_item))
|
|
826
1281
|
finally:
|
|
827
1282
|
run.is_done = True
|
|
828
1283
|
async with run.condition:
|
|
@@ -900,7 +1355,7 @@ def create_app(
|
|
|
900
1355
|
connection = selected_connection(state)
|
|
901
1356
|
checkpoint = store.read_active_compaction_checkpoint()
|
|
902
1357
|
model_history = [
|
|
903
|
-
*runtime_context_messages(cwd),
|
|
1358
|
+
*runtime_context_messages(cwd, state.settings.agent_prompt),
|
|
904
1359
|
*workspace_chat_messages(
|
|
905
1360
|
state.messages,
|
|
906
1361
|
store.read_compacted_context(),
|
|
@@ -909,14 +1364,13 @@ def create_app(
|
|
|
909
1364
|
]
|
|
910
1365
|
|
|
911
1366
|
try:
|
|
912
|
-
|
|
913
|
-
connection,
|
|
914
|
-
|
|
915
|
-
|
|
916
|
-
|
|
917
|
-
|
|
918
|
-
|
|
919
|
-
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",
|
|
920
1374
|
)
|
|
921
1375
|
except HTTPException:
|
|
922
1376
|
raise
|
|
@@ -927,33 +1381,8 @@ def create_app(
|
|
|
927
1381
|
detail="Context could not be compacted.",
|
|
928
1382
|
) from error
|
|
929
1383
|
|
|
930
|
-
marker = StoredMessage(
|
|
931
|
-
author="system",
|
|
932
|
-
content=COMPACTED_CONTEXT_MARKER,
|
|
933
|
-
id=str(uuid4()),
|
|
934
|
-
)
|
|
935
|
-
source_message_id = state.messages[-1].id if state.messages else None
|
|
936
|
-
store.save_compaction_checkpoint(
|
|
937
|
-
StoredCompactionCheckpoint(
|
|
938
|
-
id=str(uuid4()),
|
|
939
|
-
method=compact_result.method,
|
|
940
|
-
replacement_history=compact_result.replacement_history,
|
|
941
|
-
source_message_id=source_message_id,
|
|
942
|
-
summary=compact_result.summary,
|
|
943
|
-
token_after=compact_result.token_after,
|
|
944
|
-
token_before=compact_result.token_before,
|
|
945
|
-
trigger="manual",
|
|
946
|
-
)
|
|
947
|
-
)
|
|
948
1384
|
store.save_messages([*state.messages, marker])
|
|
949
|
-
logger.info(
|
|
950
|
-
"Workspace compact completed method=%s summary_length=%s token_before=%s token_after=%s",
|
|
951
|
-
compact_result.method,
|
|
952
|
-
len(compact_result.summary),
|
|
953
|
-
compact_result.token_before,
|
|
954
|
-
compact_result.token_after,
|
|
955
|
-
)
|
|
956
|
-
logger.log(TRACE_LEVEL, "Workspace compact summary=%r", compact_result.summary)
|
|
1385
|
+
logger.info("Workspace compact completed")
|
|
957
1386
|
return WorkspaceCompactResponse(message=marker)
|
|
958
1387
|
|
|
959
1388
|
@app.post("/api/workspace/respond")
|