flowent 0.1.1 → 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 +19 -9
- package/backend/src/flowent/main.py +356 -62
- package/backend/src/flowent/permissions.py +259 -0
- package/backend/src/flowent/sandbox.py +83 -6
- 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 +115 -3
- package/backend/src/flowent/tools.py +96 -2
- 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 +103 -2
- package/backend/tests/test_permissions.py +443 -0
- package/backend/tests/test_workspace_chat.py +396 -1
- package/backend/uv.lock +1 -1
- package/bin/flowent.mjs +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
|
|
@@ -23,7 +23,7 @@ from flowent.tools import (
|
|
|
23
23
|
ToolResult,
|
|
24
24
|
new_tool_item,
|
|
25
25
|
parse_tool_arguments,
|
|
26
|
-
|
|
26
|
+
run_tool_async,
|
|
27
27
|
tool_specs,
|
|
28
28
|
)
|
|
29
29
|
|
|
@@ -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
|
|
|
@@ -109,6 +110,8 @@ async def run_agent_stream(
|
|
|
109
110
|
| None = None,
|
|
110
111
|
extra_tool_specs: Sequence[Mapping[str, object]] | None = None,
|
|
111
112
|
extra_tool_title: Callable[[str], str | None] | None = None,
|
|
113
|
+
tool_runner: Callable[[str, dict[str, object], ToolContext], Awaitable[ToolResult]]
|
|
114
|
+
| None = None,
|
|
112
115
|
web_searcher: Callable[[str], Sequence[dict[str, str]]] | None = None,
|
|
113
116
|
) -> AsyncIterator[AgentStreamEvent]:
|
|
114
117
|
conversation: list[Mapping[str, object]] = [
|
|
@@ -244,15 +247,22 @@ async def run_agent_stream(
|
|
|
244
247
|
if extra_tool_runner is not None
|
|
245
248
|
else None
|
|
246
249
|
)
|
|
247
|
-
result = (
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
250
|
+
result = extra_result if isinstance(extra_result, ToolResult) else None
|
|
251
|
+
if result is None:
|
|
252
|
+
context = ToolContext(cwd=cwd, web_searcher=web_searcher)
|
|
253
|
+
result = await (
|
|
254
|
+
tool_runner(
|
|
255
|
+
tool_call.name,
|
|
256
|
+
arguments,
|
|
257
|
+
context,
|
|
258
|
+
)
|
|
259
|
+
if tool_runner is not None
|
|
260
|
+
else run_tool_async(
|
|
261
|
+
tool_call.name,
|
|
262
|
+
arguments,
|
|
263
|
+
context,
|
|
264
|
+
)
|
|
254
265
|
)
|
|
255
|
-
)
|
|
256
266
|
result_content = result.content
|
|
257
267
|
logger.debug(
|
|
258
268
|
"Tool call finished name=%s id=%s ok=%s",
|
|
@@ -1,13 +1,15 @@
|
|
|
1
|
+
import asyncio
|
|
1
2
|
import json
|
|
2
3
|
import logging
|
|
3
4
|
import os
|
|
4
5
|
from collections.abc import AsyncIterator
|
|
5
6
|
from contextlib import asynccontextmanager
|
|
7
|
+
from dataclasses import dataclass, field
|
|
6
8
|
from pathlib import Path
|
|
7
9
|
from typing import Literal
|
|
8
10
|
from uuid import uuid4
|
|
9
11
|
|
|
10
|
-
from fastapi import FastAPI, HTTPException
|
|
12
|
+
from fastapi import FastAPI, HTTPException, Query
|
|
11
13
|
from fastapi.responses import FileResponse, StreamingResponse
|
|
12
14
|
from fastapi.staticfiles import StaticFiles
|
|
13
15
|
from pydantic import BaseModel, ConfigDict
|
|
@@ -27,6 +29,7 @@ from flowent.llm import (
|
|
|
27
29
|
from flowent.logging import TRACE_LEVEL, ensure_logging_configured
|
|
28
30
|
from flowent.mcp import McpManager, McpTransport
|
|
29
31
|
from flowent.mcp_import import McpImportDiscovery, discover_imported_mcp_servers
|
|
32
|
+
from flowent.permissions import WritablePathDecision, run_tool_with_path_permissions
|
|
30
33
|
from flowent.sandbox import ensure_sandbox_available
|
|
31
34
|
from flowent.skills import (
|
|
32
35
|
discover_skills,
|
|
@@ -44,7 +47,9 @@ from flowent.storage import (
|
|
|
44
47
|
StoredTelegramBot,
|
|
45
48
|
StoredTelegramSession,
|
|
46
49
|
StoredToolItem,
|
|
50
|
+
StoredWritablePath,
|
|
47
51
|
)
|
|
52
|
+
from flowent.tools import ToolContext
|
|
48
53
|
|
|
49
54
|
logger = logging.getLogger("flowent.main")
|
|
50
55
|
|
|
@@ -78,6 +83,12 @@ class WorkspaceRespondRequest(BaseModel):
|
|
|
78
83
|
content: str
|
|
79
84
|
|
|
80
85
|
|
|
86
|
+
class WorkspaceRunResponse(BaseModel):
|
|
87
|
+
model_config = ConfigDict(extra="forbid")
|
|
88
|
+
|
|
89
|
+
run_id: str
|
|
90
|
+
|
|
91
|
+
|
|
81
92
|
class WorkspaceCompactResponse(BaseModel):
|
|
82
93
|
model_config = ConfigDict(extra="forbid")
|
|
83
94
|
|
|
@@ -115,10 +126,61 @@ class McpImportPreviewRequest(BaseModel):
|
|
|
115
126
|
source: Literal["claude_code", "codex"]
|
|
116
127
|
|
|
117
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
|
+
|
|
118
171
|
def stream_event(event: str, data: dict[str, object]) -> str:
|
|
119
172
|
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
120
173
|
|
|
121
174
|
|
|
175
|
+
def append_or_replace_message(
|
|
176
|
+
messages: list[StoredMessage], message: StoredMessage
|
|
177
|
+
) -> list[StoredMessage]:
|
|
178
|
+
return [
|
|
179
|
+
*(current for current in messages if current.id != message.id),
|
|
180
|
+
message,
|
|
181
|
+
]
|
|
182
|
+
|
|
183
|
+
|
|
122
184
|
def frontend_static_directory() -> Path:
|
|
123
185
|
configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
|
|
124
186
|
if configured_directory:
|
|
@@ -200,6 +262,13 @@ def workspace_chat_messages(
|
|
|
200
262
|
return chat_messages
|
|
201
263
|
|
|
202
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
|
+
|
|
203
272
|
def compact_prompt_messages(
|
|
204
273
|
messages: list[StoredMessage],
|
|
205
274
|
compacted_context: str,
|
|
@@ -239,6 +308,8 @@ def create_app(
|
|
|
239
308
|
store = StateStore()
|
|
240
309
|
mcp_manager = McpManager(store=store, transport=mcp_transport)
|
|
241
310
|
telegram_bot_manager: TelegramBotManager | None = None
|
|
311
|
+
workspace_runs: dict[str, WorkspaceRun] = {}
|
|
312
|
+
active_workspace_run_id: str | None = None
|
|
242
313
|
|
|
243
314
|
static_dir = frontend_static_directory().resolve(strict=False)
|
|
244
315
|
logger.debug("Flowent app created serve_frontend=%s", serve_frontend)
|
|
@@ -312,6 +383,7 @@ def create_app(
|
|
|
312
383
|
author="assistant",
|
|
313
384
|
content=assistant_content,
|
|
314
385
|
id=assistant_id,
|
|
386
|
+
status="completed",
|
|
315
387
|
thinking=assistant_thinking,
|
|
316
388
|
tools=list(assistant_tools.values()),
|
|
317
389
|
)
|
|
@@ -352,7 +424,18 @@ def create_app(
|
|
|
352
424
|
@app.get("/api/state")
|
|
353
425
|
async def app_state() -> StoredState:
|
|
354
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
|
+
)
|
|
355
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,
|
|
356
439
|
"mcp_servers": mcp_manager.servers_with_status(state.mcp_servers),
|
|
357
440
|
"skills": discover_skills(Path.cwd(), store),
|
|
358
441
|
}
|
|
@@ -461,67 +544,77 @@ def create_app(
|
|
|
461
544
|
async def save_settings(settings: StoredSettings) -> StoredSettings:
|
|
462
545
|
return store.save_settings(settings)
|
|
463
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
|
+
|
|
464
584
|
@app.put("/api/workspace/messages")
|
|
465
585
|
async def save_workspace_messages(
|
|
466
586
|
request: WorkspaceMessagesRequest,
|
|
467
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
|
|
468
595
|
return WorkspaceMessagesRequest(messages=store.save_messages(request.messages))
|
|
469
596
|
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
|
|
474
|
-
|
|
475
|
-
|
|
476
|
-
cwd = Path.cwd()
|
|
477
|
-
|
|
478
|
-
try:
|
|
479
|
-
summary = await complete_chat(
|
|
480
|
-
connection,
|
|
481
|
-
compact_prompt_messages(
|
|
482
|
-
state.messages,
|
|
483
|
-
compacted_context,
|
|
484
|
-
runtime_context_messages(cwd),
|
|
485
|
-
),
|
|
486
|
-
completion=chat_completion,
|
|
487
|
-
)
|
|
488
|
-
except HTTPException:
|
|
489
|
-
raise
|
|
490
|
-
except Exception as error:
|
|
491
|
-
logger.exception("Workspace compact failed")
|
|
492
|
-
raise HTTPException(
|
|
493
|
-
status_code=500,
|
|
494
|
-
detail="Context could not be compacted.",
|
|
495
|
-
) 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()
|
|
496
603
|
|
|
497
|
-
|
|
498
|
-
|
|
499
|
-
|
|
500
|
-
|
|
501
|
-
)
|
|
502
|
-
store.save_compacted_context(summary.content)
|
|
503
|
-
store.save_messages([*state.messages, marker])
|
|
504
|
-
logger.info(
|
|
505
|
-
"Workspace compact completed summary_length=%s", len(summary.content)
|
|
506
|
-
)
|
|
507
|
-
logger.log(TRACE_LEVEL, "Workspace compact summary=%r", summary.content)
|
|
508
|
-
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)
|
|
509
608
|
|
|
510
|
-
|
|
511
|
-
|
|
512
|
-
request: WorkspaceRespondRequest,
|
|
513
|
-
) -> StreamingResponse:
|
|
514
|
-
logger.info(
|
|
515
|
-
"Workspace response requested content_length=%s", len(request.content)
|
|
516
|
-
)
|
|
517
|
-
logger.log(TRACE_LEVEL, "Workspace user content=%r", request.content)
|
|
609
|
+
def create_workspace_run(content: str) -> WorkspaceRun:
|
|
610
|
+
nonlocal active_workspace_run_id
|
|
518
611
|
state = store.read_state()
|
|
519
612
|
connection = selected_connection(state)
|
|
520
613
|
cwd = Path.cwd()
|
|
521
614
|
|
|
522
615
|
user_message = StoredMessage(
|
|
523
616
|
author="user",
|
|
524
|
-
content=
|
|
617
|
+
content=content,
|
|
525
618
|
id=str(uuid4()),
|
|
526
619
|
)
|
|
527
620
|
next_messages = [*state.messages, user_message]
|
|
@@ -534,14 +627,78 @@ def create_app(
|
|
|
534
627
|
message.model_dump()
|
|
535
628
|
for message in [
|
|
536
629
|
*runtime_context_messages(cwd),
|
|
537
|
-
*explicit_skill_messages(cwd, store,
|
|
630
|
+
*explicit_skill_messages(cwd, store, content),
|
|
538
631
|
*chat_messages,
|
|
539
632
|
]
|
|
540
633
|
]
|
|
634
|
+
run = WorkspaceRun(condition=asyncio.Condition())
|
|
635
|
+
workspace_runs[run.id] = run
|
|
636
|
+
active_workspace_run_id = run.id
|
|
541
637
|
|
|
542
|
-
async def
|
|
638
|
+
async def run_task() -> None:
|
|
639
|
+
nonlocal active_workspace_run_id
|
|
543
640
|
assistant_tools: dict[str, StoredToolItem] = {}
|
|
641
|
+
assistant_message = StoredMessage(
|
|
642
|
+
author="assistant",
|
|
643
|
+
content="",
|
|
644
|
+
id=str(uuid4()),
|
|
645
|
+
status="running",
|
|
646
|
+
)
|
|
647
|
+
assistant_content = ""
|
|
648
|
+
assistant_thinking = ""
|
|
649
|
+
|
|
650
|
+
def persist_assistant(status: str = "running") -> None:
|
|
651
|
+
nonlocal next_messages, assistant_message
|
|
652
|
+
assistant_message = StoredMessage(
|
|
653
|
+
author="assistant",
|
|
654
|
+
content=assistant_content,
|
|
655
|
+
id=assistant_message.id,
|
|
656
|
+
status=status,
|
|
657
|
+
thinking=assistant_thinking,
|
|
658
|
+
tools=list(assistant_tools.values()),
|
|
659
|
+
)
|
|
660
|
+
next_messages = append_or_replace_message(
|
|
661
|
+
next_messages, assistant_message
|
|
662
|
+
)
|
|
663
|
+
store.upsert_message(assistant_message)
|
|
664
|
+
|
|
544
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
|
+
|
|
545
702
|
async for event in run_agent_stream(
|
|
546
703
|
completion=chat_completion,
|
|
547
704
|
connection=connection,
|
|
@@ -550,13 +707,22 @@ def create_app(
|
|
|
550
707
|
extra_tool_specs=mcp_manager.tool_specs(),
|
|
551
708
|
extra_tool_title=mcp_manager.tool_title,
|
|
552
709
|
messages=request_messages,
|
|
710
|
+
tool_runner=tool_runner,
|
|
553
711
|
):
|
|
712
|
+
if event.event == "start":
|
|
713
|
+
event_id = event.data.get("id")
|
|
714
|
+
if isinstance(event_id, str):
|
|
715
|
+
assistant_message = assistant_message.model_copy(
|
|
716
|
+
update={"id": event_id}
|
|
717
|
+
)
|
|
718
|
+
persist_assistant()
|
|
554
719
|
if event.event == "tool_start":
|
|
555
720
|
tool = event.data.get("tool")
|
|
556
721
|
if isinstance(tool, dict) and isinstance(tool.get("id"), str):
|
|
557
722
|
assistant_tools[tool["id"]] = StoredToolItem.model_validate(
|
|
558
723
|
tool
|
|
559
724
|
)
|
|
725
|
+
persist_assistant()
|
|
560
726
|
if event.event in {"tool_done", "tool_error"}:
|
|
561
727
|
tool_id = event.data.get("id")
|
|
562
728
|
if isinstance(tool_id, str) and tool_id in assistant_tools:
|
|
@@ -568,6 +734,13 @@ def create_app(
|
|
|
568
734
|
**event.data,
|
|
569
735
|
}
|
|
570
736
|
)
|
|
737
|
+
persist_assistant()
|
|
738
|
+
if event.event == "delta":
|
|
739
|
+
assistant_content += str(event.data.get("content") or "")
|
|
740
|
+
persist_assistant()
|
|
741
|
+
if event.event == "thinking_delta":
|
|
742
|
+
assistant_thinking += str(event.data.get("content") or "")
|
|
743
|
+
persist_assistant()
|
|
571
744
|
logger.log(
|
|
572
745
|
TRACE_LEVEL,
|
|
573
746
|
"Workspace stream event=%s data=%r",
|
|
@@ -577,27 +750,148 @@ def create_app(
|
|
|
577
750
|
if event.event == "done":
|
|
578
751
|
message = event.data.get("message")
|
|
579
752
|
if isinstance(message, dict):
|
|
580
|
-
|
|
581
|
-
|
|
582
|
-
|
|
583
|
-
|
|
584
|
-
|
|
585
|
-
thinking=str(message.get("thinking") or ""),
|
|
586
|
-
tools=list(assistant_tools.values()),
|
|
587
|
-
)
|
|
753
|
+
assistant_content = str(
|
|
754
|
+
message.get("content") or assistant_content
|
|
755
|
+
)
|
|
756
|
+
assistant_thinking = str(
|
|
757
|
+
message.get("thinking") or assistant_thinking
|
|
588
758
|
)
|
|
589
|
-
|
|
590
|
-
|
|
759
|
+
persist_assistant("completed")
|
|
760
|
+
await append_run_event(run, event.event, event.data)
|
|
761
|
+
except asyncio.CancelledError:
|
|
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
|
+
)
|
|
770
|
+
raise
|
|
591
771
|
except Exception as error:
|
|
592
772
|
logger.exception("Workspace response failed")
|
|
593
|
-
|
|
773
|
+
persist_assistant("failed")
|
|
774
|
+
await append_run_event(
|
|
775
|
+
run,
|
|
594
776
|
"error",
|
|
595
777
|
{"message": str(error) or "Message could not be sent."},
|
|
596
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:
|
|
597
811
|
return
|
|
598
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)
|
|
599
893
|
return StreamingResponse(
|
|
600
|
-
|
|
894
|
+
workspace_run_stream(run),
|
|
601
895
|
media_type="text/event-stream",
|
|
602
896
|
)
|
|
603
897
|
|