flowent 0.1.1 → 0.1.2

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 (39) 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__/sandbox.cpython-313.pyc +0 -0
  16. package/backend/src/flowent/__pycache__/skills.cpython-313.pyc +0 -0
  17. package/backend/src/flowent/__pycache__/storage.cpython-313.pyc +0 -0
  18. package/backend/src/flowent/__pycache__/tools.cpython-313.pyc +0 -0
  19. package/backend/src/flowent/agent.py +18 -9
  20. package/backend/src/flowent/main.py +61 -9
  21. package/backend/src/flowent/sandbox.py +69 -2
  22. package/backend/src/flowent/storage.py +51 -3
  23. package/backend/src/flowent/tools.py +72 -1
  24. package/backend/tests/__pycache__/conftest.cpython-313-pytest-9.0.3.pyc +0 -0
  25. package/backend/tests/__pycache__/test_agent_tools.cpython-313-pytest-9.0.3.pyc +0 -0
  26. package/backend/tests/__pycache__/test_channels.cpython-313-pytest-9.0.3.pyc +0 -0
  27. package/backend/tests/__pycache__/test_health.cpython-313-pytest-9.0.3.pyc +0 -0
  28. package/backend/tests/__pycache__/test_llm_providers.cpython-313-pytest-9.0.3.pyc +0 -0
  29. package/backend/tests/__pycache__/test_logging.cpython-313-pytest-9.0.3.pyc +0 -0
  30. package/backend/tests/__pycache__/test_mcp.cpython-313-pytest-9.0.3.pyc +0 -0
  31. package/backend/tests/__pycache__/test_persistence.cpython-313-pytest-9.0.3.pyc +0 -0
  32. package/backend/tests/__pycache__/test_skills.cpython-313-pytest-9.0.3.pyc +0 -0
  33. package/backend/tests/__pycache__/test_startup_requirements.cpython-313-pytest-9.0.3.pyc +0 -0
  34. package/backend/tests/__pycache__/test_workspace_chat.cpython-313-pytest-9.0.3.pyc +0 -0
  35. package/backend/tests/test_agent_tools.py +103 -2
  36. package/backend/tests/test_workspace_chat.py +269 -1
  37. package/backend/uv.lock +1 -1
  38. package/bin/flowent.mjs +1 -1
  39. package/package.json +1 -1
@@ -1,6 +1,6 @@
1
1
  [project]
2
2
  name = "flowent"
3
- version = "0.1.1"
3
+ version = "0.1.2"
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
 
@@ -109,6 +109,8 @@ async def run_agent_stream(
109
109
  | None = None,
110
110
  extra_tool_specs: Sequence[Mapping[str, object]] | None = None,
111
111
  extra_tool_title: Callable[[str], str | None] | None = None,
112
+ tool_runner: Callable[[str, dict[str, object], ToolContext], Awaitable[ToolResult]]
113
+ | None = None,
112
114
  web_searcher: Callable[[str], Sequence[dict[str, str]]] | None = None,
113
115
  ) -> AsyncIterator[AgentStreamEvent]:
114
116
  conversation: list[Mapping[str, object]] = [
@@ -244,15 +246,22 @@ async def run_agent_stream(
244
246
  if extra_tool_runner is not None
245
247
  else None
246
248
  )
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),
249
+ result = extra_result if isinstance(extra_result, ToolResult) else None
250
+ if result is None:
251
+ context = ToolContext(cwd=cwd, web_searcher=web_searcher)
252
+ result = await (
253
+ tool_runner(
254
+ tool_call.name,
255
+ arguments,
256
+ context,
257
+ )
258
+ if tool_runner is not None
259
+ else run_tool_async(
260
+ tool_call.name,
261
+ arguments,
262
+ context,
263
+ )
254
264
  )
255
- )
256
265
  result_content = result.content
