flowent 0.1.2 → 0.1.4
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__/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 +1 -0
- package/backend/src/flowent/cli.py +14 -2
- package/backend/src/flowent/compact.py +183 -0
- package/backend/src/flowent/main.py +405 -88
- package/backend/src/flowent/mcp.py +3 -1
- package/backend/src/flowent/paths.py +12 -0
- package/backend/src/flowent/permissions.py +259 -0
- package/backend/src/flowent/sandbox.py +105 -16
- package/backend/src/flowent/static/assets/index-BREidonU.css +2 -0
- package/backend/src/flowent/static/assets/index-DSniOrhL.js +81 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +218 -1
- package/backend/src/flowent/tools.py +24 -1
- 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_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_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 +235 -0
- package/backend/tests/test_mcp.py +76 -10
- package/backend/tests/test_permissions.py +443 -0
- package/backend/tests/test_startup_requirements.py +42 -0
- package/backend/tests/test_workspace_chat.py +443 -9
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-BREidonU.css +2 -0
- package/dist/frontend/assets/index-DSniOrhL.js +81 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +2 -2
- package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
- package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
- package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
- package/dist/frontend/assets/index-C89n9qe2.css +0 -2
|
@@ -4,11 +4,12 @@ import logging
|
|
|
4
4
|
import os
|
|
5
5
|
from collections.abc import AsyncIterator
|
|
6
6
|
from contextlib import asynccontextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
7
8
|
from pathlib import Path
|
|
8
9
|
from typing import Literal
|
|
9
10
|
from uuid import uuid4
|
|
10
11
|
|
|
11
|
-
from fastapi import FastAPI, HTTPException
|
|
12
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
12
13
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
13
14
|
from fastapi.staticfiles import StaticFiles
|
|
14
15
|
from pydantic import BaseModel, ConfigDict
|
|
@@ -16,18 +17,24 @@ from pydantic import BaseModel, ConfigDict
|
|
|
16
17
|
from flowent._version import __version__
|
|
17
18
|
from flowent.agent import run_agent_stream
|
|
18
19
|
from flowent.channels import TelegramBotManager, TelegramTransport
|
|
20
|
+
from flowent.compact import (
|
|
21
|
+
CompactInput,
|
|
22
|
+
LocalSummaryCompactProvider,
|
|
23
|
+
transcript_messages_after,
|
|
24
|
+
)
|
|
19
25
|
from flowent.context import runtime_context_messages
|
|
20
26
|
from flowent.llm import (
|
|
21
27
|
ChatMessage,
|
|
22
28
|
CompletionCallable,
|
|
23
29
|
ProviderConnection,
|
|
24
30
|
ProviderFormat,
|
|
25
|
-
complete_chat,
|
|
26
31
|
list_provider_models,
|
|
27
32
|
)
|
|
28
33
|
from flowent.logging import TRACE_LEVEL, ensure_logging_configured
|
|
29
34
|
from flowent.mcp import McpManager, McpTransport
|
|
30
35
|
from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
|
|
36
|
+
from flowent.paths import resolve_workdir
|
|
37
|
+
from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
|
|
31
38
|
from flowent.sandbox import ensure_sandbox_available
|
|
32
39
|
from flowent.skills import (
|
|
33
40
|
discover_skills,
|
|
@@ -36,8 +43,10 @@ from flowent.skills import (
|
|
|
36
43
|
)
|
|
37
44
|
from flowent.storage import (
|
|
38
45
|
StateStore,
|
|
46
|
+
StoredCompactionCheckpoint,
|
|
39
47
|
StoredMcpServer,
|
|
40
48
|
StoredMessage,
|
|
49
|
+
StoredPermissionRequest,
|
|
41
50
|
StoredProvider,
|
|
42
51
|
StoredSettings,
|
|
43
52
|
StoredSkill,
|
|
@@ -45,14 +54,15 @@ from flowent.storage import (
|
|
|
45
54
|
StoredTelegramBot,
|
|
46
55
|
StoredTelegramSession,
|
|
47
56
|
StoredToolItem,
|
|
57
|
+
StoredWritablePath,
|
|
48
58
|
)
|
|
59
|
+
from flowent.tools import ToolContext
|
|
49
60
|
|
|
50
61
|
logger = logging.getLogger("flowent.main")
|
|
51
62
|
|
|
52
63
|
|
|
53
64
|
DEFAULT_STATIC_DIR = Path(__file__).parent / "static"
|
|
54
65
|
COMPACTED_CONTEXT_MARKER = "Context compacted"
|
|
55
|
-
COMPACT_SYSTEM_PROMPT = "You are compacting Flowent workspace context."
|
|
56
66
|
|
|
57
67
|
|
|
58
68
|
class ProviderModelsRequest(BaseModel):
|
|
@@ -79,6 +89,12 @@ class WorkspaceRespondRequest(BaseModel):
|
|
|
79
89
|
content: str
|
|
80
90
|
|
|
81
91
|
|
|
92
|
+
class WorkspaceRunResponse(BaseModel):
|
|
93
|
+
model_config = ConfigDict(extra="forbid")
|
|
94
|
+
|
|
95
|
+
run_id: str
|
|
96
|
+
|
|
97
|
+
|
|
82
98
|
class WorkspaceCompactResponse(BaseModel):
|
|
83
99
|
model_config = ConfigDict(extra="forbid")
|
|
84
100
|
|
|
@@ -116,6 +132,61 @@ class McpImportPreviewRequest(BaseModel):
|
|
|
116
132
|
source: Literal["claude_code", "codex"]
|
|
117
133
|
|
|
118
134
|
|
|
135
|
+
class WritablePathRequest(BaseModel):
|
|
136
|
+
model_config = ConfigDict(extra="forbid")
|
|
137
|
+
|
|
138
|
+
path: str
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
class WritablePathListResponse(BaseModel):
|
|
142
|
+
model_config = ConfigDict(extra="forbid")
|
|
143
|
+
|
|
144
|
+
writable_paths: list[StoredWritablePath]
|
|
145
|
+
|
|
146
|
+
|
|
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
|
+
@dataclass
|
|
163
|
+
class WorkspaceRun:
|
|
164
|
+
condition: asyncio.Condition
|
|
165
|
+
discard_on_cancel: bool = False
|
|
166
|
+
events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
|
|
167
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
168
|
+
is_done: bool = False
|
|
169
|
+
pending_permissions: dict[str, PendingWorkspacePermission] = field(
|
|
170
|
+
default_factory=dict
|
|
171
|
+
)
|
|
172
|
+
task: asyncio.Task[None] | None = None
|
|
173
|
+
|
|
174
|
+
@property
|
|
175
|
+
def latest_event_index(self) -> int:
|
|
176
|
+
return self.events[-1][0] if self.events else 0
|
|
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
|
+
|
|
119
190
|
def stream_event(event: str, data: dict[str, object]) -> str:
|
|
120
191
|
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
121
192
|
|
|
@@ -184,8 +255,34 @@ def latest_compacted_context_index(messages: list[StoredMessage]) -> int | None:
|
|
|
184
255
|
def workspace_chat_messages(
|
|
185
256
|
messages: list[StoredMessage],
|
|
186
257
|
compacted_context: str = "",
|
|
258
|
+
checkpoint: StoredCompactionCheckpoint | None = None,
|
|
187
259
|
) -> list[ChatMessage]:
|
|
188
260
|
chat_messages: list[ChatMessage] = []
|
|
261
|
+
|
|
262
|
+
if checkpoint is not None:
|
|
263
|
+
chat_messages.extend(checkpoint.replacement_history)
|
|
264
|
+
visible_messages = transcript_messages_after(
|
|
265
|
+
messages,
|
|
266
|
+
checkpoint.source_message_id,
|
|
267
|
+
)
|
|
268
|
+
for message in visible_messages:
|
|
269
|
+
if (
|
|
270
|
+
message.author == "system"
|
|
271
|
+
and message.content == COMPACTED_CONTEXT_MARKER
|
|
272
|
+
):
|
|
273
|
+
continue
|
|
274
|
+
if message.author not in ("user", "assistant"):
|
|
275
|
+
raise HTTPException(
|
|
276
|
+
status_code=400, detail="Message history is invalid."
|
|
277
|
+
)
|
|
278
|
+
checkpoint_role: Literal["user", "assistant"] = (
|
|
279
|
+
"user" if message.author == "user" else "assistant"
|
|
280
|
+
)
|
|
281
|
+
chat_messages.append(
|
|
282
|
+
ChatMessage(role=checkpoint_role, content=message.content)
|
|
283
|
+
)
|
|
284
|
+
return chat_messages
|
|
285
|
+
|
|
189
286
|
marker_index = latest_compacted_context_index(messages)
|
|
190
287
|
visible_messages = messages
|
|
191
288
|
|
|
@@ -210,30 +307,11 @@ def workspace_chat_messages(
|
|
|
210
307
|
return chat_messages
|
|
211
308
|
|
|
212
309
|
|
|
213
|
-
def
|
|
214
|
-
|
|
215
|
-
|
|
216
|
-
|
|
217
|
-
)
|
|
218
|
-
history_messages = [
|
|
219
|
-
*(runtime_messages or []),
|
|
220
|
-
*workspace_chat_messages(messages, compacted_context),
|
|
221
|
-
]
|
|
222
|
-
history = "\n\n".join(
|
|
223
|
-
f"{message.role}: {message.content}" for message in history_messages
|
|
224
|
-
)
|
|
225
|
-
return [
|
|
226
|
-
ChatMessage(role="system", content=COMPACT_SYSTEM_PROMPT),
|
|
227
|
-
ChatMessage(
|
|
228
|
-
role="user",
|
|
229
|
-
content=(
|
|
230
|
-
"Compact the current Flowent workspace context for the next turn.\n\n"
|
|
231
|
-
"Keep the details needed to continue accurately, including decisions, "
|
|
232
|
-
"constraints, pending work, and referenced facts.\n\n"
|
|
233
|
-
f"Conversation:\n{history}"
|
|
234
|
-
),
|
|
235
|
-
),
|
|
236
|
-
]
|
|
310
|
+
def normalized_request_path(path: str, cwd: Path) -> Path:
|
|
311
|
+
raw_path = Path(path).expanduser()
|
|
312
|
+
if not raw_path.is_absolute():
|
|
313
|
+
raw_path = cwd / raw_path
|
|
314
|
+
return raw_path.resolve(strict=False)
|
|
237
315
|
|
|
238
316
|
|
|
239
317
|
def create_app(
|
|
@@ -242,22 +320,27 @@ def create_app(
|
|
|
242
320
|
chat_completion: CompletionCallable | None = None,
|
|
243
321
|
mcp_transport: McpTransport | None = None,
|
|
244
322
|
telegram_transport: TelegramTransport | None = None,
|
|
323
|
+
workdir: Path | str | None = None,
|
|
245
324
|
) -> FastAPI:
|
|
246
325
|
ensure_logging_configured()
|
|
247
326
|
ensure_sandbox_available()
|
|
248
327
|
|
|
328
|
+
cwd = resolve_workdir(workdir)
|
|
249
329
|
store = StateStore()
|
|
330
|
+
compact_provider = LocalSummaryCompactProvider()
|
|
250
331
|
mcp_manager = McpManager(store=store, transport=mcp_transport)
|
|
251
332
|
telegram_bot_manager: TelegramBotManager | None = None
|
|
333
|
+
workspace_runs: dict[str, WorkspaceRun] = {}
|
|
334
|
+
active_workspace_run_id: str | None = None
|
|
252
335
|
|
|
253
336
|
static_dir = frontend_static_directory().resolve(strict=False)
|
|
254
337
|
logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
|
|
338
|
+
logger.info("Workdir: %s", cwd)
|
|
255
339
|
logger.info("Static directory: %s", static_dir)
|
|
256
340
|
|
|
257
341
|
async def run_workspace_turn(content: str) -> StoredMessage:
|
|
258
342
|
state = store.read_state()
|
|
259
343
|
connection = selected_connection(state)
|
|
260
|
-
cwd = Path.cwd()
|
|
261
344
|
user_message = StoredMessage(
|
|
262
345
|
author="user",
|
|
263
346
|
content=content,
|
|
@@ -268,6 +351,7 @@ def create_app(
|
|
|
268
351
|
chat_messages = workspace_chat_messages(
|
|
269
352
|
next_messages,
|
|
270
353
|
store.read_compacted_context(),
|
|
354
|
+
store.read_active_compaction_checkpoint(),
|
|
271
355
|
)
|
|
272
356
|
skill_messages = explicit_skill_messages(cwd, store, content)
|
|
273
357
|
request_messages = [
|
|
@@ -363,9 +447,23 @@ def create_app(
|
|
|
363
447
|
@app.get("/api/state")
|
|
364
448
|
async def app_state() -> StoredState:
|
|
365
449
|
state = store.read_state()
|
|
450
|
+
active_run = (
|
|
451
|
+
workspace_runs.get(active_workspace_run_id)
|
|
452
|
+
if active_workspace_run_id
|
|
453
|
+
else None
|
|
454
|
+
)
|
|
366
455
|
update: dict[str, object] = {
|
|
456
|
+
"active_run_event_index": active_run.latest_event_index
|
|
457
|
+
if active_run
|
|
458
|
+
else 0,
|
|
459
|
+
"active_run_id": active_run.id
|
|
460
|
+
if active_run and not active_run.is_done
|
|
461
|
+
else None,
|
|
367
462
|
"mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
|
|
368
|
-
"
|
|
463
|
+
"permission_requests": active_run.permission_requests()
|
|
464
|
+
if active_run and not active_run.is_done
|
|
465
|
+
else [],
|
|
466
|
+
"skills": discover_skills(cwd, store),
|
|
369
467
|
}
|
|
370
468
|
if telegram_bot_manager is not None:
|
|
371
469
|
update["telegram_bot"] = telegram_bot_manager.bot_with_status(
|
|
@@ -390,12 +488,12 @@ def create_app(
|
|
|
390
488
|
async def preview_mcp_import(
|
|
391
489
|
request: McpImportPreviewRequest,
|
|
392
490
|
) -> McpImportDiscovery:
|
|
393
|
-
return discover_imported_mcp_servers(
|
|
491
|
+
return discover_imported_mcp_servers(cwd, source=request.source)
|
|
394
492
|
|
|
395
493
|
@app.post("/api/mcp/import")
|
|
396
494
|
async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
|
|
397
495
|
imported_servers = discover_imported_mcp_servers(
|
|
398
|
-
|
|
496
|
+
cwd,
|
|
399
497
|
source=request.source,
|
|
400
498
|
).servers
|
|
401
499
|
existing_servers = {server.id for server in store.read_mcp_servers()}
|
|
@@ -426,7 +524,7 @@ def create_app(
|
|
|
426
524
|
|
|
427
525
|
@app.post("/api/skills/reload")
|
|
428
526
|
async def reload_skills() -> list[StoredSkill]:
|
|
429
|
-
return discover_skills(
|
|
527
|
+
return discover_skills(cwd, store)
|
|
430
528
|
|
|
431
529
|
@app.put("/api/skills/{skill_id:path}")
|
|
432
530
|
async def save_skill_settings(
|
|
@@ -434,7 +532,7 @@ def create_app(
|
|
|
434
532
|
request: SkillSettingsRequest,
|
|
435
533
|
) -> StoredSkill:
|
|
436
534
|
try:
|
|
437
|
-
return update_skill_enabled(
|
|
535
|
+
return update_skill_enabled(cwd, store, skill_id, request.enabled)
|
|
438
536
|
except KeyError as error:
|
|
439
537
|
raise HTTPException(status_code=404, detail="Skill not found.") from error
|
|
440
538
|
|
|
@@ -472,67 +570,74 @@ def create_app(
|
|
|
472
570
|
async def save_settings(settings: StoredSettings) -> StoredSettings:
|
|
473
571
|
return store.save_settings(settings)
|
|
474
572
|
|
|
573
|
+
@app.post("/api/permissions/writable-paths")
|
|
574
|
+
async def save_writable_path(
|
|
575
|
+
request: WritablePathRequest,
|
|
576
|
+
) -> StoredWritablePath:
|
|
577
|
+
return store.save_writable_path(normalized_request_path(request.path, cwd))
|
|
578
|
+
|
|
579
|
+
@app.delete("/api/permissions/writable-paths")
|
|
580
|
+
async def delete_writable_path(
|
|
581
|
+
request: WritablePathRequest,
|
|
582
|
+
) -> WritablePathListResponse:
|
|
583
|
+
return WritablePathListResponse(
|
|
584
|
+
writable_paths=store.delete_writable_path(
|
|
585
|
+
normalized_request_path(request.path, cwd)
|
|
586
|
+
)
|
|
587
|
+
)
|
|
588
|
+
|
|
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
|
+
|
|
475
608
|
@app.put("/api/workspace/messages")
|
|
476
609
|
async def save_workspace_messages(
|
|
477
610
|
request: WorkspaceMessagesRequest,
|
|
478
611
|
) -> WorkspaceMessagesRequest:
|
|
612
|
+
nonlocal active_workspace_run_id
|
|
613
|
+
if not request.messages:
|
|
614
|
+
run = active_workspace_run()
|
|
615
|
+
if run is not None and run.task is not None and not run.task.done():
|
|
616
|
+
run.discard_on_cancel = True
|
|
617
|
+
run.task.cancel()
|
|
618
|
+
active_workspace_run_id = None
|
|
479
619
|
return WorkspaceMessagesRequest(messages=store.save_messages(request.messages))
|
|
480
620
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
cwd = Path.cwd()
|
|
621
|
+
async def append_run_event(
|
|
622
|
+
run: WorkspaceRun, event: str, data: dict[str, object]
|
|
623
|
+
) -> None:
|
|
624
|
+
async with run.condition:
|
|
625
|
+
run.events.append((run.latest_event_index + 1, event, data))
|
|
626
|
+
run.condition.notify_all()
|
|
488
627
|
|
|
489
|
-
|
|
490
|
-
|
|
491
|
-
|
|
492
|
-
|
|
493
|
-
state.messages,
|
|
494
|
-
compacted_context,
|
|
495
|
-
runtime_context_messages(cwd),
|
|
496
|
-
),
|
|
497
|
-
completion=chat_completion,
|
|
498
|
-
)
|
|
499
|
-
except HTTPException:
|
|
500
|
-
raise
|
|
501
|
-
except Exception as error:
|
|
502
|
-
logger.exception("Workspace compact failed")
|
|
503
|
-
raise HTTPException(
|
|
504
|
-
status_code=500,
|
|
505
|
-
detail="Context could not be compacted.",
|
|
506
|
-
) from error
|
|
628
|
+
def active_workspace_run() -> WorkspaceRun | None:
|
|
629
|
+
if active_workspace_run_id is None:
|
|
630
|
+
return None
|
|
631
|
+
return workspace_runs.get(active_workspace_run_id)
|
|
507
632
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
content=COMPACTED_CONTEXT_MARKER,
|
|
511
|
-
id=str(uuid4()),
|
|
512
|
-
)
|
|
513
|
-
store.save_compacted_context(summary.content)
|
|
514
|
-
store.save_messages([*state.messages, marker])
|
|
515
|
-
logger.info(
|
|
516
|
-
"Workspace compact completed summary_length=%s", len(summary.content)
|
|
517
|
-
)
|
|
518
|
-
logger.log(TRACE_LEVEL, "Workspace compact summary=%r", summary.content)
|
|
519
|
-
return WorkspaceCompactResponse(message=marker)
|
|
520
|
-
|
|
521
|
-
@app.post("/api/workspace/respond")
|
|
522
|
-
async def respond_to_workspace(
|
|
523
|
-
request: WorkspaceRespondRequest,
|
|
524
|
-
) -> StreamingResponse:
|
|
525
|
-
logger.info(
|
|
526
|
-
"Workspace response requested content_length=%s", len(request.content)
|
|
527
|
-
)
|
|
528
|
-
logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
|
|
633
|
+
def create_workspace_run(content: str) -> WorkspaceRun:
|
|
634
|
+
nonlocal active_workspace_run_id
|
|
529
635
|
state = store.read_state()
|
|
530
636
|
connection = selected_connection(state)
|
|
531
|
-
cwd = Path.cwd()
|
|
532
637
|
|
|
533
638
|
user_message = StoredMessage(
|
|
534
639
|
author="user",
|
|
535
|
-
content=
|
|
640
|
+
content=content,
|
|
536
641
|
id=str(uuid4()),
|
|
537
642
|
)
|
|
538
643
|
next_messages = [*state.messages, user_message]
|
|
@@ -540,17 +645,22 @@ def create_app(
|
|
|
540
645
|
chat_messages = workspace_chat_messages(
|
|
541
646
|
next_messages,
|
|
542
647
|
store.read_compacted_context(),
|
|
648
|
+
store.read_active_compaction_checkpoint(),
|
|
543
649
|
)
|
|
544
650
|
request_messages = [
|
|
545
651
|
message.model_dump()
|
|
546
652
|
for message in [
|
|
547
653
|
*runtime_context_messages(cwd),
|
|
548
|
-
*explicit_skill_messages(cwd, store,
|
|
654
|
+
*explicit_skill_messages(cwd, store, content),
|
|
549
655
|
*chat_messages,
|
|
550
656
|
]
|
|
551
657
|
]
|
|
658
|
+
run = WorkspaceRun(condition=asyncio.Condition())
|
|
659
|
+
workspace_runs[run.id] = run
|
|
660
|
+
active_workspace_run_id = run.id
|
|
552
661
|
|
|
553
|
-
async def
|
|
662
|
+
async def run_task() -> None:
|
|
663
|
+
nonlocal active_workspace_run_id
|
|
554
664
|
assistant_tools: dict[str, StoredToolItem] = {}
|
|
555
665
|
assistant_message = StoredMessage(
|
|
556
666
|
author="assistant",
|
|
@@ -577,6 +687,61 @@ def create_app(
|
|
|
577
687
|
store.upsert_message(assistant_message)
|
|
578
688
|
|
|
579
689
|
try:
|
|
690
|
+
current_tool_id: str | None = None
|
|
691
|
+
|
|
692
|
+
async def request_writable_path(
|
|
693
|
+
path: Path, reason: str
|
|
694
|
+
) -> WritablePathDecision:
|
|
695
|
+
permission_id = str(uuid4())
|
|
696
|
+
future = asyncio.get_running_loop().create_future()
|
|
697
|
+
run.pending_permissions[permission_id] = PendingWorkspacePermission(
|
|
698
|
+
future=future,
|
|
699
|
+
path=path,
|
|
700
|
+
reason=reason,
|
|
701
|
+
tool_call_id=current_tool_id,
|
|
702
|
+
)
|
|
703
|
+
if current_tool_id and current_tool_id in assistant_tools:
|
|
704
|
+
assistant_tools[current_tool_id] = (
|
|
705
|
+
StoredToolItem.model_validate(
|
|
706
|
+
{
|
|
707
|
+
**assistant_tools[current_tool_id].model_dump(
|
|
708
|
+
exclude_none=True
|
|
709
|
+
),
|
|
710
|
+
"status": "waiting",
|
|
711
|
+
}
|
|
712
|
+
)
|
|
713
|
+
)
|
|
714
|
+
persist_assistant()
|
|
715
|
+
await append_run_event(
|
|
716
|
+
run,
|
|
717
|
+
"permission_request",
|
|
718
|
+
{
|
|
719
|
+
"id": permission_id,
|
|
720
|
+
"path": str(path),
|
|
721
|
+
"reason": reason,
|
|
722
|
+
"tool_call_id": current_tool_id,
|
|
723
|
+
},
|
|
724
|
+
)
|
|
725
|
+
try:
|
|
726
|
+
return await future
|
|
727
|
+
finally:
|
|
728
|
+
run.pending_permissions.pop(permission_id, None)
|
|
729
|
+
|
|
730
|
+
async def tool_runner(
|
|
731
|
+
name: str,
|
|
732
|
+
arguments: dict[str, object],
|
|
733
|
+
context: ToolContext,
|
|
734
|
+
):
|
|
735
|
+
return await run_tool_with_path_permissions(
|
|
736
|
+
name,
|
|
737
|
+
arguments,
|
|
738
|
+
context,
|
|
739
|
+
request_writable_path=request_writable_path,
|
|
740
|
+
writable_paths=[
|
|
741
|
+
Path(path.path) for path in store.read_writable_paths()
|
|
742
|
+
],
|
|
743
|
+
)
|
|
744
|
+
|
|
580
745
|
async for event in run_agent_stream(
|
|
581
746
|
completion=chat_completion,
|
|
582
747
|
connection=connection,
|
|
@@ -585,6 +750,7 @@ def create_app(
|
|
|
585
750
|
extra_tool_specs=mcp_manager.tool_specs(),
|
|
586
751
|
extra_tool_title=mcp_manager.tool_title,
|
|
587
752
|
messages=request_messages,
|
|
753
|
+
tool_runner=tool_runner,
|
|
588
754
|
):
|
|
589
755
|
if event.event == "start":
|
|
590
756
|
event_id = event.data.get("id")
|
|
@@ -596,6 +762,7 @@ def create_app(
|
|
|
596
762
|
if event.event == "tool_start":
|
|
597
763
|
tool = event.data.get("tool")
|
|
598
764
|
if isinstance(tool, dict) and isinstance(tool.get("id"), str):
|
|
765
|
+
current_tool_id = tool["id"]
|
|
599
766
|
assistant_tools[tool["id"]] = StoredToolItem.model_validate(
|
|
600
767
|
tool
|
|
601
768
|
)
|
|
@@ -603,6 +770,9 @@ def create_app(
|
|
|
603
770
|
if event.event in {"tool_done", "tool_error"}:
|
|
604
771
|
tool_id = event.data.get("id")
|
|
605
772
|
if isinstance(tool_id, str) and tool_id in assistant_tools:
|
|
773
|
+
current_tool_id = (
|
|
774
|
+
None if current_tool_id == tool_id else current_tool_id
|
|
775
|
+
)
|
|
606
776
|
assistant_tools[tool_id] = StoredToolItem.model_validate(
|
|
607
777
|
{
|
|
608
778
|
**assistant_tools[tool_id].model_dump(
|
|
@@ -634,22 +804,169 @@ def create_app(
|
|
|
634
804
|
message.get("thinking") or assistant_thinking
|
|
635
805
|
)
|
|
636
806
|
persist_assistant("completed")
|
|
637
|
-
|
|
807
|
+
await append_run_event(run, event.event, event.data)
|
|
638
808
|
except asyncio.CancelledError:
|
|
639
|
-
logger.info("Workspace
|
|
640
|
-
|
|
809
|
+
logger.info("Workspace run stopped")
|
|
810
|
+
if not run.discard_on_cancel:
|
|
811
|
+
persist_assistant("interrupted")
|
|
812
|
+
await append_run_event(
|
|
813
|
+
run,
|
|
814
|
+
"error",
|
|
815
|
+
{"message": "Response stopped."},
|
|
816
|
+
)
|
|
641
817
|
raise
|
|
642
818
|
except Exception as error:
|
|
643
819
|
logger.exception("Workspace response failed")
|
|
644
820
|
persist_assistant("failed")
|
|
645
|
-
|
|
821
|
+
await append_run_event(
|
|
822
|
+
run,
|
|
646
823
|
"error",
|
|
647
824
|
{"message": str(error) or "Message could not be sent."},
|
|
648
825
|
)
|
|
826
|
+
finally:
|
|
827
|
+
run.is_done = True
|
|
828
|
+
async with run.condition:
|
|
829
|
+
run.condition.notify_all()
|
|
830
|
+
if active_workspace_run_id == run.id:
|
|
831
|
+
active_workspace_run_id = None
|
|
832
|
+
|
|
833
|
+
run.task = asyncio.create_task(run_task())
|
|
834
|
+
return run
|
|
835
|
+
|
|
836
|
+
async def workspace_run_stream(
|
|
837
|
+
run: WorkspaceRun, after: int = 0
|
|
838
|
+
) -> AsyncIterator[str]:
|
|
839
|
+
next_event_index = after + 1
|
|
840
|
+
while True:
|
|
841
|
+
async with run.condition:
|
|
842
|
+
|
|
843
|
+
def has_next_event(index: int = next_event_index) -> bool:
|
|
844
|
+
return run.is_done or any(
|
|
845
|
+
event_index >= index for event_index, _, _ in run.events
|
|
846
|
+
)
|
|
847
|
+
|
|
848
|
+
await run.condition.wait_for(has_next_event)
|
|
849
|
+
events = [event for event in run.events if event[0] >= next_event_index]
|
|
850
|
+
|
|
851
|
+
for index, event, data in events:
|
|
852
|
+
next_event_index = index + 1
|
|
853
|
+
yield stream_event(event, data)
|
|
854
|
+
if event in {"done", "error"}:
|
|
855
|
+
return
|
|
856
|
+
|
|
857
|
+
if run.is_done and not events:
|
|
649
858
|
return
|
|
650
859
|
|
|
860
|
+
@app.post("/api/workspace/runs")
|
|
861
|
+
async def start_workspace_run(
|
|
862
|
+
request: WorkspaceRespondRequest,
|
|
863
|
+
) -> WorkspaceRunResponse:
|
|
864
|
+
logger.info("Workspace run requested content_length=%s", len(request.content))
|
|
865
|
+
logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
|
|
866
|
+
run = create_workspace_run(request.content)
|
|
867
|
+
return WorkspaceRunResponse(run_id=run.id)
|
|
868
|
+
|
|
869
|
+
@app.get("/api/workspace/runs/{run_id}/stream")
|
|
870
|
+
async def stream_workspace_run(
|
|
871
|
+
run_id: str,
|
|
872
|
+
after: int = Query(default=0, ge=0),
|
|
873
|
+
) -> StreamingResponse:
|
|
874
|
+
run = workspace_runs.get(run_id)
|
|
875
|
+
if run is None:
|
|
876
|
+
raise HTTPException(status_code=404, detail="Run not found.")
|
|
877
|
+
return StreamingResponse(
|
|
878
|
+
workspace_run_stream(run, after),
|
|
879
|
+
media_type="text/event-stream",
|
|
880
|
+
)
|
|
881
|
+
|
|
882
|
+
@app.post("/api/workspace/runs/{run_id}/stop")
|
|
883
|
+
async def stop_workspace_run(run_id: str) -> dict[str, bool]:
|
|
884
|
+
run = workspace_runs.get(run_id)
|
|
885
|
+
if run is None:
|
|
886
|
+
raise HTTPException(status_code=404, detail="Run not found.")
|
|
887
|
+
if run.task is not None and not run.task.done():
|
|
888
|
+
run.task.cancel()
|
|
889
|
+
return {"ok": True}
|
|
890
|
+
|
|
891
|
+
@app.post("/api/workspace/compact")
|
|
892
|
+
async def compact_workspace() -> WorkspaceCompactResponse:
|
|
893
|
+
if active_workspace_run() is not None:
|
|
894
|
+
raise HTTPException(
|
|
895
|
+
status_code=409,
|
|
896
|
+
detail="Compact is unavailable while Flowent is responding.",
|
|
897
|
+
)
|
|
898
|
+
logger.info("Workspace compact requested")
|
|
899
|
+
state = store.read_state()
|
|
900
|
+
connection = selected_connection(state)
|
|
901
|
+
checkpoint = store.read_active_compaction_checkpoint()
|
|
902
|
+
model_history = [
|
|
903
|
+
*runtime_context_messages(cwd),
|
|
904
|
+
*workspace_chat_messages(
|
|
905
|
+
state.messages,
|
|
906
|
+
store.read_compacted_context(),
|
|
907
|
+
checkpoint,
|
|
908
|
+
),
|
|
909
|
+
]
|
|
910
|
+
|
|
911
|
+
try:
|
|
912
|
+
compact_result = await compact_provider.compact(
|
|
913
|
+
connection,
|
|
914
|
+
CompactInput(
|
|
915
|
+
messages=state.messages,
|
|
916
|
+
model_history=model_history,
|
|
917
|
+
trigger="manual",
|
|
918
|
+
),
|
|
919
|
+
completion=chat_completion,
|
|
920
|
+
)
|
|
921
|
+
except HTTPException:
|
|
922
|
+
raise
|
|
923
|
+
except Exception as error:
|
|
924
|
+
logger.exception("Workspace compact failed")
|
|
925
|
+
raise HTTPException(
|
|
926
|
+
status_code=500,
|
|
927
|
+
detail="Context could not be compacted.",
|
|
928
|
+
) from error
|
|
929
|
+
|
|
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
|
+
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)
|
|
957
|
+
return WorkspaceCompactResponse(message=marker)
|
|
958
|
+
|
|
959
|
+
@app.post("/api/workspace/respond")
|
|
960
|
+
async def respond_to_workspace(
|
|
961
|
+
request: WorkspaceRespondRequest,
|
|
962
|
+
) -> StreamingResponse:
|
|
963
|
+
logger.info(
|
|
964
|
+
"Workspace response requested content_length=%s", len(request.content)
|
|
965
|
+
)
|
|
966
|
+
logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
|
|
967
|
+
run = create_workspace_run(request.content)
|
|
651
968
|
return StreamingResponse(
|
|
652
|
-
|
|
969
|
+
workspace_run_stream(run),
|
|
653
970
|
media_type="text/event-stream",
|
|
654
971
|
)
|
|
655
972
|
|