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.

Files changed (76) hide show
  1. kimi_cli/CHANGELOG.md +304 -0
  2. kimi_cli/__init__.py +374 -0
  3. kimi_cli/agent.py +261 -0
  4. kimi_cli/agents/koder/README.md +3 -0
  5. kimi_cli/agents/koder/agent.yaml +24 -0
  6. kimi_cli/agents/koder/sub.yaml +11 -0
  7. kimi_cli/agents/koder/system.md +72 -0
  8. kimi_cli/config.py +138 -0
  9. kimi_cli/llm.py +8 -0
  10. kimi_cli/metadata.py +117 -0
  11. kimi_cli/prompts/metacmds/__init__.py +4 -0
  12. kimi_cli/prompts/metacmds/compact.md +74 -0
  13. kimi_cli/prompts/metacmds/init.md +21 -0
  14. kimi_cli/py.typed +0 -0
  15. kimi_cli/share.py +8 -0
  16. kimi_cli/soul/__init__.py +59 -0
  17. kimi_cli/soul/approval.py +69 -0
  18. kimi_cli/soul/context.py +142 -0
  19. kimi_cli/soul/denwarenji.py +37 -0
  20. kimi_cli/soul/kimisoul.py +248 -0
  21. kimi_cli/soul/message.py +76 -0
  22. kimi_cli/soul/toolset.py +25 -0
  23. kimi_cli/soul/wire.py +101 -0
  24. kimi_cli/tools/__init__.py +85 -0
  25. kimi_cli/tools/bash/__init__.py +97 -0
  26. kimi_cli/tools/bash/bash.md +31 -0
  27. kimi_cli/tools/dmail/__init__.py +38 -0
  28. kimi_cli/tools/dmail/dmail.md +15 -0
  29. kimi_cli/tools/file/__init__.py +21 -0
  30. kimi_cli/tools/file/glob.md +17 -0
  31. kimi_cli/tools/file/glob.py +149 -0
  32. kimi_cli/tools/file/grep.md +5 -0
  33. kimi_cli/tools/file/grep.py +285 -0
  34. kimi_cli/tools/file/patch.md +8 -0
  35. kimi_cli/tools/file/patch.py +131 -0
  36. kimi_cli/tools/file/read.md +14 -0
  37. kimi_cli/tools/file/read.py +139 -0
  38. kimi_cli/tools/file/replace.md +7 -0
  39. kimi_cli/tools/file/replace.py +132 -0
  40. kimi_cli/tools/file/write.md +5 -0
  41. kimi_cli/tools/file/write.py +107 -0
  42. kimi_cli/tools/mcp.py +85 -0
  43. kimi_cli/tools/task/__init__.py +156 -0
  44. kimi_cli/tools/task/task.md +26 -0
  45. kimi_cli/tools/test.py +55 -0
  46. kimi_cli/tools/think/__init__.py +21 -0
  47. kimi_cli/tools/think/think.md +1 -0
  48. kimi_cli/tools/todo/__init__.py +27 -0
  49. kimi_cli/tools/todo/set_todo_list.md +15 -0
  50. kimi_cli/tools/utils.py +150 -0
  51. kimi_cli/tools/web/__init__.py +4 -0
  52. kimi_cli/tools/web/fetch.md +1 -0
  53. kimi_cli/tools/web/fetch.py +94 -0
  54. kimi_cli/tools/web/search.md +1 -0
  55. kimi_cli/tools/web/search.py +126 -0
  56. kimi_cli/ui/__init__.py +68 -0
  57. kimi_cli/ui/acp/__init__.py +441 -0
  58. kimi_cli/ui/print/__init__.py +176 -0
  59. kimi_cli/ui/shell/__init__.py +326 -0
  60. kimi_cli/ui/shell/console.py +3 -0
  61. kimi_cli/ui/shell/liveview.py +158 -0
  62. kimi_cli/ui/shell/metacmd.py +309 -0
  63. kimi_cli/ui/shell/prompt.py +574 -0
  64. kimi_cli/ui/shell/setup.py +192 -0
  65. kimi_cli/ui/shell/update.py +204 -0
  66. kimi_cli/utils/changelog.py +101 -0
  67. kimi_cli/utils/logging.py +18 -0
  68. kimi_cli/utils/message.py +8 -0
  69. kimi_cli/utils/path.py +23 -0
  70. kimi_cli/utils/provider.py +64 -0
  71. kimi_cli/utils/pyinstaller.py +24 -0
  72. kimi_cli/utils/string.py +12 -0
  73. kimi_cli-0.35.dist-info/METADATA +24 -0
  74. kimi_cli-0.35.dist-info/RECORD +76 -0
  75. kimi_cli-0.35.dist-info/WHEEL +4 -0
  76. kimi_cli-0.35.dist-info/entry_points.txt +3 -0
