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.
Files changed (53) hide show
  1. package/backend/pyproject.toml +1 -1
  2. package/backend/src/flowent/__pycache__/__init__.cpython-313.pyc +0 -0
  3. package/backend/src/flowent/__pycache__/_version.cpython-313.pyc +0 -0
  4. package/backend/src/flowent/__pycache__/agent.cpython-313.pyc +0 -0
  5. package/backend/src/flowent/__pycache__/channels.cpython-313.pyc +0 -0
  6. package/backend/src/flowent/__pycache__/cli.cpython-313.pyc +0 -0
  7. package/backend/src/flowent/__pycache__/context.cpython-313.pyc +0 -0
  8. package/backend/src/flowent/__pycache__/llm.cpython-313.pyc +0 -0
  9. package/backend/src/flowent/__pycache__/logging.cpython-313.pyc +0 -0
  10. package/backend/src/flowent/__pycache__/main.cpython-313.pyc +0 -0
  11. package/backend/src/flowent/__pycache__/mcp.cpython-313.pyc +0 -0
  12. package/backend/src/flowent/__pycache__/mcp_import.cpython-313.pyc +0 -0
  13. package/backend/src/flowent/__pycache__/patch.cpython-313.pyc +0 -0
  14. package/backend/src/flowent/__pycache__/paths.cpython-313.pyc +0 -0
  15. package/backend/src/flowent/__pycache__/permissions.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/sandbox.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  20. package/backend/src/flowent/agent.py +19 -9
  21. package/backend/src/flowent/main.py +356 -62
  22. package/backend/src/flowent/permissions.py +259 -0
  23. package/backend/src/flowent/sandbox.py +83 -6
  24. package/backend/src/flowent/static/assets/index-DjF2KBwE.js +81 -0
  25. package/backend/src/flowent/static/assets/index-P-bBpJG8.css +2 -0
  26. package/backend/src/flowent/static/index.html +2 -2
  27. package/backend/src/flowent/storage.py +115 -3
  28. package/backend/src/flowent/tools.py +96 -2
  29. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  30. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  36. package/backend/tests/__pycache__/test_permissions.cpython-313-pytest-9.0.3.pyc +0 -0
  37. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  38. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  39. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  40. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  41. package/backend/tests/test_agent_tools.py +103 -2
  42. package/backend/tests/test_permissions.py +443 -0
  43. package/backend/tests/test_workspace_chat.py +396 -1
  44. package/backend/uv.lock +1 -1
  45. package/bin/flowent.mjs +1 -1
  46. package/dist/frontend/assets/index-DjF2KBwE.js +81 -0
  47. package/dist/frontend/assets/index-P-bBpJG8.css +2 -0
  48. package/dist/frontend/index.html +2 -2
  49. package/package.json +1 -1
  50. package/backend/src/flowent/static/assets/index-BhHdc2d_.js +0 -81
  51. package/backend/src/flowent/static/assets/index-C89n9qe2.css +0 -2
  52. package/dist/frontend/assets/index-BhHdc2d_.js +0 -81
  53. package/dist/frontend/assets/index-C89n9qe2.css +0 -2
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.1.1"
3
+ version = "0.1.3"
4
4
  description = "A workflow orchestration platform for multi-agent collaboration."
5
5
  readme = "README.md"
6
6
  authors = [
@@ -23,7 +23,7 @@ from flowent.tools import (
23
23
  ToolResult,
24
24
  new_tool_item,
25
25
  parse_tool_arguments,
26
- run_tool,
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
- extra_result
249
- if isinstance(extra_result, ToolResult)
250
- else run_tool(
251
- tool_call.name,
252
- arguments,
253
- ToolContext(cwd=cwd, web_searcher=web_searcher),
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
- @app.post("/api/workspace/compact")
471
- async def compact_workspace() -> WorkspaceCompactResponse:
472
- logger.info("Workspace compact requested")
473
- state = store.read_state()
474
- connection = selected_connection(state)
475
- compacted_context = store.read_compacted_context()
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
- marker = StoredMessage(
498
- author="system",
499
- content=COMPACTED_CONTEXT_MARKER,
500
- id=str(uuid4()),
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
- @app.post("/api/workspace/respond")
511
- async def respond_to_workspace(
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=request.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, request.content),
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 response_stream() -> AsyncIterator[str]:
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
- next_messages.append(
581
- StoredMessage(
582
- author="assistant",
583
- content=str(message.get("content") or ""),
584
- id=str(message.get("id") or uuid4()),
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
- store.save_messages(next_messages)
590
- yield stream_event(event.event, event.data)
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
- yield stream_event(
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
- response_stream(),
894
+ workspace_run_stream(run),
601
895
  media_type="text/event-stream",
602
896
  )
603
897