kimi-cli 0.35__py3-none-any.whl
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.
Potentially problematic release.
This version of kimi-cli might be problematic. Click here for more details.
- kimi_cli/CHANGELOG.md +304 -0
- kimi_cli/__init__.py +374 -0
- kimi_cli/agent.py +261 -0
- kimi_cli/agents/koder/README.md +3 -0
- kimi_cli/agents/koder/agent.yaml +24 -0
- kimi_cli/agents/koder/sub.yaml +11 -0
- kimi_cli/agents/koder/system.md +72 -0
- kimi_cli/config.py +138 -0
- kimi_cli/llm.py +8 -0
- kimi_cli/metadata.py +117 -0
- kimi_cli/prompts/metacmds/__init__.py +4 -0
- kimi_cli/prompts/metacmds/compact.md +74 -0
- kimi_cli/prompts/metacmds/init.md +21 -0
- kimi_cli/py.typed +0 -0
- kimi_cli/share.py +8 -0
- kimi_cli/soul/__init__.py +59 -0
- kimi_cli/soul/approval.py +69 -0
- kimi_cli/soul/context.py +142 -0
- kimi_cli/soul/denwarenji.py +37 -0
- kimi_cli/soul/kimisoul.py +248 -0
- kimi_cli/soul/message.py +76 -0
- kimi_cli/soul/toolset.py +25 -0
- kimi_cli/soul/wire.py +101 -0
- kimi_cli/tools/__init__.py +85 -0
- kimi_cli/tools/bash/__init__.py +97 -0
- kimi_cli/tools/bash/bash.md +31 -0
- kimi_cli/tools/dmail/__init__.py +38 -0
- kimi_cli/tools/dmail/dmail.md +15 -0
- kimi_cli/tools/file/__init__.py +21 -0
- kimi_cli/tools/file/glob.md +17 -0
- kimi_cli/tools/file/glob.py +149 -0
- kimi_cli/tools/file/grep.md +5 -0
- kimi_cli/tools/file/grep.py +285 -0
- kimi_cli/tools/file/patch.md +8 -0
- kimi_cli/tools/file/patch.py +131 -0
- kimi_cli/tools/file/read.md +14 -0
- kimi_cli/tools/file/read.py +139 -0
- kimi_cli/tools/file/replace.md +7 -0
- kimi_cli/tools/file/replace.py +132 -0
- kimi_cli/tools/file/write.md +5 -0
- kimi_cli/tools/file/write.py +107 -0
- kimi_cli/tools/mcp.py +85 -0
- kimi_cli/tools/task/__init__.py +156 -0
- kimi_cli/tools/task/task.md +26 -0
- kimi_cli/tools/test.py +55 -0
- kimi_cli/tools/think/__init__.py +21 -0
- kimi_cli/tools/think/think.md +1 -0
- kimi_cli/tools/todo/__init__.py +27 -0
- kimi_cli/tools/todo/set_todo_list.md +15 -0
- kimi_cli/tools/utils.py +150 -0
- kimi_cli/tools/web/__init__.py +4 -0
- kimi_cli/tools/web/fetch.md +1 -0
- kimi_cli/tools/web/fetch.py +94 -0
- kimi_cli/tools/web/search.md +1 -0
- kimi_cli/tools/web/search.py +126 -0
- kimi_cli/ui/__init__.py +68 -0
- kimi_cli/ui/acp/__init__.py +441 -0
- kimi_cli/ui/print/__init__.py +176 -0
- kimi_cli/ui/shell/__init__.py +326 -0
- kimi_cli/ui/shell/console.py +3 -0
- kimi_cli/ui/shell/liveview.py +158 -0
- kimi_cli/ui/shell/metacmd.py +309 -0
- kimi_cli/ui/shell/prompt.py +574 -0
- kimi_cli/ui/shell/setup.py +192 -0
- kimi_cli/ui/shell/update.py +204 -0
- kimi_cli/utils/changelog.py +101 -0
- kimi_cli/utils/logging.py +18 -0
- kimi_cli/utils/message.py +8 -0
- kimi_cli/utils/path.py +23 -0
- kimi_cli/utils/provider.py +64 -0
- kimi_cli/utils/pyinstaller.py +24 -0
- kimi_cli/utils/string.py +12 -0
- kimi_cli-0.35.dist-info/METADATA +24 -0
- kimi_cli-0.35.dist-info/RECORD +76 -0
- kimi_cli-0.35.dist-info/WHEEL +4 -0
- kimi_cli-0.35.dist-info/entry_points.txt +3 -0
kimi_cli/tools/utils.py
ADDED
|
@@ -0,0 +1,150 @@
|
|
|
1
|
+
import re
|
|
2
|
+
import string
|
|
3
|
+
from pathlib import Path
|
|
4
|
+
|
|
5
|
+
from kosong.tooling import ToolError, ToolOk
|
|
6
|
+
|
|
7
|
+
|
|
8
|
+
def load_desc(path: Path, substitutions: dict[str, str] | None = None) -> str:
|
|
9
|
+
"""Load a tool description from a file, with optional substitutions."""
|
|
10
|
+
description = path.read_text()
|
|
11
|
+
if substitutions:
|
|
12
|
+
description = string.Template(description).substitute(substitutions)
|
|
13
|
+
return description
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
def truncate_line(line: str, max_length: int, marker: str = "...") -> str:
|
|
17
|
+
"""
|
|
18
|
+
Truncate a line if it exceeds `max_length`, preserving the beginning and the line break.
|
|
19
|
+
The output may be longer than `max_length` if it is too short to fit the marker.
|
|
20
|
+
"""
|
|
21
|
+
if len(line) <= max_length:
|
|
22
|
+
return line
|
|
23
|
+
|
|
24
|
+
# Find line breaks at the end of the line
|
|
25
|
+
m = re.search(r"[\r\n]+$", line)
|
|
26
|
+
linebreak = m.group(0) if m else ""
|
|
27
|
+
end = marker + linebreak
|
|
28
|
+
max_length = max(max_length, len(end))
|
|
29
|
+
return line[: max_length - len(end)] + end
|
|
30
|
+
|
|
31
|
+
|
|
32
|
+
# Default output limits
|
|
33
|
+
DEFAULT_MAX_CHARS = 50_000
|
|
34
|
+
DEFAULT_MAX_LINE_LENGTH = 2000
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class ToolResultBuilder:
|
|
38
|
+
"""
|
|
39
|
+
Builder for tool results with character and line limits.
|
|
40
|
+
"""
|
|
41
|
+
|
|
42
|
+
def __init__(
|
|
43
|
+
self,
|
|
44
|
+
max_chars: int = DEFAULT_MAX_CHARS,
|
|
45
|
+
max_line_length: int | None = DEFAULT_MAX_LINE_LENGTH,
|
|
46
|
+
):
|
|
47
|
+
self.max_chars = max_chars
|
|
48
|
+
self.max_line_length = max_line_length
|
|
49
|
+
self._marker = "[...truncated]"
|
|
50
|
+
if max_line_length is not None:
|
|
51
|
+
assert max_line_length > len(self._marker)
|
|
52
|
+
self._buffer: list[str] = []
|
|
53
|
+
self._n_chars = 0
|
|
54
|
+
self._n_lines = 0
|
|
55
|
+
self._truncation_happened = False
|
|
56
|
+
|
|
57
|
+
def write(self, text: str) -> int:
|
|
58
|
+
"""
|
|
59
|
+
Write text to the output buffer.
|
|
60
|
+
|
|
61
|
+
Returns:
|
|
62
|
+
int: Number of characters actually written
|
|
63
|
+
"""
|
|
64
|
+
if self.is_full:
|
|
65
|
+
return 0
|
|
66
|
+
|
|
67
|
+
lines = text.splitlines(keepends=True)
|
|
68
|
+
if not lines:
|
|
69
|
+
return 0
|
|
70
|
+
|
|
71
|
+
chars_written = 0
|
|
72
|
+
|
|
73
|
+
for line in lines:
|
|
74
|
+
if self.is_full:
|
|
75
|
+
break
|
|
76
|
+
|
|
77
|
+
original_line = line
|
|
78
|
+
remaining_chars = self.max_chars - self._n_chars
|
|
79
|
+
limit = (
|
|
80
|
+
min(remaining_chars, self.max_line_length)
|
|
81
|
+
if self.max_line_length is not None
|
|
82
|
+
else remaining_chars
|
|
83
|
+
)
|
|
84
|
+
line = truncate_line(line, limit, self._marker)
|
|
85
|
+
if line != original_line:
|
|
86
|
+
self._truncation_happened = True
|
|
87
|
+
|
|
88
|
+
self._buffer.append(line)
|
|
89
|
+
chars_written += len(line)
|
|
90
|
+
self._n_chars += len(line)
|
|
91
|
+
if line.endswith("\n"):
|
|
92
|
+
self._n_lines += 1
|
|
93
|
+
|
|
94
|
+
return chars_written
|
|
95
|
+
|
|
96
|
+
def ok(self, message: str = "", *, brief: str = "") -> ToolOk:
|
|
97
|
+
"""Create a ToolOk result with the current output."""
|
|
98
|
+
output = "".join(self._buffer)
|
|
99
|
+
|
|
100
|
+
final_message = message
|
|
101
|
+
if final_message and not final_message.endswith("."):
|
|
102
|
+
final_message += "."
|
|
103
|
+
truncation_msg = "Output is truncated to fit in the message."
|
|
104
|
+
if self._truncation_happened:
|
|
105
|
+
if final_message:
|
|
106
|
+
final_message += f" {truncation_msg}"
|
|
107
|
+
else:
|
|
108
|
+
final_message = truncation_msg
|
|
109
|
+
|
|
110
|
+
return ToolOk(output=output, message=final_message, brief=brief)
|
|
111
|
+
|
|
112
|
+
def error(self, message: str, *, brief: str) -> ToolError:
|
|
113
|
+
"""Create a ToolError result with the current output."""
|
|
114
|
+
output = "".join(self._buffer)
|
|
115
|
+
|
|
116
|
+
final_message = message
|
|
117
|
+
if self._truncation_happened:
|
|
118
|
+
truncation_msg = "Output is truncated to fit in the message."
|
|
119
|
+
if final_message:
|
|
120
|
+
final_message += f" {truncation_msg}"
|
|
121
|
+
else:
|
|
122
|
+
final_message = truncation_msg
|
|
123
|
+
|
|
124
|
+
return ToolError(output=output, message=final_message, brief=brief)
|
|
125
|
+
|
|
126
|
+
@property
|
|
127
|
+
def is_full(self) -> bool:
|
|
128
|
+
"""Check if output buffer is full due to character limit."""
|
|
129
|
+
return self._n_chars >= self.max_chars
|
|
130
|
+
|
|
131
|
+
@property
|
|
132
|
+
def n_chars(self) -> int:
|
|
133
|
+
"""Get current character count."""
|
|
134
|
+
return self._n_chars
|
|
135
|
+
|
|
136
|
+
@property
|
|
137
|
+
def n_lines(self) -> int:
|
|
138
|
+
"""Get current line count."""
|
|
139
|
+
return self._n_lines
|
|
140
|
+
|
|
141
|
+
|
|
142
|
+
class ToolRejectedError(ToolError):
|
|
143
|
+
def __init__(self):
|
|
144
|
+
super().__init__(
|
|
145
|
+
message=(
|
|
146
|
+
"The tool call is rejected by the user. "
|
|
147
|
+
"Please follow the new instructions from the user."
|
|
148
|
+
),
|
|
149
|
+
brief="Rejected by user",
|
|
150
|
+
)
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
Fetch a web page from a URL and extract main text content from it.
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
import aiohttp
|
|
5
|
+
import trafilatura
|
|
6
|
+
from kosong.tooling import CallableTool2, ToolReturnType
|
|
7
|
+
from pydantic import BaseModel, Field
|
|
8
|
+
|
|
9
|
+
from kimi_cli.tools.utils import ToolResultBuilder, load_desc
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Params(BaseModel):
|
|
13
|
+
url: str = Field(description="The URL to fetch content from.")
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class FetchURL(CallableTool2[Params]):
|
|
17
|
+
name: str = "FetchURL"
|
|
18
|
+
description: str = load_desc(Path(__file__).parent / "fetch.md", {})
|
|
19
|
+
params: type[Params] = Params
|
|
20
|
+
|
|
21
|
+
@override
|
|
22
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
23
|
+
builder = ToolResultBuilder(max_line_length=None)
|
|
24
|
+
|
|
25
|
+
try:
|
|
26
|
+
async with (
|
|
27
|
+
aiohttp.ClientSession() as session,
|
|
28
|
+
session.get(
|
|
29
|
+
params.url,
|
|
30
|
+
headers={
|
|
31
|
+
"User-Agent": (
|
|
32
|
+
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) AppleWebKit/537.36 "
|
|
33
|
+
"(KHTML, like Gecko) Chrome/91.0.4472.124 Safari/537.36"
|
|
34
|
+
),
|
|
35
|
+
},
|
|
36
|
+
) as response,
|
|
37
|
+
):
|
|
38
|
+
if response.status >= 400:
|
|
39
|
+
return builder.error(
|
|
40
|
+
(
|
|
41
|
+
f"Failed to fetch URL. Status: {response.status}. "
|
|
42
|
+
f"This may indicate the page is not accessible or the server is down."
|
|
43
|
+
),
|
|
44
|
+
brief=f"HTTP {response.status} error",
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
html = await response.text()
|
|
48
|
+
except aiohttp.ClientError as e:
|
|
49
|
+
return builder.error(
|
|
50
|
+
(
|
|
51
|
+
f"Failed to fetch URL due to network error: {str(e)}. "
|
|
52
|
+
"This may indicate the URL is invalid or the server is unreachable."
|
|
53
|
+
),
|
|
54
|
+
brief="Network error",
|
|
55
|
+
)
|
|
56
|
+
|
|
57
|
+
if not html:
|
|
58
|
+
return builder.ok(
|
|
59
|
+
"The response body is empty.",
|
|
60
|
+
brief="Empty response body",
|
|
61
|
+
)
|
|
62
|
+
|
|
63
|
+
extracted_text = trafilatura.extract(
|
|
64
|
+
html,
|
|
65
|
+
include_comments=True,
|
|
66
|
+
include_tables=True,
|
|
67
|
+
include_formatting=False,
|
|
68
|
+
output_format="txt",
|
|
69
|
+
with_metadata=True,
|
|
70
|
+
)
|
|
71
|
+
|
|
72
|
+
if not extracted_text:
|
|
73
|
+
return builder.error(
|
|
74
|
+
(
|
|
75
|
+
"Failed to extract meaningful content from the page. "
|
|
76
|
+
"This may indicate the page content is not suitable for text extraction, "
|
|
77
|
+
"or the page requires JavaScript to render its content."
|
|
78
|
+
),
|
|
79
|
+
brief="No content extracted",
|
|
80
|
+
)
|
|
81
|
+
|
|
82
|
+
builder.write(extracted_text)
|
|
83
|
+
return builder.ok("The returned content is the main text content extracted from the page.")
|
|
84
|
+
|
|
85
|
+
|
|
86
|
+
if __name__ == "__main__":
|
|
87
|
+
import asyncio
|
|
88
|
+
|
|
89
|
+
async def main():
|
|
90
|
+
fetch_url_tool = FetchURL()
|
|
91
|
+
result = await fetch_url_tool(Params(url="https://trafilatura.readthedocs.io/en/latest/"))
|
|
92
|
+
print(result)
|
|
93
|
+
|
|
94
|
+
asyncio.run(main())
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
WebSearch tool allows you to search on the internet to get latest information, including news, documents, release notes, blog posts, papers, etc.
|
|
@@ -0,0 +1,126 @@
|
|
|
1
|
+
from pathlib import Path
|
|
2
|
+
from typing import override
|
|
3
|
+
|
|
4
|
+
import aiohttp
|
|
5
|
+
from kosong.tooling import CallableTool2, ToolReturnType
|
|
6
|
+
from pydantic import BaseModel, Field, ValidationError
|
|
7
|
+
|
|
8
|
+
import kimi_cli
|
|
9
|
+
from kimi_cli.config import Config
|
|
10
|
+
from kimi_cli.soul.toolset import get_current_tool_call_or_none
|
|
11
|
+
from kimi_cli.tools.utils import ToolResultBuilder, load_desc
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class Params(BaseModel):
|
|
15
|
+
query: str = Field(description="The query text to search for.")
|
|
16
|
+
limit: int = Field(
|
|
17
|
+
description=(
|
|
18
|
+
"The number of results to return. "
|
|
19
|
+
"Typically you do not need to set this value. "
|
|
20
|
+
"When the results do not contain what you need, "
|
|
21
|
+
"you probably want to give a more concrete query."
|
|
22
|
+
),
|
|
23
|
+
default=5,
|
|
24
|
+
ge=1,
|
|
25
|
+
le=20,
|
|
26
|
+
)
|
|
27
|
+
include_content: bool = Field(
|
|
28
|
+
description=(
|
|
29
|
+
"Whether to include the content of the web pages in the results. "
|
|
30
|
+
"It can consume a large amount of tokens when this is set to True. "
|
|
31
|
+
"You should avoid enabling this when `limit` is set to a large value."
|
|
32
|
+
),
|
|
33
|
+
default=False,
|
|
34
|
+
)
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class SearchWeb(CallableTool2[Params]):
|
|
38
|
+
name: str = "SearchWeb"
|
|
39
|
+
description: str = load_desc(Path(__file__).parent / "search.md", {})
|
|
40
|
+
params: type[Params] = Params
|
|
41
|
+
|
|
42
|
+
def __init__(self, config: Config, **kwargs):
|
|
43
|
+
super().__init__(**kwargs)
|
|
44
|
+
if config.services.moonshot_search is not None:
|
|
45
|
+
self._base_url = config.services.moonshot_search.base_url
|
|
46
|
+
self._api_key = config.services.moonshot_search.api_key.get_secret_value()
|
|
47
|
+
else:
|
|
48
|
+
self._base_url = ""
|
|
49
|
+
self._api_key = ""
|
|
50
|
+
|
|
51
|
+
@override
|
|
52
|
+
async def __call__(self, params: Params) -> ToolReturnType:
|
|
53
|
+
builder = ToolResultBuilder(max_line_length=None)
|
|
54
|
+
|
|
55
|
+
if not self._base_url or not self._api_key:
|
|
56
|
+
return builder.error(
|
|
57
|
+
"Search service is not configured. You may want to try other methods to search.",
|
|
58
|
+
brief="Search service not configured",
|
|
59
|
+
)
|
|
60
|
+
|
|
61
|
+
tool_call = get_current_tool_call_or_none()
|
|
62
|
+
assert tool_call is not None, "Tool call is expected to be set"
|
|
63
|
+
|
|
64
|
+
async with (
|
|
65
|
+
aiohttp.ClientSession() as session,
|
|
66
|
+
session.post(
|
|
67
|
+
self._base_url,
|
|
68
|
+
headers={
|
|
69
|
+
"User-Agent": kimi_cli.USER_AGENT,
|
|
70
|
+
"Authorization": f"Bearer {self._api_key}",
|
|
71
|
+
"X-Msh-Tool-Call-Id": tool_call.id,
|
|
72
|
+
},
|
|
73
|
+
json={
|
|
74
|
+
"text_query": params.query,
|
|
75
|
+
"limit": params.limit,
|
|
76
|
+
"enable_page_crawling": params.include_content,
|
|
77
|
+
"timeout_seconds": 30,
|
|
78
|
+
},
|
|
79
|
+
) as response,
|
|
80
|
+
):
|
|
81
|
+
if response.status != 200:
|
|
82
|
+
return builder.error(
|
|
83
|
+
(
|
|
84
|
+
f"Failed to search. Status: {response.status}. "
|
|
85
|
+
"This may indicates that the search service is currently unavailable."
|
|
86
|
+
),
|
|
87
|
+
brief="Failed to search",
|
|
88
|
+
)
|
|
89
|
+
|
|
90
|
+
try:
|
|
91
|
+
results = Response(**await response.json()).search_results
|
|
92
|
+
except ValidationError as e:
|
|
93
|
+
return builder.error(
|
|
94
|
+
(
|
|
95
|
+
f"Failed to parse search results. Error: {e}. "
|
|
96
|
+
"This may indicates that the search service is currently unavailable."
|
|
97
|
+
),
|
|
98
|
+
brief="Failed to parse search results",
|
|
99
|
+
)
|
|
100
|
+
|
|
101
|
+
for i, result in enumerate(results):
|
|
102
|
+
if i > 0:
|
|
103
|
+
builder.write("---\n\n")
|
|
104
|
+
builder.write(
|
|
105
|
+
f"Title: {result.title}\nDate: {result.date}\n"
|
|
106
|
+
f"URL: {result.url}\nSummary: {result.snippet}\n\n"
|
|
107
|
+
)
|
|
108
|
+
if result.content:
|
|
109
|
+
builder.write(f"{result.content}\n\n")
|
|
110
|
+
|
|
111
|
+
return builder.ok()
|
|
112
|
+
|
|
113
|
+
|
|
114
|
+
class SearchResult(BaseModel):
|
|
115
|
+
site_name: str
|
|
116
|
+
title: str
|
|
117
|
+
url: str
|
|
118
|
+
snippet: str
|
|
119
|
+
content: str = ""
|
|
120
|
+
date: str = ""
|
|
121
|
+
icon: str = ""
|
|
122
|
+
mime: str = ""
|
|
123
|
+
|
|
124
|
+
|
|
125
|
+
class Response(BaseModel):
|
|
126
|
+
search_results: list[SearchResult]
|
kimi_cli/ui/__init__.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
import asyncio
|
|
2
|
+
import contextlib
|
|
3
|
+
from collections.abc import Callable, Coroutine
|
|
4
|
+
from typing import Any
|
|
5
|
+
|
|
6
|
+
from kimi_cli.soul import Soul
|
|
7
|
+
from kimi_cli.soul.wire import Wire
|
|
8
|
+
from kimi_cli.utils.logging import logger
|
|
9
|
+
|
|
10
|
+
type UILoopFn = Callable[[Wire], Coroutine[Any, Any, None]]
|
|
11
|
+
"""A long-running async function to visualize the agent behavior."""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class RunCancelled(Exception):
|
|
15
|
+
"""The run was cancelled by the cancel event."""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
async def run_soul(
|
|
19
|
+
soul: Soul,
|
|
20
|
+
user_input: str,
|
|
21
|
+
ui_loop_fn: UILoopFn,
|
|
22
|
+
cancel_event: asyncio.Event,
|
|
23
|
+
):
|
|
24
|
+
"""
|
|
25
|
+
Run the soul with the given user input.
|
|
26
|
+
|
|
27
|
+
`cancel_event` is a outside handle that can be used to cancel the run. When the event is set,
|
|
28
|
+
the run will be gracefully stopped and a `RunCancelled` will be raised.
|
|
29
|
+
|
|
30
|
+
Raises:
|
|
31
|
+
ChatProviderError: When the LLM provider returns an error.
|
|
32
|
+
MaxStepsReached: When the maximum number of steps is reached.
|
|
33
|
+
RunCancelled: When the run is cancelled by the cancel event.
|
|
34
|
+
"""
|
|
35
|
+
wire = Wire()
|
|
36
|
+
logger.debug("Starting UI loop with function: {ui_loop_fn}", ui_loop_fn=ui_loop_fn)
|
|
37
|
+
|
|
38
|
+
ui_task = asyncio.create_task(ui_loop_fn(wire))
|
|
39
|
+
soul_task = asyncio.create_task(soul.run(user_input, wire))
|
|
40
|
+
|
|
41
|
+
cancel_event_task = asyncio.create_task(cancel_event.wait())
|
|
42
|
+
await asyncio.wait(
|
|
43
|
+
[soul_task, cancel_event_task],
|
|
44
|
+
return_when=asyncio.FIRST_COMPLETED,
|
|
45
|
+
)
|
|
46
|
+
|
|
47
|
+
try:
|
|
48
|
+
if cancel_event.is_set():
|
|
49
|
+
logger.debug("Cancelling the run task")
|
|
50
|
+
soul_task.cancel()
|
|
51
|
+
try:
|
|
52
|
+
await soul_task
|
|
53
|
+
except asyncio.CancelledError:
|
|
54
|
+
raise RunCancelled from None
|
|
55
|
+
else:
|
|
56
|
+
assert soul_task.done() # either stop event is set or the run task is done
|
|
57
|
+
cancel_event_task.cancel()
|
|
58
|
+
with contextlib.suppress(asyncio.CancelledError):
|
|
59
|
+
await cancel_event_task
|
|
60
|
+
soul_task.result() # this will raise if any exception was raised in the run task
|
|
61
|
+
finally:
|
|
62
|
+
logger.debug("Shutting down the visualization loop")
|
|
63
|
+
# shutting down the event queue should break the visualization loop
|
|
64
|
+
wire.shutdown()
|
|
65
|
+
try:
|
|
66
|
+
await asyncio.wait_for(ui_task, timeout=0.5)
|
|
67
|
+
except TimeoutError:
|
|
68
|
+
logger.warning("Visualization loop timed out")
|