flowent 0.1.2 → 0.1.3
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__/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/main.py +297 -55
- package/backend/src/flowent/permissions.py +259 -0
- package/backend/src/flowent/sandbox.py +14 -4
- package/backend/src/flowent/static/assets/index-DjF2KBwE.js +81 -0
- package/backend/src/flowent/static/assets/index-P-bBpJG8.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +64 -0
- 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_permissions.py +443 -0
- package/backend/tests/test_workspace_chat.py +127 -0
- package/backend/uv.lock +1 -1
- package/dist/frontend/assets/index-DjF2KBwE.js +81 -0
- package/dist/frontend/assets/index-P-bBpJG8.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- 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
package/backend/pyproject.toml
CHANGED
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
Binary file
|
|
@@ -39,6 +39,7 @@ Use tools deliberately:
|
|
|
39
39
|
- Search files when you need to find definitions, references, or related behavior.
|
|
40
40
|
- Apply structured patches for file edits.
|
|
41
41
|
- Run shell commands for diagnostics, builds, tests, and operations that require the local environment.
|
|
42
|
+
- When a shell command needs to write outside the current workspace, declare each needed writable directory with sandbox_permissions set to with_additional_permissions and additional_permissions.file_system.write.
|
|
42
43
|
- Search the web only when current external information is needed.
|
|
43
44
|
- Update the plan when a task has multiple meaningful steps.
|
|
44
45
|
|
|
@@ -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
|
|
@@ -28,6 +29,7 @@ from flowent.llm import (
|
|
|
28
29
|
from flowent.logging import TRACE_LEVEL, ensure_logging_configured
|
|
29
30
|
from flowent.mcp import McpManager, McpTransport
|
|
30
31
|
from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
|
|
32
|
+
from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
|
|
31
33
|
from flowent.sandbox import ensure_sandbox_available
|
|
32
34
|
from flowent.skills import (
|
|
33
35
|
discover_skills,
|
|
@@ -45,7 +47,9 @@ from flowent.storage import (
|
|
|
45
47
|
StoredTelegramBot,
|
|
46
48
|
StoredTelegramSession,
|
|
47
49
|
StoredToolItem,
|
|
50
|
+
StoredWritablePath,
|
|
48
51
|
)
|
|
52
|
+
from flowent.tools import ToolContext
|
|
49
53
|
|
|
50
54
|
logger = logging.getLogger("flowent.main")
|
|
51
55
|
|
|
@@ -79,6 +83,12 @@ class WorkspaceRespondRequest(BaseModel):
|
|
|
79
83
|
content: str
|
|
80
84
|
|
|
81
85
|
|
|
86
|
+
class WorkspaceRunResponse(BaseModel):
|
|
87
|
+
model_config = ConfigDict(extra="forbid")
|
|
88
|
+
|
|
89
|
+
run_id: str
|
|
90
|
+
|
|
91
|
+
|
|
82
92
|
class WorkspaceCompactResponse(BaseModel):
|
|
83
93
|
model_config = ConfigDict(extra="forbid")
|
|
84
94
|
|
|
@@ -116,6 +126,48 @@ class McpImportPreviewRequest(BaseModel):
|
|
|
116
126
|
source: Literal["claude_code", "codex"]
|
|
117
127
|
|
|
118
128
|
|
|
129
|
+
class WritablePathRequest(BaseModel):
|
|
130
|
+
model_config = ConfigDict(extra="forbid")
|
|
131
|
+
|
|
132
|
+
path: str
|
|
133
|
+
|
|
134
|
+
|
|
135
|
+
class WritablePathListResponse(BaseModel):
|
|
136
|
+
model_config = ConfigDict(extra="forbid")
|
|
137
|
+
|
|
138
|
+
writable_paths: list[StoredWritablePath]
|
|
139
|
+
|
|
140
|
+
|
|
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
|
+
@dataclass
|
|
155
|
+
class WorkspaceRun:
|
|
156
|
+
condition: asyncio.Condition
|
|
157
|
+
discard_on_cancel: bool = False
|
|
158
|
+
events: list[tuple[int, str, dict[str, object]]] = field(default_factory=list)
|
|
159
|
+
id: str = field(default_factory=lambda: str(uuid4()))
|
|
160
|
+
is_done: bool = False
|
|
161
|
+
pending_permissions: dict[str, PendingWorkspacePermission] = field(
|
|
162
|
+
default_factory=dict
|
|
163
|
+
)
|
|
164
|
+
task: asyncio.Task[None] | None = None
|
|
165
|
+
|
|
166
|
+
@property
|
|
167
|
+
def latest_event_index(self) -> int:
|
|
168
|
+
return self.events[-1][0] if self.events else 0
|
|
169
|
+
|
|
170
|
+
|
|
119
171
|
def stream_event(event: str, data: dict[str, object]) -> str:
|
|
120
172
|
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
121
173
|
|
|
@@ -210,6 +262,13 @@ def workspace_chat_messages(
|
|
|
210
262
|
return chat_messages
|
|
211
263
|
|
|
212
264
|
|
|
265
|
+
def normalized_request_path(path: str, cwd: Path) -> Path:
|
|
266
|
+
raw_path = Path(path).expanduser()
|
|
267
|
+
if not raw_path.is_absolute():
|
|
268
|
+
raw_path = cwd / raw_path
|
|
269
|
+
return raw_path.resolve(strict=False)
|
|
270
|
+
|
|
271
|
+
|
|
213
272
|
def compact_prompt_messages(
|
|
214
273
|
messages: list[StoredMessage],
|
|
215
274
|
compacted_context: str,
|
|
@@ -249,6 +308,8 @@ def create_app(
|
|
|
249
308
|
store = StateStore()
|
|
250
309
|
mcp_manager = McpManager(store=store, transport=mcp_transport)
|
|
251
310
|
telegram_bot_manager: TelegramBotManager | None = None
|
|
311
|
+
workspace_runs: dict[str, WorkspaceRun] = {}
|
|
312
|
+
active_workspace_run_id: str | None = None
|
|
252
313
|
|
|
253
314
|
static_dir = frontend_static_directory().resolve(strict=False)
|
|
254
315
|
logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
|
|
@@ -363,7 +424,18 @@ def create_app(
|
|
|
363
424
|
@app.get("/api/state")
|
|
364
425
|
async def app_state() -> StoredState:
|
|
365
426
|
state = store.read_state()
|
|
427
|
+
active_run = (
|
|
428
|
+
workspace_runs.get(active_workspace_run_id)
|
|
429
|
+
if active_workspace_run_id
|
|
430
|
+
else None
|
|
431
|
+
)
|
|
366
432
|
update: dict[str, object] = {
|
|
433
|
+
"active_run_event_index": active_run.latest_event_index
|
|
434
|
+
if active_run
|
|
435
|
+
else 0,
|
|
436
|
+
"active_run_id": active_run.id
|
|
437
|
+
if active_run and not active_run.is_done
|
|
438
|
+
else None,
|
|
367
439
|
"mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
|
|
368
440
|
"skills": discover_skills(Path.cwd(), store),
|
|
369
441
|
}
|
|
@@ -472,67 +544,77 @@ def create_app(
|
|
|
472
544
|
async def save_settings(settings: StoredSettings) -> StoredSettings:
|
|
473
545
|
return store.save_settings(settings)
|
|
474
546
|
|
|
547
|
+
@app.post("/api/permissions/writable-paths")
|
|
548
|
+
async def save_writable_path(
|
|
549
|
+
request: WritablePathRequest,
|
|
550
|
+
) -> StoredWritablePath:
|
|
551
|
+
return store.save_writable_path(
|
|
552
|
+
normalized_request_path(request.path, Path.cwd())
|
|
553
|
+
)
|
|
554
|
+
|
|
555
|
+
@app.delete("/api/permissions/writable-paths")
|
|
556
|
+
async def delete_writable_path(
|
|
557
|
+
request: WritablePathRequest,
|
|
558
|
+
) -> WritablePathListResponse:
|
|
559
|
+
return WritablePathListResponse(
|
|
560
|
+
writable_paths=store.delete_writable_path(
|
|
561
|
+
normalized_request_path(request.path, Path.cwd())
|
|
562
|
+
)
|
|
563
|
+
)
|
|
564
|
+
|
|
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
|
+
|
|
475
584
|
@app.put("/api/workspace/messages")
|
|
476
585
|
async def save_workspace_messages(
|
|
477
586
|
request: WorkspaceMessagesRequest,
|
|
478
587
|
) -> WorkspaceMessagesRequest:
|
|
588
|
+
nonlocal active_workspace_run_id
|
|
589
|
+
if not request.messages:
|
|
590
|
+
run = active_workspace_run()
|
|
591
|
+
if run is not None and run.task is not None and not run.task.done():
|
|
592
|
+
run.discard_on_cancel = True
|
|
593
|
+
run.task.cancel()
|
|
594
|
+
active_workspace_run_id = None
|
|
479
595
|
return WorkspaceMessagesRequest(messages=store.save_messages(request.messages))
|
|
480
596
|
|
|
481
|
-
|
|
482
|
-
|
|
483
|
-
|
|
484
|
-
|
|
485
|
-
|
|
486
|
-
|
|
487
|
-
cwd = Path.cwd()
|
|
488
|
-
|
|
489
|
-
try:
|
|
490
|
-
summary = await complete_chat(
|
|
491
|
-
connection,
|
|
492
|
-
compact_prompt_messages(
|
|
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
|
|
597
|
+
async def append_run_event(
|
|
598
|
+
run: WorkspaceRun, event: str, data: dict[str, object]
|
|
599
|
+
) -> None:
|
|
600
|
+
async with run.condition:
|
|
601
|
+
run.events.append((run.latest_event_index + 1, event, data))
|
|
602
|
+
run.condition.notify_all()
|
|
507
603
|
|
|
508
|
-
|
|
509
|
-
|
|
510
|
-
|
|
511
|
-
|
|
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)
|
|
604
|
+
def active_workspace_run() -> WorkspaceRun | None:
|
|
605
|
+
if active_workspace_run_id is None:
|
|
606
|
+
return None
|
|
607
|
+
return workspace_runs.get(active_workspace_run_id)
|
|
520
608
|
|
|
521
|
-
|
|
522
|
-
|
|
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)
|
|
609
|
+
def create_workspace_run(content: str) -> WorkspaceRun:
|
|
610
|
+
nonlocal active_workspace_run_id
|
|
529
611
|
state = store.read_state()
|
|
530
612
|
connection = selected_connection(state)
|
|
531
613
|
cwd = Path.cwd()
|
|
532
614
|
|
|
533
615
|
user_message = StoredMessage(
|
|
534
616
|
author="user",
|
|
535
|
-
content=
|
|
617
|
+
content=content,
|
|
536
618
|
id=str(uuid4()),
|
|
537
619
|
)
|
|
538
620
|
next_messages = [*state.messages, user_message]
|
|
@@ -545,12 +627,16 @@ def create_app(
|
|
|
545
627
|
message.model_dump()
|
|
546
628
|
for message in [
|
|
547
629
|
*runtime_context_messages(cwd),
|
|
548
|
-
*explicit_skill_messages(cwd, store,
|
|
630
|
+
*explicit_skill_messages(cwd, store, content),
|
|
549
631
|
*chat_messages,
|
|
550
632
|
]
|
|
551
633
|
]
|
|
634
|
+
run = WorkspaceRun(condition=asyncio.Condition())
|
|
635
|
+
workspace_runs[run.id] = run
|
|
636
|
+
active_workspace_run_id = run.id
|
|
552
637
|
|
|
553
|
-
async def
|
|
638
|
+
async def run_task() -> None:
|
|
639
|
+
nonlocal active_workspace_run_id
|
|
554
640
|
assistant_tools: dict[str, StoredToolItem] = {}
|
|
555
641
|
assistant_message = StoredMessage(
|
|
556
642
|
author="assistant",
|
|
@@ -577,6 +663,42 @@ def create_app(
|
|
|
577
663
|
store.upsert_message(assistant_message)
|
|
578
664
|
|
|
579
665
|
try:
|
|
666
|
+
|
|
667
|
+
async def request_writable_path(
|
|
668
|
+
path: Path, reason: str
|
|
669
|
+
) -> WritablePathDecision:
|
|
670
|
+
permission_id = str(uuid4())
|
|
671
|
+
future = asyncio.get_running_loop().create_future()
|
|
672
|
+
run.pending_permissions[permission_id] = PendingWorkspacePermission(
|
|
673
|
+
future=future,
|
|
674
|
+
path=path,
|
|
675
|
+
)
|
|
676
|
+
await append_run_event(
|
|
677
|
+
run,
|
|
678
|
+
"permission_request",
|
|
679
|
+
{
|
|
680
|
+
"id": permission_id,
|
|
681
|
+
"path": str(path),
|
|
682
|
+
"reason": reason,
|
|
683
|
+
},
|
|
684
|
+
)
|
|
685
|
+
return await future
|
|
686
|
+
|
|
687
|
+
async def tool_runner(
|
|
688
|
+
name: str,
|
|
689
|
+
arguments: dict[str, object],
|
|
690
|
+
context: ToolContext,
|
|
691
|
+
):
|
|
692
|
+
return await run_tool_with_path_permissions(
|
|
693
|
+
name,
|
|
694
|
+
arguments,
|
|
695
|
+
context,
|
|
696
|
+
request_writable_path=request_writable_path,
|
|
697
|
+
writable_paths=[
|
|
698
|
+
Path(path.path) for path in store.read_writable_paths()
|
|
699
|
+
],
|
|
700
|
+
)
|
|
701
|
+
|
|
580
702
|
async for event in run_agent_stream(
|
|
581
703
|
completion=chat_completion,
|
|
582
704
|
connection=connection,
|
|
@@ -585,6 +707,7 @@ def create_app(
|
|
|
585
707
|
extra_tool_specs=mcp_manager.tool_specs(),
|
|
586
708
|
extra_tool_title=mcp_manager.tool_title,
|
|
587
709
|
messages=request_messages,
|
|
710
|
+
tool_runner=tool_runner,
|
|
588
711
|
):
|
|
589
712
|
if event.event == "start":
|
|
590
713
|
event_id = event.data.get("id")
|
|
@@ -634,22 +757,141 @@ def create_app(
|
|
|
634
757
|
message.get("thinking") or assistant_thinking
|
|
635
758
|
)
|
|
636
759
|
persist_assistant("completed")
|
|
637
|
-
|
|
760
|
+
await append_run_event(run, event.event, event.data)
|
|
638
761
|
except asyncio.CancelledError:
|
|
639
|
-
logger.info("Workspace
|
|
640
|
-
|
|
762
|
+
logger.info("Workspace run stopped")
|
|
763
|
+
if not run.discard_on_cancel:
|
|
764
|
+
persist_assistant("interrupted")
|
|
765
|
+
await append_run_event(
|
|
766
|
+
run,
|
|
767
|
+
"error",
|
|
768
|
+
{"message": "Response stopped."},
|
|
769
|
+
)
|
|
641
770
|
raise
|
|
642
771
|
except Exception as error:
|
|
643
772
|
logger.exception("Workspace response failed")
|
|
644
773
|
persist_assistant("failed")
|
|
645
|
-
|
|
774
|
+
await append_run_event(
|
|
775
|
+
run,
|
|
646
776
|
"error",
|
|
647
777
|
{"message": str(error) or "Message could not be sent."},
|
|
648
778
|
)
|
|
779
|
+
finally:
|
|
780
|
+
run.is_done = True
|
|
781
|
+
async with run.condition:
|
|
782
|
+
run.condition.notify_all()
|
|
783
|
+
if active_workspace_run_id == run.id:
|
|
784
|
+
active_workspace_run_id = None
|
|
785
|
+
|
|
786
|
+
run.task = asyncio.create_task(run_task())
|
|
787
|
+
return run
|
|
788
|
+
|
|
789
|
+
async def workspace_run_stream(
|
|
790
|
+
run: WorkspaceRun, after: int = 0
|
|
791
|
+
) -> AsyncIterator[str]:
|
|
792
|
+
next_event_index = after + 1
|
|
793
|
+
while True:
|
|
794
|
+
async with run.condition:
|
|
795
|
+
|
|
796
|
+
def has_next_event(index: int = next_event_index) -> bool:
|
|
797
|
+
return run.is_done or any(
|
|
798
|
+
event_index >= index for event_index, _, _ in run.events
|
|
799
|
+
)
|
|
800
|
+
|
|
801
|
+
await run.condition.wait_for(has_next_event)
|
|
802
|
+
events = [event for event in run.events if event[0] >= next_event_index]
|
|
803
|
+
|
|
804
|
+
for index, event, data in events:
|
|
805
|
+
next_event_index = index + 1
|
|
806
|
+
yield stream_event(event, data)
|
|
807
|
+
if event in {"done", "error"}:
|
|
808
|
+
return
|
|
809
|
+
|
|
810
|
+
if run.is_done and not events:
|
|
649
811
|
return
|
|
650
812
|
|
|
813
|
+
@app.post("/api/workspace/runs")
|
|
814
|
+
async def start_workspace_run(
|
|
815
|
+
request: WorkspaceRespondRequest,
|
|
816
|
+
) -> WorkspaceRunResponse:
|
|
817
|
+
logger.info("Workspace run requested content_length=%s", len(request.content))
|
|
818
|
+
logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
|
|
819
|
+
run = create_workspace_run(request.content)
|
|
820
|
+
return WorkspaceRunResponse(run_id=run.id)
|
|
821
|
+
|
|
822
|
+
@app.get("/api/workspace/runs/{run_id}/stream")
|
|
823
|
+
async def stream_workspace_run(
|
|
824
|
+
run_id: str,
|
|
825
|
+
after: int = Query(default=0, ge=0),
|
|
826
|
+
) -> StreamingResponse:
|
|
827
|
+
run = workspace_runs.get(run_id)
|
|
828
|
+
if run is None:
|
|
829
|
+
raise HTTPException(status_code=404, detail="Run not found.")
|
|
830
|
+
return StreamingResponse(
|
|
831
|
+
workspace_run_stream(run, after),
|
|
832
|
+
media_type="text/event-stream",
|
|
833
|
+
)
|
|
834
|
+
|
|
835
|
+
@app.post("/api/workspace/runs/{run_id}/stop")
|
|
836
|
+
async def stop_workspace_run(run_id: str) -> dict[str, bool]:
|
|
837
|
+
run = workspace_runs.get(run_id)
|
|
838
|
+
if run is None:
|
|
839
|
+
raise HTTPException(status_code=404, detail="Run not found.")
|
|
840
|
+
if run.task is not None and not run.task.done():
|
|
841
|
+
run.task.cancel()
|
|
842
|
+
return {"ok": True}
|
|
843
|
+
|
|
844
|
+
@app.post("/api/workspace/compact")
|
|
845
|
+
async def compact_workspace() -> WorkspaceCompactResponse:
|
|
846
|
+
logger.info("Workspace compact requested")
|
|
847
|
+
state = store.read_state()
|
|
848
|
+
connection = selected_connection(state)
|
|
849
|
+
compacted_context = store.read_compacted_context()
|
|
850
|
+
cwd = Path.cwd()
|
|
851
|
+
|
|
852
|
+
try:
|
|
853
|
+
summary = await complete_chat(
|
|
854
|
+
connection,
|
|
855
|
+
compact_prompt_messages(
|
|
856
|
+
state.messages,
|
|
857
|
+
compacted_context,
|
|
858
|
+
runtime_context_messages(cwd),
|
|
859
|
+
),
|
|
860
|
+
completion=chat_completion,
|
|
861
|
+
)
|
|
862
|
+
except HTTPException:
|
|
863
|
+
raise
|
|
864
|
+
except Exception as error:
|
|
865
|
+
logger.exception("Workspace compact failed")
|
|
866
|
+
raise HTTPException(
|
|
867
|
+
status_code=500,
|
|
868
|
+
detail="Context could not be compacted.",
|
|
869
|
+
) from error
|
|
870
|
+
|
|
871
|
+
marker = StoredMessage(
|
|
872
|
+
author="system",
|
|
873
|
+
content=COMPACTED_CONTEXT_MARKER,
|
|
874
|
+
id=str(uuid4()),
|
|
875
|
+
)
|
|
876
|
+
store.save_compacted_context(summary.content)
|
|
877
|
+
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)
|
|
882
|
+
return WorkspaceCompactResponse(message=marker)
|
|
883
|
+
|
|
884
|
+
@app.post("/api/workspace/respond")
|
|
885
|
+
async def respond_to_workspace(
|
|
886
|
+
request: WorkspaceRespondRequest,
|
|
887
|
+
) -> StreamingResponse:
|
|
888
|
+
logger.info(
|
|
889
|
+
"Workspace response requested content_length=%s", len(request.content)
|
|
890
|
+
)
|
|
891
|
+
logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
|
|
892
|
+
run = create_workspace_run(request.content)
|
|
651
893
|
return StreamingResponse(
|
|
652
|
-
|
|
894
|
+
workspace_run_stream(run),
|
|
653
895
|
media_type="text/event-stream",
|
|
654
896
|
)
|
|
655
897
|
|