257
266
  logger.debug(
258
267
  "Tool call finished name=%s id=%s ok=%s",
@@ -1,3 +1,4 @@
1
+ import asyncio
1
2
  import json
2
3
  import logging
3
4
  import os
@@ -119,6 +120,15 @@ def stream_event(event: str, data: dict[str, object]) -> str:
119
120
  return f"event: {event}\ndata: {json.dumps(data)}\n\n"
120
121
 
121
122
 
123
+ def append_or_replace_message(
124
+ messages: list[StoredMessage], message: StoredMessage
125
+ ) -> list[StoredMessage]:
126
+ return [
127
+ *(current for current in messages if current.id != message.id),
128
+ message,
129
+ ]
130
+
131
+
122
132
  def frontend_static_directory() -> Path:
123
133
  configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
124
134
  if configured_directory:
@@ -312,6 +322,7 @@ def create_app(
312
322
  author="assistant",
313
323
  content=assistant_content,
314
324
  id=assistant_id,
325
+ status="completed",
315
326
  thinking=assistant_thinking,
316
327
  tools=list(assistant_tools.values()),
317
328
  )
@@ -541,6 +552,30 @@ def create_app(
541
552
 
542
553
  async def response_stream() -> AsyncIterator[str]:
543
554
  assistant_tools: dict[str, StoredToolItem] = {}
555
+ assistant_message = StoredMessage(
556
+ author="assistant",
557
+ content="",
558
+ id=str(uuid4()),
559
+ status="running",
560
+ )
561
+ assistant_content = ""
562
+ assistant_thinking = ""
563
+
564
+ def persist_assistant(status: str = "running") -> None:
565
+ nonlocal next_messages, assistant_message
566
+ assistant_message = StoredMessage(
567
+ author="assistant",
568
+ content=assistant_content,
569
+ id=assistant_message.id,
570
+ status=status,
571
+ thinking=assistant_thinking,
572
+ tools=list(assistant_tools.values()),
573
+ )
574
+ next_messages = append_or_replace_message(
575
+ next_messages, assistant_message
576
+ )
577
+ store.upsert_message(assistant_message)
578
+
544
579
  try:
545
580
  async for event in run_agent_stream(
546
581
  completion=chat_completion,
@@ -551,12 +586,20 @@ def create_app(
551
586
  extra_tool_title=mcp_manager.tool_title,
552
587
  messages=request_messages,
553
588
  ):
589
+ if event.event == "start":
590
+ event_id = event.data.get("id")
591
+ if isinstance(event_id, str):
592
+ assistant_message = assistant_message.model_copy(
593
+ update={"id": event_id}
594
+ )
595
+ persist_assistant()
554
596
  if event.event == "tool_start":
555
597
  tool = event.data.get("tool")
556
598
  if isinstance(tool, dict) and isinstance(tool.get("id"), str):
557
599
  assistant_tools[tool["id"]] = StoredToolItem.model_validate(
558
600
  tool
559
601
  )
602
+ persist_assistant()
560
603
  if event.event in {"tool_done", "tool_error"}:
561
604
  tool_id = event.data.get("id")
562
605
  if isinstance(tool_id, str) and tool_id in assistant_tools:
@@ -568,6 +611,13 @@ def create_app(
568
611
  **event.data,
569
612
  }
570
613
  )
614
+ persist_assistant()
615
+ if event.event == "delta":
616
+ assistant_content += str(event.data.get("content") or "")
617
+ persist_assistant()
618
+ if event.event == "thinking_delta":
619
+ assistant_thinking += str(event.data.get("content") or "")
620
+ persist_assistant()
571
621
  logger.log(
572
622
  TRACE_LEVEL,
573
623
  "Workspace stream event=%s data=%r",
@@ -577,19 +627,21 @@ def create_app(
577
627
  if event.event == "done":
578
628
  message = event.data.get("message")
579
629
  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
- )
630
+ assistant_content = str(
631
+ message.get("content") or assistant_content
632
+ )
633
+ assistant_thinking = str(
634
+ message.get("thinking") or assistant_thinking
588
635
  )
589
- store.save_messages(next_messages)
636
+ persist_assistant("completed")
590
637
  yield stream_event(event.event, event.data)
638
+ except asyncio.CancelledError:
639
+ logger.info("Workspace response interrupted")
640
+ persist_assistant("interrupted")
641
+ raise
591
642
  except Exception as error:
592
643
  logger.exception("Workspace response failed")
644
+ persist_assistant("failed")
593
645
  yield stream_event(
594
646
  "error",
595
647
  {"message": str(error) or "Message could not be sent."},
@@ -1,11 +1,14 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import ctypes
4
5
  import errno
5
6
  import os
6
7
  import shutil
8
+ import signal
7
9
  import subprocess
8
10
  import tempfile
11
+ from contextlib import suppress
9
12
  from dataclasses import dataclass
10
13
  from pathlib import Path
11
14
  from typing import BinaryIO
@@ -127,7 +130,7 @@ class SandboxRunner:
127
130
  self,
128
131
  *,
129
132
  cwd: Path | None = None,
130
- timeout_seconds: int = 30,
133
+ timeout_seconds: float = 30,
131
134
  output_limit: int = 20000,
132
135
  ) -> None:
133
136
  self.cwd = (cwd or Path.cwd()).resolve(strict=False)
@@ -193,7 +196,7 @@ class SandboxRunner:
193
196
  *,
194
197
  env: dict[str, str] | None = None,
195
198
  input_text: str | None = None,
196
- timeout_seconds: int | None = None,
199
+ timeout_seconds: float | None = None,
197
200
  ) -> CommandResult:
198
201
  sandbox_command = self.build_command(command)
199
202
  pass_fds: tuple[int, ...] = ()
@@ -232,3 +235,67 @@ class SandboxRunner:
232
235
  stderr=completed.stderr[: self.output_limit],
233
236
  stdout=completed.stdout[: self.output_limit],
234
237
  )
238
+
239
+ async def run_async(
240
+ self,
241
+ command: list[str],
242
+ *,
243
+ env: dict[str, str] | None = None,
244
+ input_text: str | None = None,
245
+ timeout_seconds: float | None = None,
246
+ ) -> CommandResult:
247
+ sandbox_command = self.build_command(command)
248
+ pass_fds: tuple[int, ...] = ()
249
+ if sandbox_command.seccomp_file is not None:
250
+ pass_fds = (sandbox_command.seccomp_file.fileno(),)
251
+
252
+ process_env = os.environ.copy()
253
+ if env is not None:
254
+ process_env.update(env)
255
+ process = await asyncio.create_subprocess_exec(
256
+ *sandbox_command.args,
257
+ cwd=self.cwd,
258
+ env=process_env,
259
+ pass_fds=pass_fds,
260
+ start_new_session=True,
261
+ stdin=asyncio.subprocess.PIPE if input_text is not None else None,
262
+ stdout=asyncio.subprocess.PIPE,
263
+ stderr=asyncio.subprocess.PIPE,
264
+ )
265
+ try:
266
+ stdout, stderr = await asyncio.wait_for(
267
+ process.communicate(
268
+ input_text.encode() if input_text is not None else None
269
+ ),
270
+ timeout=timeout_seconds or self.timeout_seconds,
271
+ )
272
+ except TimeoutError as error:
273
+ with suppress(ProcessLookupError):
274
+ os.killpg(process.pid, signal.SIGKILL)
275
+ stdout, stderr = await process.communicate()
276
+ return CommandResult(
277
+ command=" ".join(command),
278
+ exit_code=124,
279
+ stderr=str(error) or "Command timed out.",
280
+ stdout=self._text_output(stdout)[: self.output_limit],
281
+ )
282
+ except asyncio.CancelledError:
283
+ with suppress(ProcessLookupError):
284
+ os.killpg(process.pid, signal.SIGTERM)
285
+ try:
286
+ await asyncio.wait_for(process.wait(), timeout=1)
287
+ except TimeoutError:
288
+ with suppress(ProcessLookupError):
289
+ os.killpg(process.pid, signal.SIGKILL)
290
+ await process.wait()
291
+ raise
292
+ finally:
293
+ if sandbox_command.seccomp_file is not None:
294
+ sandbox_command.seccomp_file.close()
295
+
296
+ return CommandResult(
297
+ command=" ".join(command),
298
+ exit_code=process.returncode or 0,
299
+ stderr=self._text_output(stderr)[: self.output_limit],
300
+ stdout=self._text_output(stdout)[: self.output_limit],
301
+ )
@@ -105,6 +105,9 @@ class StoredMessage(BaseModel):
105
105
  author: str
106
106
  content: str
107
107
  id: str
108
+ status: str = Field(
109
+ default="completed", exclude_if=lambda value: value == "completed"
110
+ )
108
111
  thinking: str = Field(default="", exclude_if=lambda value: value == "")
109
112
  tools: list[StoredToolItem] = Field(default_factory=list)
110
113
 
@@ -168,6 +171,7 @@ class StateStore:
168
171
  author=row["author"],
169
172
  content=row["content"],
170
173
  id=row["id"],
174
+ status=row["status"],
171
175
  thinking=row["thinking"],
172
176
  tools=[
173
177
  StoredToolItem.model_validate(tool)
@@ -176,7 +180,7 @@ class StateStore:
176
180
  )
177
181
  for row in connection.execute(
178
182
  """
179
- SELECT id, author, content, tools, thinking
183
+ SELECT id, author, content, tools, thinking, status
180
184
  FROM messages
181
185
  ORDER BY position, id
182
186
  """
@@ -476,8 +480,8 @@ class StateStore:
476
480
  connection.execute("DELETE FROM messages")
477
481
  connection.executemany(
478
482
  """
479
- INSERT INTO messages (id, author, content, tools, thinking, position)
480
- VALUES (?, ?, ?, ?, ?, ?)
483
+ INSERT INTO messages (id, author, content, tools, thinking, status, position)
484
+ VALUES (?, ?, ?, ?, ?, ?, ?)
481
485
  """,
482
486
  [
483
487
  (
@@ -491,6 +495,7 @@ class StateStore:
491
495
  ]
492
496
  ),
493
497
  message.thinking,
498
+ message.status,
494
499
  position,
495
500
  )
496
501
  for position, message in enumerate(messages)
@@ -500,6 +505,44 @@ class StateStore:
500
505
  connection.execute("DELETE FROM workspace_context WHERE id = 1")
501
506
  return messages
502
507
 
508
+ def upsert_message(self, message: StoredMessage) -> StoredMessage:
509
+ with self.connect() as connection:
510
+ row = connection.execute(
511
+ "SELECT position FROM messages WHERE id = ?", (message.id,)
512
+ ).fetchone()
513
+ if row:
514
+ position = row["position"]
515
+ else:
516
+ position_row = connection.execute(
517
+ "SELECT COALESCE(MAX(position) + 1, 0) AS position FROM messages"
518
+ ).fetchone()
519
+ position = position_row["position"]
520
+ connection.execute(
521
+ """
522
+ INSERT INTO messages (id, author, content, tools, thinking, status, position)
523
+ VALUES (?, ?, ?, ?, ?, ?, ?)
524
+ ON CONFLICT(id) DO UPDATE SET
525
+ author = excluded.author,
526
+ content = excluded.content,
527
+ tools = excluded.tools,
528
+ thinking = excluded.thinking,
529
+ status = excluded.status,
530
+ position = excluded.position
531
+ """,
532
+ (
533
+ message.id,
534
+ message.author,
535
+ message.content,
536
+ json.dumps(
537
+ [tool.model_dump(exclude_none=True) for tool in message.tools]
538
+ ),
539
+ message.thinking,
540
+ message.status,
541
+ position,
542
+ ),
543
+ )
544
+ return message
545
+
503
546
  def read_compacted_context(self) -> str:
504
547
  with self.connect() as connection:
505
548
  row = connection.execute(
@@ -698,6 +741,7 @@ class StateStore:
698
741
  id TEXT PRIMARY KEY,
699
742
  author TEXT NOT NULL,
700
743
  content TEXT NOT NULL,
744
+ status TEXT NOT NULL DEFAULT 'completed',
701
745
  position INTEGER NOT NULL
702
746
  );
703
747
 
@@ -741,6 +785,10 @@ class StateStore:
741
785
  connection.execute(
742
786
  "ALTER TABLE messages ADD COLUMN thinking TEXT NOT NULL DEFAULT ''"
743
787
  )
788
+ if "status" not in columns:
789
+ connection.execute(
790
+ "ALTER TABLE messages ADD COLUMN status TEXT NOT NULL DEFAULT 'completed'"
791
+ )
744
792
  settings_columns = {
745
793
  row["name"] for row in connection.execute("PRAGMA table_info(settings)")
746
794
  }
@@ -1,5 +1,6 @@
1
1
  from __future__ import annotations
2
2
 
3
+ import asyncio
3
4
  import json
4
5
  import subprocess
5
6
  import sys
@@ -198,6 +199,22 @@ def run_tool(
198
199
  return ToolResult(content=str(error), ok=False, title=title)
199
200
 
200
201
 
202
+ async def run_tool_async(
203
+ name: str, arguments: dict[str, object], context: ToolContext
204
+ ) -> ToolResult:
205
+ try:
206
+ if name == "shell_command":
207
+ return await shell_command_async(arguments, context)
208
+ if name == "apply_patch":
209
+ return await apply_patch_tool_async(arguments, context)
210
+ return await asyncio.to_thread(run_tool, name, arguments, context)
211
+ except Exception as error:
212
+ title = (
213
+ "Edit failed" if name == "apply_patch" else tool_call_title(name, arguments)
214
+ )
215
+ return ToolResult(content=str(error), ok=False, title=title)
216
+
217
+
201
218
  def integer_argument(arguments: dict[str, object], name: str, default: int) -> int:
202
219
  value = arguments.get(name, default)
203
220
  if isinstance(value, int):
@@ -207,6 +224,15 @@ def integer_argument(arguments: dict[str, object], name: str, default: int) -> i
207
224
  return default
208
225
 
209
226
 
227
+ def number_argument(arguments: dict[str, object], name: str, default: float) -> float:
228
+ value = arguments.get(name, default)
229
+ if isinstance(value, int | float):
230
+ return float(value)
231
+ if isinstance(value, str):
232
+ return float(value)
233
+ return default
234
+
235
+
210
236
  def read_file(arguments: dict[str, object], context: ToolContext) -> ToolResult:
211
237
  path = resolve_tool_path(str(arguments["path"]), context.cwd)
212
238
  offset = integer_argument(arguments, "offset", 0)
@@ -271,6 +297,28 @@ def apply_patch_tool(arguments: dict[str, object], context: ToolContext) -> Tool
271
297
  )
272
298
 
273
299
 
300
+ async def apply_patch_tool_async(
301
+ arguments: dict[str, object], context: ToolContext
302
+ ) -> ToolResult:
303
+ patch = str(arguments["patch"])
304
+ paths = affected_paths(patch, context.cwd)
305
+ runner = SandboxRunner(cwd=context.cwd)
306
+ for path in paths:
307
+ runner.ensure_writable_path(path)
308
+ result = await runner.run_async(
309
+ [sys.executable, "-m", "flowent.cli", "apply-patch", "--cwd", str(context.cwd)],
310
+ input_text=patch,
311
+ )
312
+ if result.exit_code != 0:
313
+ raise SandboxError(tool_failure_content(result))
314
+ data = json.loads(result.stdout or "{}")
315
+ return ToolResult(
316
+ content=result.stdout,
317
+ data=data if isinstance(data, dict) else {},
318
+ title=patch_title_from_result(data),
319
+ )
320
+
321
+
274
322
  def patch_title_from_result(data: object) -> str:
275
323
  if not isinstance(data, dict):
276
324
  return "Edited files"
@@ -308,7 +356,7 @@ def tool_failure_content(result: object) -> str:
308
356
 
309
357
  def shell_command(arguments: dict[str, object], context: ToolContext) -> ToolResult:
310
358
  command = str(arguments["command"])
311
- timeout_seconds = integer_argument(arguments, "timeout_seconds", 30)
359
+ timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
312
360
  result = SandboxRunner(cwd=context.cwd).run(
313
361
  ["/bin/sh", "-c", command], timeout_seconds=timeout_seconds
314
362
  )
@@ -327,6 +375,29 @@ def shell_command(arguments: dict[str, object], context: ToolContext) -> ToolRes
327
375
  )
328
376
 
329
377
 
378
+ async def shell_command_async(
379
+ arguments: dict[str, object], context: ToolContext
380
+ ) -> ToolResult:
381
+ command = str(arguments["command"])
382
+ timeout_seconds = number_argument(arguments, "timeout_seconds", 30)
383
+ result = await SandboxRunner(cwd=context.cwd).run_async(
384
+ ["/bin/sh", "-c", command], timeout_seconds=timeout_seconds
385
+ )
386
+ ok = result.exit_code == 0
387
+ content = result.stdout or result.stderr
388
+ return ToolResult(
389
+ content=content,
390
+ data={
391
+ "command": command,
392
+ "exit_code": result.exit_code,
393
+ "stderr": result.stderr,
394
+ "stdout": result.stdout,
395
+ },
396
+ ok=ok,
397
+ title=f"Ran {command}",
398
+ )
399
+
400
+
330
401
  def update_plan(arguments: dict[str, object]) -> ToolResult:
331
402
  items = arguments.get("items", [])
332
403
  content = json.dumps(items, ensure_ascii=False)
@@ -1,11 +1,15 @@
1
+ import asyncio
1
2
  import json
3
+ import time
2
4
  from pathlib import Path
3
5
 
6
+ import pytest
4
7
  from fastapi.testclient import TestClient
5
8
 
6
- from flowent.agent import FLOWENT_AGENT_SYSTEM_PROMPT
9
+ from flowent.agent import FLOWENT_AGENT_SYSTEM_PROMPT, run_agent_stream
10
+ from flowent.llm import ProviderConnection, ProviderFormat
7
11
  from flowent.main import create_app
8
- from flowent.sandbox import SandboxRunner
12
+ from flowent.sandbox import SandboxCommand, SandboxRunner
9
13
  from flowent.tools import ToolContext, run_tool
10
14
 
11
15
 
@@ -222,6 +226,103 @@ def test_shell_command_has_network_by_default(tmp_path) -> None:
222
226
  assert "network-ready" in result.content
223
227
 
224
228
 
229
+ @pytest.mark.anyio
230
+ async def test_async_shell_command_does_not_block_other_tasks(
231
+ tmp_path, monkeypatch
232
+ ) -> None:
233
+ runner = SandboxRunner(cwd=tmp_path)
234
+ command = [
235
+ "/bin/sh",
236
+ "-c",
237
+ "python - <<'PY'\nimport time\ntime.sleep(0.2)\nprint('done')\nPY",
238
+ ]
239
+ monkeypatch.setattr(
240
+ runner,
241
+ "build_command",
242
+ lambda command: SandboxCommand(command, seccomp_available=False),
243
+ )
244
+ command_task = asyncio.create_task(runner.run_async(command, timeout_seconds=1))
245
+ start = time.perf_counter()
246
+ await asyncio.sleep(0.01)
247
+ elapsed = time.perf_counter() - start
248
+ result = await command_task
249
+
250
+ assert elapsed < 0.1
251
+ assert result.exit_code == 0
252
+ assert "done" in result.stdout
253
+
254
+
255
+ @pytest.mark.anyio
256
+ async def test_async_shell_command_timeout_returns_failed_result(
257
+ tmp_path, monkeypatch
258
+ ) -> None:
259
+ runner = SandboxRunner(cwd=tmp_path)
260
+ command = [
261
+ "/bin/sh",
262
+ "-c",
263
+ "python - <<'PY'\nimport time\ntime.sleep(1)\nprint('late')\nPY",
264
+ ]
265
+ monkeypatch.setattr(
266
+ runner,
267
+ "build_command",
268
+ lambda command: SandboxCommand(command, seccomp_available=False),
269
+ )
270
+ result = await runner.run_async(
271
+ command,
272
+ timeout_seconds=0.05,
273
+ )
274
+
275
+ assert result.exit_code == 124
276
+ assert "late" not in result.stdout
277
+
278
+
279
+ @pytest.mark.anyio
280
+ async def test_agent_stream_stops_after_cancelled_tool(tmp_path) -> None:
281
+ cancelled = False
282
+
283
+ async def fake_completion(**request: object) -> object:
284
+ async def chunks() -> object:
285
+ yield tool_call_chunk("shell_command", {"command": "slow"})
286
+
287
+ return chunks()
288
+
289
+ async def fake_runner(
290
+ name: str, arguments: dict[str, object], context: ToolContext
291
+ ):
292
+ nonlocal cancelled
293
+ try:
294
+ await asyncio.sleep(10)
295
+ except asyncio.CancelledError:
296
+ cancelled = True
297
+ raise
298
+
299
+ stream = run_agent_stream(
300
+ completion=fake_completion,
301
+ connection=ProviderConnection(
302
+ base_url=None,
303
+ model="gpt-5.1",
304
+ name="OpenAI",
305
+ provider=ProviderFormat.OPENAI,
306
+ secret_reference="sk-local",
307
+ ),
308
+ cwd=tmp_path,
309
+ messages=[{"role": "user", "content": "Run it."}],
310
+ tool_runner=fake_runner,
311
+ )
312
+
313
+ await stream.__anext__()
314
+ await stream.__anext__()
315
+ await stream.__anext__()
316
+ next_event = asyncio.create_task(stream.__anext__())
317
+ await asyncio.sleep(0)
318
+ next_event.cancel()
319
+ with pytest.raises(asyncio.CancelledError):
320
+ await next_event
321
+ await stream.aclose()
322
+
323
+ assert cancelled
324
+
325
+
225
326
  def test_shell_command_denies_ptrace_when_seccomp_is_available(tmp_path) -> None:
226
327
  command = SandboxRunner(cwd=tmp_path).build_command(["/bin/true"])
227
328
  if not command.seccomp_available:
@@ -1,11 +1,17 @@
1
+ import asyncio
2
+ import time
3
+
4
+ import httpx
5
+ import pytest
1
6
  from fastapi.testclient import TestClient
2
7
 
3
8
  from flowent.agent import FLOWENT_AGENT_SYSTEM_PROMPT
4
9
  from flowent.main import create_app
10
+ from flowent.sandbox import CommandResult, SandboxRunner
5
11
 
6
12
 
7
13
  def configure_provider(
8
- client: TestClient,
14
+ client,
9
15
  *,
10
16
  base_url: str = "",
11
17
  model: str = "gpt-5.1",
@@ -35,6 +41,37 @@ def configure_provider(
35
41
  )
36
42
 
37
43
 
44
+ async def configure_provider_async(
45
+ client: httpx.AsyncClient,
46
+ *,
47
+ base_url: str = "",
48
+ model: str = "gpt-5.1",
49
+ name: str = "OpenAI",
50
+ provider_id: str = "provider-openai",
51
+ provider_type: str = "openai",
52
+ reasoning_effort: str = "default",
53
+ ) -> None:
54
+ await client.post(
55
+ "/api/providers",
56
+ json={
57
+ "api_key": "sk-local",
58
+ "base_url": base_url,
59
+ "id": provider_id,
60
+ "models": [model],
61
+ "name": name,
62
+ "type": provider_type,
63
+ },
64
+ )
65
+ await client.put(
66
+ "/api/settings",
67
+ json={
68
+ "reasoning_effort": reasoning_effort,
69
+ "selected_model": model,
70
+ "selected_provider_id": provider_id,
71
+ },
72
+ )
73
+
74
+
38
75
  def project_context_message(request: dict[str, object]) -> dict[str, object] | None:
39
76
  for message in request["messages"]:
40
77
  if str(message["content"]).startswith("# AGENTS.md instructions for "):
@@ -63,6 +100,88 @@ def stream_events(content: str) -> list[dict[str, object]]:
63
100
  return events
64
101
 
65
102
 
103
+ def tool_call_chunk(
104
+ name: str,
105
+ arguments: str,
106
+ *,
107
+ call_id: str = "call-1",
108
+ ) -> dict[str, object]:
109
+ return {
110
+ "choices": [
111
+ {
112
+ "delta": {
113
+ "tool_calls": [
114
+ {
115
+ "index": 0,
116
+ "id": call_id,
117
+ "type": "function",
118
+ "function": {
119
+ "arguments": arguments,
120
+ "name": name,
121
+ },
122
+ }
123
+ ]
124
+ }
125
+ }
126
+ ]
127
+ }
128
+
129
+
130
+ @pytest.mark.anyio
131
+ async def test_workspace_long_shell_command_does_not_block_health(
132
+ tmp_path, monkeypatch
133
+ ) -> None:
134
+ monkeypatch.chdir(tmp_path)
135
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
136
+ command_started = asyncio.Event()
137
+ command_can_finish = asyncio.Event()
138
+
139
+ async def fake_run_async(self, command, **kwargs):
140
+ command_started.set()
141
+ await asyncio.wait_for(command_can_finish.wait(), timeout=2)
142
+ return CommandResult(
143
+ command=" ".join(command),
144
+ exit_code=0,
145
+ stderr="",
146
+ stdout="slow command finished",
147
+ )
148
+
149
+ monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
150
+
151
+ captured_requests: list[dict[str, object]] = []
152
+
153
+ async def fake_completion(**request: object) -> object:
154
+ captured_requests.append(request)
155
+
156
+ async def chunks() -> object:
157
+ if len(captured_requests) == 1:
158
+ yield tool_call_chunk("shell_command", '{"command": "slow"}')
159
+ else:
160
+ yield {"choices": [{"delta": {"content": "Done."}}]}
161
+
162
+ return chunks()
163
+
164
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
165
+ async with httpx.AsyncClient(
166
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
167
+ ) as client:
168
+ await configure_provider_async(client)
169
+ response_task = asyncio.create_task(
170
+ client.post("/api/workspace/respond", json={"content": "Run slow."})
171
+ )
172
+ await asyncio.wait_for(command_started.wait(), timeout=2)
173
+ start = time.perf_counter()
174
+ health_response = await client.get("/api/health")
175
+ elapsed = time.perf_counter() - start
176
+ command_can_finish.set()
177
+ response = await response_task
178
+
179
+ assert health_response.status_code == 200
180
+ assert health_response.json() == {"status": "ok"}
181
+ assert elapsed < 0.2
182
+ assert response.status_code == 200
183
+
184
+
66
185
  def test_workspace_response_streams_selected_provider_model_and_history(
67
186
  tmp_path, monkeypatch
68
187
  ) -> None:
@@ -604,3 +723,152 @@ def test_project_instructions_are_truncated_to_size_limit(
604
723
  assert project_message is not None
605
724
  assert "1234567890ab" in project_message["content"]
606
725
  assert "cdef" not in project_message["content"]
726
+
727
+
728
+ @pytest.mark.anyio
729
+ async def test_workspace_persists_tool_start_during_stream(
730
+ tmp_path, monkeypatch
731
+ ) -> None:
732
+ monkeypatch.chdir(tmp_path)
733
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
734
+ command_started = asyncio.Event()
735
+ command_can_finish = asyncio.Event()
736
+
737
+ async def fake_run_async(self, command, **kwargs):
738
+ command_started.set()
739
+ await asyncio.wait_for(command_can_finish.wait(), timeout=2)
740
+ return CommandResult(
741
+ command=" ".join(command),
742
+ exit_code=0,
743
+ stderr="",
744
+ stdout="Launch notes",
745
+ )
746
+
747
+ monkeypatch.setattr(SandboxRunner, "run_async", fake_run_async)
748
+
749
+ async def fake_completion(**request: object) -> object:
750
+ async def chunks() -> object:
751
+ if request["messages"][-1]["role"] == "user":
752
+ yield tool_call_chunk("shell_command", '{"command": "slow"}')
753
+ else:
754
+ yield {"choices": [{"delta": {"content": "Done."}}]}
755
+
756
+ return chunks()
757
+
758
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
759
+ async with httpx.AsyncClient(
760
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
761
+ ) as client:
762
+ await configure_provider_async(client)
763
+ response_task = asyncio.create_task(
764
+ client.post("/api/workspace/respond", json={"content": "Read notes."})
765
+ )
766
+ await asyncio.wait_for(command_started.wait(), timeout=2)
767
+ state = (await client.get("/api/state")).json()
768
+ command_can_finish.set()
769
+ response = await response_task
770
+
771
+ assistant = state["messages"][-1]
772
+ assert response.status_code == 200
773
+ assert assistant["author"] == "assistant"
774
+ assert assistant["status"] == "running"
775
+ assert assistant["tools"][0]["name"] == "shell_command"
776
+ assert assistant["tools"][0]["status"] == "running"
777
+
778
+
779
+ @pytest.mark.anyio
780
+ async def test_workspace_persists_tool_result_during_stream(
781
+ tmp_path, monkeypatch
782
+ ) -> None:
783
+ monkeypatch.chdir(tmp_path)
784
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
785
+ (tmp_path / "notes.txt").write_text("Launch notes")
786
+ second_round_started = asyncio.Event()
787
+ continue_stream = asyncio.Event()
788
+
789
+ async def fake_completion(**request: object) -> object:
790
+ async def chunks() -> object:
791
+ if request["messages"][-1]["role"] == "user":
792
+ yield tool_call_chunk("read_file", '{"path": "notes.txt"}')
793
+ return
794
+ second_round_started.set()
795
+ await asyncio.wait_for(continue_stream.wait(), timeout=2)
796
+ yield {"choices": [{"delta": {"content": "Done."}}]}
797
+
798
+ return chunks()
799
+
800
+ app = create_app(serve_frontend=False, chat_completion=fake_completion)
801
+ async with httpx.AsyncClient(
802
+ transport=httpx.ASGITransport(app=app), base_url="http://testserver"
803
+ ) as client:
804
+ await configure_provider_async(client)
805
+ response_task = asyncio.create_task(
806
+ client.post("/api/workspace/respond", json={"content": "Read notes."})
807
+ )
808
+ await asyncio.wait_for(second_round_started.wait(), timeout=2)
809
+ state = (await client.get("/api/state")).json()
810
+ continue_stream.set()
811
+ response = await response_task
812
+
813
+ assistant = state["messages"][-1]
814
+ assert response.status_code == 200
815
+ assert assistant["status"] == "running"
816
+ assert assistant["tools"][0]["status"] == "success"
817
+ assert assistant["tools"][0]["content"] == "Launch notes"
818
+
819
+
820
+ def test_workspace_persists_failed_draft_when_stream_errors(
821
+ tmp_path, monkeypatch
822
+ ) -> None:
823
+ monkeypatch.chdir(tmp_path)
824
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
825
+
826
+ async def fake_completion(**request: object) -> object:
827
+ async def chunks() -> object:
828
+ yield {"choices": [{"delta": {"content": "Partial answer."}}]}
829
+ raise RuntimeError("provider stopped")
830
+
831
+ return chunks()
832
+
833
+ client = TestClient(
834
+ create_app(serve_frontend=False, chat_completion=fake_completion)
835
+ )
836
+ configure_provider(client)
837
+
838
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
839
+
840
+ assert response.status_code == 200
841
+ events = stream_events(response.text)
842
+ assert events[-1]["event"] == "error"
843
+ state = client.get("/api/state").json()
844
+ assistant = state["messages"][-1]
845
+ assert assistant["author"] == "assistant"
846
+ assert assistant["content"] == "Partial answer."
847
+ assert assistant["status"] == "failed"
848
+
849
+
850
+ def test_workspace_marks_draft_complete_when_stream_finishes(
851
+ tmp_path, monkeypatch
852
+ ) -> None:
853
+ monkeypatch.chdir(tmp_path)
854
+ monkeypatch.setenv("FLOWENT_DATA_DIR", str(tmp_path / "data"))
855
+
856
+ async def fake_completion(**request: object) -> object:
857
+ async def chunks() -> object:
858
+ yield {"choices": [{"delta": {"content": "Done."}}]}
859
+
860
+ return chunks()
861
+
862
+ client = TestClient(
863
+ create_app(serve_frontend=False, chat_completion=fake_completion)
864
+ )
865
+ configure_provider(client)
866
+
867
+ response = client.post("/api/workspace/respond", json={"content": "Hello."})
868
+
869
+ assert response.status_code == 200
870
+ state = client.get("/api/state").json()
871
+ assistant = state["messages"][-1]
872
+ assert assistant["author"] == "assistant"
873
+ assert assistant["content"] == "Done."
874
+ assert assistant.get("status", "completed") == "completed"
package/backend/uv.lock CHANGED
@@ -462,7 +462,7 @@ wheels = [
462
462
 
463
463
  [[package]]
464
464
  name = "flowent"
465
- version = "0.1.1"
465
+ version = "0.1.2"
466
466
  source = { editable = "." }
467
467
  dependencies = [
468
468
  { name = "fastapi", extra = ["standard"] },
package/bin/flowent.mjs CHANGED
@@ -70,7 +70,7 @@ const child = spawn(
70
70
  uvCommand,
71
71
  ["run", "--project", backendProject, "flowent", ...passthroughArgs],
72
72
  {
73
- cwd: packageRoot,
73
+ cwd: process.cwd(),
74
74
  stdio: "inherit",
75
75
  env: {
76
76
  ...process.env,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "flowent",
3
- "version": "0.1.1",
3
+ "version": "0.1.2",
4
4
  "description": "A workflow orchestration platform for multi-agent collaboration.",
5
5
  "author": "ImFeH2 <i@feh2.im>",
6
6
  "license": "Apache-2.0",