flowent 0.1.0 → 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.
- 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__/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 +18 -9
- package/backend/src/flowent/main.py +81 -17
- package/backend/src/flowent/mcp_import.py +28 -13
- package/backend/src/flowent/sandbox.py +69 -2
- package/backend/src/flowent/static/assets/index-BhHdc2d_.js +81 -0
- package/backend/src/flowent/static/assets/index-C89n9qe2.css +2 -0
- package/backend/src/flowent/static/index.html +2 -2
- package/backend/src/flowent/storage.py +51 -3
- package/backend/src/flowent/tools.py +72 -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_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_mcp.py +32 -20
- package/backend/tests/test_workspace_chat.py +269 -1
- package/backend/uv.lock +1 -1
- package/bin/flowent.mjs +1 -1
- package/dist/frontend/assets/index-BhHdc2d_.js +81 -0
- package/dist/frontend/assets/index-C89n9qe2.css +2 -0
- package/dist/frontend/index.html +2 -2
- package/package.json +1 -1
- package/backend/src/flowent/static/assets/index-DqTHSMBo.js +0 -81
- package/backend/src/flowent/static/assets/index-d3FBbOXX.css +0 -2
- package/dist/frontend/assets/index-DqTHSMBo.js +0 -81
- package/dist/frontend/assets/index-d3FBbOXX.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
|
|
@@ -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
|
|
|
@@ -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
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
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
|
|
@@ -105,13 +106,29 @@ class SkillSettingsRequest(BaseModel):
|
|
|
105
106
|
class McpImportRequest(BaseModel):
|
|
106
107
|
model_config = ConfigDict(extra="forbid")
|
|
107
108
|
|
|
108
|
-
|
|
109
|
+
server_id: str
|
|
110
|
+
source: Literal["claude_code", "codex"]
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
class McpImportPreviewRequest(BaseModel):
|
|
114
|
+
model_config = ConfigDict(extra="forbid")
|
|
115
|
+
|
|
116
|
+
source: Literal["claude_code", "codex"]
|
|
109
117
|
|
|
110
118
|
|
|
111
119
|
def stream_event(event: str, data: dict[str, object]) -> str:
|
|
112
120
|
return f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
113
121
|
|
|
114
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
|
+
|
|
115
132
|
def frontend_static_directory() -> Path:
|
|
116
133
|
configured_directory = os.environ.get("FLOWENT_STATIC_DIR")
|
|
117
134
|
if configured_directory:
|
|
@@ -305,6 +322,7 @@ def create_app(
|
|
|
305
322
|
author="assistant",
|
|
306
323
|
content=assistant_content,
|
|
307
324
|
id=assistant_id,
|
|
325
|
+
status="completed",
|
|
308
326
|
thinking=assistant_thinking,
|
|
309
327
|
tools=list(assistant_tools.values()),
|
|
310
328
|
)
|
|
@@ -368,19 +386,24 @@ def create_app(
|
|
|
368
386
|
saved_server = store.save_mcp_server(server)
|
|
369
387
|
return await mcp_manager.sync_server(saved_server)
|
|
370
388
|
|
|
371
|
-
@app.
|
|
372
|
-
async def preview_mcp_import(
|
|
373
|
-
|
|
389
|
+
@app.post("/api/mcp/import/preview")
|
|
390
|
+
async def preview_mcp_import(
|
|
391
|
+
request: McpImportPreviewRequest,
|
|
392
|
+
) -> McpImportDiscovery:
|
|
393
|
+
return discover_imported_mcp_servers(Path.cwd(), source=request.source)
|
|
374
394
|
|
|
375
395
|
@app.post("/api/mcp/import")
|
|
376
396
|
async def import_mcp_servers(request: McpImportRequest) -> list[StoredMcpServer]:
|
|
377
|
-
imported_servers = discover_imported_mcp_servers(
|
|
397
|
+
imported_servers = discover_imported_mcp_servers(
|
|
398
|
+
Path.cwd(),
|
|
399
|
+
source=request.source,
|
|
400
|
+
).servers
|
|
378
401
|
existing_servers = {server.id for server in store.read_mcp_servers()}
|
|
379
402
|
for server in imported_servers:
|
|
380
|
-
if
|
|
403
|
+
if server.id != request.server_id:
|
|
404
|
+
continue
|
|
405
|
+
if server.id in existing_servers:
|
|
381
406
|
continue
|
|
382
|
-
if request.duplicate_action == "replace" and server.id in existing_servers:
|
|
383
|
-
await mcp_manager.delete_server(server.id)
|
|
384
407
|
store.save_mcp_server(server)
|
|
385
408
|
existing_servers.add(server.id)
|
|
386
409
|
return mcp_manager.servers_with_status(store.read_mcp_servers())
|
|
@@ -529,6 +552,30 @@ def create_app(
|
|
|
529
552
|
|
|
530
553
|
async def response_stream() -> AsyncIterator[str]:
|
|
531
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
|
+
|
|
532
579
|
try:
|
|
533
580
|
async for event in run_agent_stream(
|
|
534
581
|
completion=chat_completion,
|
|
@@ -539,12 +586,20 @@ def create_app(
|
|
|
539
586
|
extra_tool_title=mcp_manager.tool_title,
|
|
540
587
|
messages=request_messages,
|
|
541
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()
|
|
542
596
|
if event.event == "tool_start":
|
|
543
597
|
tool = event.data.get("tool")
|
|
544
598
|
if isinstance(tool, dict) and isinstance(tool.get("id"), str):
|
|
545
599
|
assistant_tools[tool["id"]] = StoredToolItem.model_validate(
|
|
546
600
|
tool
|
|
547
601
|
)
|
|
602
|
+
persist_assistant()
|
|
548
603
|
if event.event in {"tool_done", "tool_error"}:
|
|
549
604
|
tool_id = event.data.get("id")
|
|
550
605
|
if isinstance(tool_id, str) and tool_id in assistant_tools:
|
|
@@ -556,6 +611,13 @@ def create_app(
|
|
|
556
611
|
**event.data,
|
|
557
612
|
}
|
|
558
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()
|
|
559
621
|
logger.log(
|
|
560
622
|
TRACE_LEVEL,
|
|
561
623
|
"Workspace stream event=%s data=%r",
|
|
@@ -565,19 +627,21 @@ def create_app(
|
|
|
565
627
|
if event.event == "done":
|
|
566
628
|
message = event.data.get("message")
|
|
567
629
|
if isinstance(message, dict):
|
|
568
|
-
|
|
569
|
-
|
|
570
|
-
|
|
571
|
-
|
|
572
|
-
|
|
573
|
-
thinking=str(message.get("thinking") or ""),
|
|
574
|
-
tools=list(assistant_tools.values()),
|
|
575
|
-
)
|
|
630
|
+
assistant_content = str(
|
|
631
|
+
message.get("content") or assistant_content
|
|
632
|
+
)
|
|
633
|
+
assistant_thinking = str(
|
|
634
|
+
message.get("thinking") or assistant_thinking
|
|
576
635
|
)
|
|
577
|
-
|
|
636
|
+
persist_assistant("completed")
|
|
578
637
|
yield stream_event(event.event, event.data)
|
|
638
|
+
except asyncio.CancelledError:
|
|
639
|
+
logger.info("Workspace response interrupted")
|
|
640
|
+
persist_assistant("interrupted")
|
|
641
|
+
raise
|
|
579
642
|
except Exception as error:
|
|
580
643
|
logger.exception("Workspace response failed")
|
|
644
|
+
persist_assistant("failed")
|
|
581
645
|
yield stream_event(
|
|
582
646
|
"error",
|
|
583
647
|
{"message": str(error) or "Message could not be sent."},
|
|
@@ -33,21 +33,26 @@ class McpImportDiscovery(BaseModel):
|
|
|
33
33
|
def discover_imported_mcp_servers(
|
|
34
34
|
cwd: Path | None = None,
|
|
35
35
|
home: Path | None = None,
|
|
36
|
+
source: McpImportSource | None = None,
|
|
36
37
|
) -> McpImportDiscovery:
|
|
37
38
|
workspace = (cwd or Path.cwd()).resolve(strict=False)
|
|
38
39
|
user_home = (home or Path.home()).resolve(strict=False)
|
|
39
40
|
sources: list[McpImportSourceResult] = []
|
|
40
41
|
|
|
41
|
-
for path,
|
|
42
|
+
for path, config_source in candidate_mcp_config_files(
|
|
43
|
+
workspace,
|
|
44
|
+
user_home,
|
|
45
|
+
source,
|
|
46
|
+
):
|
|
42
47
|
if not path.is_file():
|
|
43
48
|
continue
|
|
44
49
|
try:
|
|
45
|
-
servers = parse_mcp_config_file(path,
|
|
50
|
+
servers = parse_mcp_config_file(path, config_source, workspace)
|
|
46
51
|
sources.append(
|
|
47
52
|
McpImportSourceResult(
|
|
48
53
|
path=str(path.resolve(strict=False)),
|
|
49
54
|
servers=servers,
|
|
50
|
-
source=
|
|
55
|
+
source=config_source,
|
|
51
56
|
)
|
|
52
57
|
)
|
|
53
58
|
except Exception as error:
|
|
@@ -55,7 +60,7 @@ def discover_imported_mcp_servers(
|
|
|
55
60
|
McpImportSourceResult(
|
|
56
61
|
error=str(error),
|
|
57
62
|
path=str(path.resolve(strict=False)),
|
|
58
|
-
source=
|
|
63
|
+
source=config_source,
|
|
59
64
|
)
|
|
60
65
|
)
|
|
61
66
|
|
|
@@ -70,16 +75,26 @@ def discover_imported_mcp_servers(
|
|
|
70
75
|
def candidate_mcp_config_files(
|
|
71
76
|
cwd: Path,
|
|
72
77
|
home: Path,
|
|
78
|
+
source: McpImportSource | None = None,
|
|
73
79
|
) -> list[tuple[Path, McpImportSource]]:
|
|
74
|
-
candidates: list[tuple[Path, McpImportSource]] = [
|
|
75
|
-
|
|
76
|
-
(
|
|
77
|
-
|
|
78
|
-
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
|
|
82
|
-
|
|
80
|
+
candidates: list[tuple[Path, McpImportSource]] = []
|
|
81
|
+
if source in (None, "claude_code"):
|
|
82
|
+
candidates.extend(
|
|
83
|
+
[
|
|
84
|
+
(cwd / ".mcp.json", "claude_code"),
|
|
85
|
+
(cwd / ".claude" / "settings.local.json", "claude_code"),
|
|
86
|
+
(cwd / ".claude" / "settings.json", "claude_code"),
|
|
87
|
+
(home / ".claude.json", "claude_code"),
|
|
88
|
+
(home / ".claude" / "settings.json", "claude_code"),
|
|
89
|
+
]
|
|
90
|
+
)
|
|
91
|
+
if source in (None, "codex"):
|
|
92
|
+
candidates.extend(
|
|
93
|
+
[
|
|
94
|
+
(cwd / ".codex" / "config.toml", "codex"),
|
|
95
|
+
(home / ".codex" / "config.toml", "codex"),
|
|
96
|
+
]
|
|
97
|
+
)
|
|
83
98
|
seen: set[tuple[Path, McpImportSource]] = set()
|
|
84
99
|
unique_candidates: list[tuple[Path, McpImportSource]] = []
|
|
85
100
|
for path, source in candidates:
|
|
@@ -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:
|
|
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:
|
|
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
|
+
)
|