@@ -0,0 +1,441 @@
1
+ import asyncio
2
+ import uuid
3
+
4
+ import acp
5
+ import streamingjson
6
+ from kosong.base.message import (
7
+ ContentPart,
8
+ TextPart,
9
+ ToolCall,
10
+ ToolCallPart,
11
+ )
12
+ from kosong.chat_provider import ChatProviderError
13
+ from kosong.tooling import ToolError, ToolOk, ToolResult
14
+
15
+ from kimi_cli.soul import LLMNotSet, MaxStepsReached, Soul
16
+ from kimi_cli.soul.wire import (
17
+ ApprovalRequest,
18
+ ApprovalResponse,
19
+ StatusUpdate,
20
+ StepBegin,
21
+ StepInterrupted,
22
+ Wire,
23
+ )
24
+ from kimi_cli.tools import extract_subtitle
25
+ from kimi_cli.ui import RunCancelled, run_soul
26
+ from kimi_cli.utils.logging import logger
27
+
28
+
29
+ class _ToolCallState:
30
+ """Manages the state of a single tool call for streaming updates."""
31
+
32
+ def __init__(self, tool_call: ToolCall):
33
+ # When the user rejected or cancelled a tool call, the step result may not
34
+ # be appended to the context. In this case, future step may emit tool call
35
+ # with the same tool call ID (on the LLM side). To avoid confusion of the
36
+ # ACP client, we need to ensure the uniqueness in the ACP connection.
37
+ self.acp_tool_call_id = str(uuid.uuid4())
38
+
39
+ self.tool_call = tool_call
40
+ self.args = tool_call.function.arguments or ""
41
+ self.lexer = streamingjson.Lexer()
42
+ if tool_call.function.arguments is not None:
43
+ self.lexer.append_string(tool_call.function.arguments)
44
+
45
+ def append_args_part(self, args_part: str):
46
+ """Append a new arguments part to the accumulated args and lexer."""
47
+ self.args += args_part
48
+ self.lexer.append_string(args_part)
49
+
50
+ def get_title(self) -> str:
51
+ """Get the current title with subtitle if available."""
52
+ tool_name = self.tool_call.function.name
53
+ subtitle = extract_subtitle(self.lexer, tool_name)
54
+ if subtitle:
55
+ return f"{tool_name}: {subtitle}"
56
+ return tool_name
57
+
58
+
59
+ class _RunState:
60
+ def __init__(self):
61
+ self.tool_calls: dict[str, _ToolCallState] = {}
62
+ """Map of tool call ID (LLM-side ID) to tool call state."""
63
+ self.last_tool_call: _ToolCallState | None = None
64
+ self.cancel_event = asyncio.Event()
65
+
66
+
67
+ class ACPAgentImpl:
68
+ """Implementation of the ACP Agent protocol."""
69
+
70
+ def __init__(self, soul: Soul, connection: acp.AgentSideConnection):
71
+ self.soul = soul
72
+ self.connection = connection
73
+ self.session_id: str | None = None
74
+ self.run_state: _RunState | None = None
75
+
76
+ async def initialize(self, params: acp.InitializeRequest) -> acp.InitializeResponse:
77
+ """Handle initialize request."""
78
+ logger.info(
79
+ "ACP server initialized with protocol version: {version}",
80
+ version=params.protocolVersion,
81
+ )
82
+
83
+ return acp.InitializeResponse(
84
+ protocolVersion=params.protocolVersion,
85
+ agentCapabilities=acp.schema.AgentCapabilities(
86
+ loadSession=False,
87
+ promptCapabilities=acp.schema.PromptCapabilities(
88
+ embeddedContext=False, image=False, audio=False
89
+ ),
90
+ ),
91
+ authMethods=[],
92
+ )
93
+
94
+ async def authenticate(self, params: acp.AuthenticateRequest) -> None:
95
+ """Handle authenticate request."""
96
+ logger.info("Authenticate with method: {method}", method=params.methodId)
97
+
98
+ async def newSession(self, params: acp.NewSessionRequest) -> acp.NewSessionResponse:
99
+ """Handle new session request."""
100
+ self.session_id = f"sess_{uuid.uuid4().hex[:16]}"
101
+ logger.info("Created session {id} with cwd: {cwd}", id=self.session_id, cwd=params.cwd)
102
+ return acp.NewSessionResponse(sessionId=self.session_id)
103
+
104
+ async def loadSession(self, params: acp.LoadSessionRequest) -> None:
105
+ """Handle load session request."""
106
+ self.session_id = params.sessionId
107
+ logger.info("Loaded session: {id}", id=self.session_id)
108
+
109
+ async def setSessionModel(self, params: acp.SetSessionModelRequest) -> None:
110
+ """Handle set session model request."""
111
+ logger.warning("Set session model: {model}", model=params.modelId)
112
+
113
+ async def setSessionMode(
114
+ self, params: acp.SetSessionModeRequest
115
+ ) -> acp.SetSessionModeResponse | None:
116
+ """Handle set session mode request."""
117
+ logger.warning("Set session mode: {mode}", mode=params.modeId)
118
+ return None
119
+
120
+ async def extMethod(self, method: str, params: dict) -> dict:
121
+ """Handle extension method."""
122
+ logger.warning("Unsupported extension method: {method}", method=method)
123
+ return {}
124
+
125
+ async def extNotification(self, method: str, params: dict) -> None:
126
+ """Handle extension notification."""
127
+ logger.warning("Unsupported extension notification: {method}", method=method)
128
+
129
+ async def prompt(self, params: acp.PromptRequest) -> acp.PromptResponse:
130
+ """Handle prompt request with streaming support."""
131
+ # Extract text from prompt content blocks
132
+ prompt_text = "\n".join(
133
+ block.text for block in params.prompt if isinstance(block, acp.schema.TextContentBlock)
134
+ )
135
+
136
+ if not prompt_text:
137
+ raise acp.RequestError.invalid_params({"reason": "No text in prompt"})
138
+
139
+ logger.info("Processing prompt: {text}", text=prompt_text[:100])
140
+
141
+ self.run_state = _RunState()
142
+ try:
143
+ await run_soul(self.soul, prompt_text, self._stream_events, self.run_state.cancel_event)
144
+ return acp.PromptResponse(stopReason="end_turn")
145
+ except LLMNotSet:
146
+ logger.error("LLM not set")
147
+ raise acp.RequestError.internal_error({"error": "LLM not set"}) from None
148
+ except ChatProviderError as e:
149
+ logger.exception("LLM provider error:")
150
+ raise acp.RequestError.internal_error({"error": f"LLM provider error: {e}"}) from e
151
+ except MaxStepsReached as e:
152
+ logger.warning("Max steps reached: {n}", n=e.n_steps)
153
+ return acp.PromptResponse(stopReason="max_turn_requests")
154
+ except RunCancelled:
155
+ logger.info("Prompt cancelled by user")
156
+ return acp.PromptResponse(stopReason="cancelled")
157
+ except BaseException as e:
158
+ logger.exception("Unknown error:")
159
+ raise acp.RequestError.internal_error({"error": f"Unknown error: {e}"}) from e
160
+ finally:
161
+ self.run_state = None
162
+
163
+ async def cancel(self, params: acp.CancelNotification) -> None:
164
+ """Handle cancel notification."""
165
+ logger.info("Cancel for session: {id}", id=params.sessionId)
166
+
167
+ if self.run_state is None:
168
+ logger.warning("No running prompt to cancel")
169
+ return
170
+
171
+ if not self.run_state.cancel_event.is_set():
172
+ logger.info("Cancelling running prompt")
173
+ self.run_state.cancel_event.set()
174
+
175
+ async def _stream_events(self, wire: Wire):
176
+ try:
177
+ # expect a StepBegin
178
+ assert isinstance(await wire.receive(), StepBegin)
179
+
180
+ while True:
181
+ msg = await wire.receive()
182
+
183
+ if isinstance(msg, TextPart):
184
+ await self._send_text(msg.text)
185
+ elif isinstance(msg, ContentPart):
186
+ logger.warning("Unsupported content part: {part}", part=msg)
187
+ await self._send_text(f"[{msg.__class__.__name__}]")
188
+ elif isinstance(msg, ToolCall):
189
+ await self._send_tool_call(msg)
190
+ elif isinstance(msg, ToolCallPart):
191
+ await self._send_tool_call_part(msg)
192
+ elif isinstance(msg, ToolResult):
193
+ await self._send_tool_result(msg)
194
+ elif isinstance(msg, ApprovalRequest):
195
+ await self._handle_approval_request(msg)
196
+ elif isinstance(msg, StatusUpdate):
197
+ # TODO: stream status if needed
198
+ pass
199
+ elif isinstance(msg, StepInterrupted):
200
+ break
201
+ except asyncio.QueueShutDown:
202
+ logger.debug("Event stream loop shutting down")
203
+
204
+ async def _send_text(self, text: str):
205
+ """Send text chunk to client."""
206
+ if not self.session_id:
207
+ return
208
+
209
+ await self.connection.sessionUpdate(
210
+ acp.SessionNotification(
211
+ sessionId=self.session_id,
212
+ update=acp.schema.AgentMessageChunk(
213
+ content=acp.schema.TextContentBlock(type="text", text=text),
214
+ sessionUpdate="agent_message_chunk",
215
+ ),
216
+ )
217
+ )
218
+
219
+ async def _send_tool_call(self, tool_call: ToolCall):
220
+ """Send tool call to client."""
221
+ assert self.run_state is not None
222
+ if not self.session_id:
223
+ return
224
+
225
+ # Create and store tool call state
226
+ state = _ToolCallState(tool_call)
227
+ self.run_state.tool_calls[tool_call.id] = state
228
+ self.run_state.last_tool_call = state
229
+
230
+ await self.connection.sessionUpdate(
231
+ acp.SessionNotification(
232
+ sessionId=self.session_id,
233
+ update=acp.schema.ToolCallStart(
234
+ sessionUpdate="tool_call",
235
+ toolCallId=state.acp_tool_call_id,
236
+ title=state.get_title(),
237
+ status="in_progress",
238
+ content=[
239
+ acp.schema.ContentToolCallContent(
240
+ type="content",
241
+ content=acp.schema.TextContentBlock(type="text", text=state.args),
242
+ )
243
+ ],
244
+ ),
245
+ )
246
+ )
247
+ logger.debug("Sent tool call: {name}", name=tool_call.function.name)
248
+
249
+ async def _send_tool_call_part(self, part: ToolCallPart):
250
+ """Send tool call part (streaming arguments)."""
251
+ assert self.run_state is not None
252
+ if not self.session_id or not part.arguments_part or self.run_state.last_tool_call is None:
253
+ return
254
+
255
+ # Append new arguments part to the last tool call
256
+ self.run_state.last_tool_call.append_args_part(part.arguments_part)
257
+
258
+ # Update the tool call with new content and title
259
+ update = acp.schema.ToolCallProgress(
260
+ sessionUpdate="tool_call_update",
261
+ toolCallId=self.run_state.last_tool_call.acp_tool_call_id,
262
+ title=self.run_state.last_tool_call.get_title(),
263
+ status="in_progress",
264
+ content=[
265
+ acp.schema.ContentToolCallContent(
266
+ type="content",
267
+ content=acp.schema.TextContentBlock(
268
+ type="text", text=self.run_state.last_tool_call.args
269
+ ),
270
+ )
271
+ ],
272
+ )
273
+
274
+ await self.connection.sessionUpdate(
275
+ acp.SessionNotification(sessionId=self.session_id, update=update)
276
+ )
277
+ logger.debug("Sent tool call update: {delta}", delta=part.arguments_part[:50])
278
+
279
+ async def _send_tool_result(self, result: ToolResult):
280
+ """Send tool result to client."""
281
+ assert self.run_state is not None
282
+ if not self.session_id:
283
+ return
284
+
285
+ tool_result = result.result
286
+ is_error = isinstance(tool_result, ToolError)
287
+
288
+ state = self.run_state.tool_calls.pop(result.tool_call_id, None)
289
+ if state is None:
290
+ logger.warning("Tool call not found: {id}", id=result.tool_call_id)
291
+ return
292
+
293
+ update = acp.schema.ToolCallProgress(
294
+ sessionUpdate="tool_call_update",
295
+ toolCallId=state.acp_tool_call_id,
296
+ status="failed" if is_error else "completed",
297
+ )
298
+
299
+ if state.tool_call.function.name == "SetTodoList" and not is_error:
300
+ update.content = _tool_result_to_acp_content(tool_result)
301
+
302
+ await self.connection.sessionUpdate(
303
+ acp.SessionNotification(sessionId=self.session_id, update=update)
304
+ )
305
+
306
+ logger.debug("Sent tool result: {id}", id=result.tool_call_id)
307
+
308
+ async def _handle_approval_request(self, request: ApprovalRequest):
309
+ """Handle approval request by sending permission request to client."""
310
+ assert self.run_state is not None
311
+ if not self.session_id:
312
+ logger.warning("No session ID, auto-rejecting approval request")
313
+ request.resolve(ApprovalResponse.REJECT)
314
+ return
315
+
316
+ state = self.run_state.tool_calls.get(request.tool_call_id, None)
317
+ if state is None:
318
+ logger.warning("Tool call not found: {id}", id=request.tool_call_id)
319
+ request.resolve(ApprovalResponse.REJECT)
320
+ return
321
+
322
+ # Create permission request with options
323
+ permission_request = acp.RequestPermissionRequest(
324
+ sessionId=self.session_id,
325
+ toolCall=acp.schema.ToolCallUpdate(
326
+ toolCallId=state.acp_tool_call_id,
327
+ content=[
328
+ acp.schema.ContentToolCallContent(
329
+ type="content",
330
+ content=acp.schema.TextContentBlock(
331
+ type="text",
332
+ text=f"Requesting approval to perform: {request.description}",
333
+ ),
334
+ ),
335
+ ],
336
+ ),
337
+ options=[
338
+ acp.schema.PermissionOption(
339
+ optionId="approve",
340
+ name="Approve",
341
+ kind="allow_once",
342
+ ),
343
+ acp.schema.PermissionOption(
344
+ optionId="approve_for_session",
345
+ name="Approve for this session",
346
+ kind="allow_always",
347
+ ),
348
+ acp.schema.PermissionOption(
349
+ optionId="reject",
350
+ name="Reject",
351
+ kind="reject_once",
352
+ ),
353
+ ],
354
+ )
355
+
356
+ try:
357
+ # Send permission request and wait for response
358
+ logger.debug("Requesting permission for action: {action}", action=request.action)
359
+ response = await self.connection.requestPermission(permission_request)
360
+ logger.debug("Received permission response: {response}", response=response)
361
+
362
+ # Process the outcome
363
+ if isinstance(response.outcome, acp.schema.AllowedOutcome):
364
+ # selected
365
+ if response.outcome.optionId == "approve":
366
+ logger.debug("Permission granted for: {action}", action=request.action)
367
+ request.resolve(ApprovalResponse.APPROVE)
368
+ elif response.outcome.optionId == "approve_for_session":
369
+ logger.debug("Permission granted for session: {action}", action=request.action)
370
+ request.resolve(ApprovalResponse.APPROVE_FOR_SESSION)
371
+ else:
372
+ logger.debug("Permission denied for: {action}", action=request.action)
373
+ request.resolve(ApprovalResponse.REJECT)
374
+ else:
375
+ # cancelled
376
+ logger.debug("Permission request cancelled for: {action}", action=request.action)
377
+ request.resolve(ApprovalResponse.REJECT)
378
+ except Exception:
379
+ logger.exception("Error handling approval request:")
380
+ # On error, reject the request
381
+ request.resolve(ApprovalResponse.REJECT)
382
+
383
+
384
+ def _tool_result_to_acp_content(
385
+ tool_result: ToolOk | ToolError,
386
+ ) -> list[
387
+ acp.schema.ContentToolCallContent
388
+ | acp.schema.FileEditToolCallContent
389
+ | acp.schema.TerminalToolCallContent
390
+ ]:
391
+ def _to_acp_content(part: ContentPart) -> acp.schema.ContentToolCallContent:
392
+ if isinstance(part, TextPart):
393
+ return acp.schema.ContentToolCallContent(
394
+ type="content", content=acp.schema.TextContentBlock(type="text", text=part.text)
395
+ )
396
+ else:
397
+ logger.warning("Unsupported content part in tool result: {part}", part=part)
398
+ return acp.schema.ContentToolCallContent(
399
+ type="content",
400
+ content=acp.schema.TextContentBlock(
401
+ type="text", text=f"[{part.__class__.__name__}]"
402
+ ),
403
+ )
404
+
405
+ content = []
406
+ if isinstance(tool_result.output, str):
407
+ content.append(_to_acp_content(TextPart(text=tool_result.output)))
408
+ elif isinstance(tool_result.output, ContentPart):
409
+ content.append(_to_acp_content(tool_result.output))
410
+ elif isinstance(tool_result.output, list):
411
+ content.extend(_to_acp_content(part) for part in tool_result.output)
412
+
413
+ return content
414
+
415
+
416
+ class ACPServer:
417
+ """ACP server using the official acp library."""
418
+
419
+ def __init__(self, soul: Soul):
420
+ self.soul = soul
421
+
422
+ async def run(self) -> bool:
423
+ """Run the ACP server."""
424
+ logger.info("Starting ACP server on stdio")
425
+
426
+ # Get stdio streams
427
+ reader, writer = await acp.stdio_streams()
428
+
429
+ # Create connection - the library handles all JSON-RPC details!
430
+ _ = acp.AgentSideConnection(
431
+ lambda conn: ACPAgentImpl(self.soul, conn),
432
+ writer,
433
+ reader,
434
+ )
435
+
436
+ logger.info("ACP server ready")
437
+
438
+ # Keep running - connection handles everything
439
+ await asyncio.Event().wait()
440
+
441
+ return True
@@ -0,0 +1,176 @@
1
+ import asyncio
2
+ import json
3
+ import signal
4
+ import sys
5
+ from functools import partial
6
+ from typing import Literal
7
+
8
+ import aiofiles
9
+ from kosong.base.message import Message
10
+ from kosong.chat_provider import ChatProviderError
11
+
12
+ from kimi_cli.soul import LLMNotSet, MaxStepsReached
13
+ from kimi_cli.soul.kimisoul import KimiSoul
14
+ from kimi_cli.soul.wire import StepInterrupted, Wire
15
+ from kimi_cli.ui import RunCancelled, run_soul
16
+ from kimi_cli.utils.logging import logger
17
+ from kimi_cli.utils.message import message_extract_text
18
+
19
+ InputFormat = Literal["text", "stream-json"]
20
+ OutputFormat = Literal["text", "stream-json"]
21
+
22
+
23
+ class PrintApp:
24
+ """
25
+ An app implementation that prints the agent behavior to the console.
26
+
27
+ Args:
28
+ soul (KimiSoul): The soul to run. Only `KimiSoul` is supported.
29
+ input_format (InputFormat): The input format to use.
30
+ output_format (OutputFormat): The output format to use.
31
+ """
32
+
33
+ def __init__(self, soul: KimiSoul, input_format: InputFormat, output_format: OutputFormat):
34
+ self.soul = soul
35
+ self.input_format = input_format
36
+ self.output_format = output_format
37
+ self.soul._approval.set_yolo(True)
38
+ # TODO(approval): proper approval request handling
39
+
40
+ async def run(self, command: str | None = None) -> bool:
41
+ cancel_event = asyncio.Event()
42
+
43
+ def _handler():
44
+ logger.debug("SIGINT received.")
45
+ cancel_event.set()
46
+
47
+ loop = asyncio.get_running_loop()
48
+ loop.add_signal_handler(signal.SIGINT, _handler)
49
+
50
+ if command is None and not sys.stdin.isatty() and self.input_format == "text":
51
+ command = sys.stdin.read().strip()
52
+ logger.info("Read command from stdin: {command}", command=command)
53
+
54
+ try:
55
+ while True:
56
+ if command is None:
57
+ if self.input_format == "text":
58
+ return True
59
+ else:
60
+ assert self.input_format == "stream-json"
61
+ command = self._read_next_command()
62
+ if command is None:
63
+ return True
64
+
65
+ if command:
66
+ logger.info("Running agent with command: {command}", command=command)
67
+ if self.output_format == "text":
68
+ visualize_fn = self._visualize_text
69
+ print(command)
70
+ else:
71
+ assert self.output_format == "stream-json"
72
+ visualize_fn = partial(self._visualize_stream_json, start_position=0)
73
+ await run_soul(self.soul, command, visualize_fn, cancel_event)
74
+ else:
75
+ logger.info("Empty command, skipping")
76
+
77
+ command = None
78
+ except LLMNotSet:
79
+ logger.error("LLM not set")
80
+ print("LLM not set")
81
+ except ChatProviderError as e:
82
+ logger.exception("LLM provider error:")
83
+ print(f"LLM provider error: {e}")
84
+ except MaxStepsReached as e:
85
+ logger.warning("Max steps reached: {n_steps}", n_steps=e.n_steps)
86
+ print(f"Max steps reached: {e.n_steps}")
87
+ except RunCancelled:
88
+ logger.error("Interrupted by user")
89
+ print("Interrupted by user")
90
+ except BaseException as e:
91
+ logger.exception("Unknown error:")
92
+ print(f"Unknown error: {e}")
93
+ raise
94
+ finally:
95
+ loop.remove_signal_handler(signal.SIGINT)
96
+ return False
97
+
98
+ # TODO: unify with `_soul_run` in `ShellApp` and `ACPAgentImpl`
99
+ async def _soul_run(self, user_input: str):
100
+ wire = Wire()
101
+ logger.debug("Starting visualization loop")
102
+
103
+ if self.output_format == "text":
104
+ vis_task = asyncio.create_task(self._visualize_text(wire))
105
+ else:
106
+ assert self.output_format == "stream-json"
107
+ if not self.soul._context._file_backend.exists():
108
+ self.soul._context._file_backend.touch()
109
+ start_position = self.soul._context._file_backend.stat().st_size
110
+ vis_task = asyncio.create_task(self._visualize_stream_json(wire, start_position))
111
+
112
+ try:
113
+ await self.soul.run(user_input, wire)
114
+ finally:
115
+ wire.shutdown()
116
+ # shutting down the event queue should break the visualization loop
117
+ try:
118
+ await asyncio.wait_for(vis_task, timeout=0.5)
119
+ except TimeoutError:
120
+ logger.warning("Visualization loop timed out")
121
+
122
+ def _read_next_command(self) -> str | None:
123
+ while True:
124
+ json_line = sys.stdin.readline()
125
+ if not json_line:
126
+ # EOF
127
+ return None
128
+
129
+ json_line = json_line.strip()
130
+ if not json_line:
131
+ # for empty line, read next line
132
+ continue
133
+
134
+ try:
135
+ data = json.loads(json_line)
136
+ message = Message.model_validate(data)
137
+ if message.role == "user":
138
+ return message_extract_text(message)
139
+ logger.warning(
140
+ "Ignoring message with role `{role}`: {json_line}",
141
+ role=message.role,
142
+ json_line=json_line,
143
+ )
144
+ except Exception:
145
+ logger.warning("Ignoring invalid user message: {json_line}", json_line=json_line)
146
+
147
+ async def _visualize_text(self, wire: Wire):
148
+ try:
149
+ while True:
150
+ msg = await wire.receive()
151
+ print(msg)
152
+ if isinstance(msg, StepInterrupted):
153
+ break
154
+ except asyncio.QueueShutDown:
155
+ logger.debug("Visualization loop shutting down")
156
+
157
+ async def _visualize_stream_json(self, wire: Wire, start_position: int):
158
+ try:
159
+ async with aiofiles.open(self.soul._context._file_backend, encoding="utf-8") as f:
160
+ await f.seek(start_position)
161
+ while True:
162
+ should_end = False
163
+ while wire._queue.qsize() > 0:
164
+ msg = wire._queue.get_nowait()
165
+ if isinstance(msg, StepInterrupted):
166
+ should_end = True
167
+
168
+ line = await f.readline()
169
+ if not line:
170
+ if should_end:
171
+ break
172
+ await asyncio.sleep(0.1)
173
+ continue
174
+ print(line, end="")
175
+ except asyncio.QueueShutDown:
176
+ logger.debug("Visualization loop shutting down")