citrus-code 0.1.0__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.
Files changed (70) hide show
  1. citrus_code/__init__.py +0 -0
  2. citrus_code/__main__.py +5 -0
  3. citrus_code/agent/__init__.py +0 -0
  4. citrus_code/agent/agent.py +255 -0
  5. citrus_code/agent/cancellation.py +66 -0
  6. citrus_code/agent/context.py +20 -0
  7. citrus_code/agent/events.py +97 -0
  8. citrus_code/agent/hooks.py +37 -0
  9. citrus_code/agent/loop.py +429 -0
  10. citrus_code/agent/message_queue.py +44 -0
  11. citrus_code/agent/state.py +13 -0
  12. citrus_code/agent/tool_runner.py +161 -0
  13. citrus_code/agent/types.py +34 -0
  14. citrus_code/ai/__init__.py +0 -0
  15. citrus_code/ai/completion.py +30 -0
  16. citrus_code/ai/events.py +78 -0
  17. citrus_code/ai/provider.py +20 -0
  18. citrus_code/ai/providers/__init__.py +0 -0
  19. citrus_code/ai/providers/chat_completions.py +503 -0
  20. citrus_code/ai/providers/chat_stream_types.py +47 -0
  21. citrus_code/ai/providers/deepseek.py +104 -0
  22. citrus_code/ai/providers/fake.py +101 -0
  23. citrus_code/ai/providers/tool_call_buffer.py +73 -0
  24. citrus_code/ai/registry.py +40 -0
  25. citrus_code/ai/types.py +142 -0
  26. citrus_code/cli/__init__.py +5 -0
  27. citrus_code/cli/main.py +258 -0
  28. citrus_code/cli/preview.py +55 -0
  29. citrus_code/cli/prompts.py +52 -0
  30. citrus_code/cli/render.py +306 -0
  31. citrus_code/cli/session_files.py +75 -0
  32. citrus_code/cli/terminal.py +280 -0
  33. citrus_code/cli/theme.py +51 -0
  34. citrus_code/coding/__init__.py +0 -0
  35. citrus_code/coding/coding_session.py +287 -0
  36. citrus_code/coding/compaction/__init__.py +5 -0
  37. citrus_code/coding/compaction/controller.py +168 -0
  38. citrus_code/coding/compaction/summarize.py +125 -0
  39. citrus_code/coding/compaction/tokens.py +48 -0
  40. citrus_code/coding/context_hooks.py +41 -0
  41. citrus_code/coding/history.py +47 -0
  42. citrus_code/coding/project_context.py +44 -0
  43. citrus_code/coding/session/__init__.py +30 -0
  44. citrus_code/coding/session/manager.py +200 -0
  45. citrus_code/coding/session/projection.py +50 -0
  46. citrus_code/coding/session/recovery.py +33 -0
  47. citrus_code/coding/session/storage.py +138 -0
  48. citrus_code/coding/session/types.py +108 -0
  49. citrus_code/coding/tools/__init__.py +35 -0
  50. citrus_code/coding/tools/bash.py +5 -0
  51. citrus_code/coding/tools/edit.py +101 -0
  52. citrus_code/coding/tools/edit_text.py +172 -0
  53. citrus_code/coding/tools/factory.py +57 -0
  54. citrus_code/coding/tools/find.py +86 -0
  55. citrus_code/coding/tools/grep.py +151 -0
  56. citrus_code/coding/tools/ls.py +61 -0
  57. citrus_code/coding/tools/mutation.py +39 -0
  58. citrus_code/coding/tools/paths.py +11 -0
  59. citrus_code/coding/tools/powershell.py +5 -0
  60. citrus_code/coding/tools/processes.py +103 -0
  61. citrus_code/coding/tools/read.py +133 -0
  62. citrus_code/coding/tools/shell.py +218 -0
  63. citrus_code/coding/tools/truncate.py +87 -0
  64. citrus_code/coding/tools/write.py +94 -0
  65. citrus_code/config.py +34 -0
  66. citrus_code-0.1.0.dist-info/METADATA +412 -0
  67. citrus_code-0.1.0.dist-info/RECORD +70 -0
  68. citrus_code-0.1.0.dist-info/WHEEL +4 -0
  69. citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
  70. citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
@@ -0,0 +1,75 @@
1
+ """CLI session discovery, launch settings, and an OS-held writer lock."""
2
+
3
+ import os
4
+ from collections.abc import Iterator
5
+ from contextlib import contextmanager
6
+ from pathlib import Path
7
+ from typing import Literal
8
+
9
+ from pydantic import BaseModel, ConfigDict, Field
10
+
11
+ if os.name == "nt":
12
+ import msvcrt
13
+ else:
14
+ import fcntl
15
+
16
+
17
+ ShellName = Literal["bash", "powershell"]
18
+
19
+
20
+ class LaunchSettings(BaseModel):
21
+ model_config = ConfigDict(extra="forbid", strict=True)
22
+
23
+ version: Literal[1] = 1
24
+ shell: ShellName
25
+ tools: list[str] = Field(min_length=1)
26
+
27
+
28
+ def settings_path(session_path: Path) -> Path:
29
+ return session_path.with_suffix(".settings.json")
30
+
31
+
32
+ def load_settings(session_path: Path) -> LaunchSettings | None:
33
+ path = settings_path(session_path)
34
+ if not path.exists():
35
+ return None
36
+ return LaunchSettings.model_validate_json(path.read_bytes())
37
+
38
+
39
+ def save_settings(session_path: Path, settings: LaunchSettings) -> None:
40
+ with settings_path(session_path).open("x", encoding="utf-8", newline="\n") as file:
41
+ file.write(settings.model_dump_json(indent=2) + "\n")
42
+
43
+
44
+ def session_paths(directory: Path) -> list[Path]:
45
+ if not directory.exists():
46
+ return []
47
+ return sorted(directory.glob("*.jsonl"), key=lambda p: p.stat().st_mtime_ns, reverse=True)
48
+
49
+
50
+ @contextmanager
51
+ def session_lease(path: Path) -> Iterator[None]:
52
+ """The persistent lock file is reused; the OS releases locks on process exit."""
53
+ path.parent.mkdir(parents=True, exist_ok=True)
54
+ with path.with_suffix(".jsonl.lock").open("a+b") as file:
55
+ try:
56
+ if os.fstat(file.fileno()).st_size == 0:
57
+ file.write(b"\0")
58
+ file.flush()
59
+ file.seek(0)
60
+ if os.name == "nt":
61
+ msvcrt.locking(file.fileno(), msvcrt.LK_NBLCK, 1)
62
+ else:
63
+ fcntl.flock(file.fileno(), fcntl.LOCK_EX | fcntl.LOCK_NB)
64
+ except OSError as exc:
65
+ raise RuntimeError(
66
+ f"无法取得会话写入锁,文件可能已由另一个 CLI 使用:{path}"
67
+ ) from exc
68
+ try:
69
+ yield
70
+ finally:
71
+ file.seek(0)
72
+ if os.name == "nt":
73
+ msvcrt.locking(file.fileno(), msvcrt.LK_UNLCK, 1)
74
+ else:
75
+ fcntl.flock(file.fileno(), fcntl.LOCK_UN)
@@ -0,0 +1,280 @@
1
+ """Concurrent terminal input and Agent output, with cooperative cancellation."""
2
+
3
+ import asyncio
4
+ import os
5
+ import signal
6
+ from collections.abc import Iterator
7
+ from contextlib import aclosing, contextmanager
8
+ from dataclasses import dataclass
9
+ from types import FrameType
10
+
11
+ from prompt_toolkit import PromptSession
12
+ from prompt_toolkit.auto_suggest import AutoSuggestFromHistory
13
+ from prompt_toolkit.completion import WordCompleter
14
+ from prompt_toolkit.formatted_text import FormattedText
15
+ from prompt_toolkit.key_binding import KeyBindings, KeyPressEvent
16
+ from prompt_toolkit.output import ColorDepth
17
+ from prompt_toolkit.patch_stdout import patch_stdout
18
+
19
+ from ..agent.events import AgentEndEvent
20
+ from ..coding.coding_session import CodingSession
21
+ from ..coding.session.recovery import recover_pending_tools
22
+ from .render import Console
23
+ from .preview import MarkdownPreview
24
+ from .theme import PROMPT_STYLE, clean_text
25
+
26
+
27
+ HELP = """命令:
28
+ /help 显示帮助
29
+ /status 当前运行状态、分支和队列状态
30
+ /history 显示当前分支的对话
31
+ /tree 列出节点;* 表示当前节点
32
+ /checkout <节点ID> 切换分支,支持唯一的 ID 前缀;需要空闲且无排队消息
33
+ /steer <消息> 向正在运行的 Agent 追加引导消息
34
+ /follow <消息> 排队,等待 Agent 正常完成后处理
35
+ /clear-queue 清空尚未接纳的排队消息
36
+ /recover 空闲时补齐当前分支末尾缺失的工具结果
37
+ /abort 取消当前任务,等待收尾
38
+ /quit 取消当前任务并退出
39
+ 运行时直接输入普通文字也会作为引导消息排队。
40
+ Ctrl+C:运行时取消当前任务;空闲时退出。Ctrl+D:退出。
41
+ Enter:发送。Alt+Enter 或 Ctrl+J:换行。Tab:补全命令。
42
+ """
43
+
44
+
45
+ class PromptInterrupted(Exception):
46
+ """Keep Ctrl+C local to the input task instead of stopping asyncio itself."""
47
+
48
+
49
+ @dataclass(frozen=True)
50
+ class RunOutcome:
51
+ exit_code: int
52
+ fatal: bool = False
53
+
54
+
55
+ async def run_turn(session: CodingSession, console: Console, text: str) -> RunOutcome:
56
+ end: AgentEndEvent | None = None
57
+ try:
58
+ console.begin_turn(text)
59
+ async with aclosing(session.prompt(text)) as stream:
60
+ async for event in stream:
61
+ console.event(event)
62
+ if isinstance(event, AgentEndEvent):
63
+ end = event
64
+ if end is None:
65
+ raise RuntimeError("Agent 事件流结束,但未收到结束事件")
66
+ if end.reason == "aborted":
67
+ return RunOutcome(130)
68
+ return RunOutcome(0 if end.reason == "stop" else 1)
69
+ except Exception as exc:
70
+ console.finish_response()
71
+ console.note(f"[运行失败] {type(exc).__name__}: {exc}")
72
+ console.note("本次 CLI 将退出。请检查会话文件后使用 --resume 重新加载。")
73
+ return RunOutcome(2, fatal=True)
74
+ finally:
75
+ console.finish_response()
76
+ console.phase = "就绪"
77
+
78
+
79
+ @contextmanager
80
+ def cancel_on_sigint(session: CodingSession, console: Console) -> Iterator[None]:
81
+ loop = asyncio.get_running_loop()
82
+
83
+ def on_signal(signum: int, frame: FrameType | None) -> None:
84
+ loop.call_soon_threadsafe(session.abort)
85
+ loop.call_soon_threadsafe(console.note, "[取消] 正在等待任务收尾……")
86
+
87
+ previous = signal.signal(signal.SIGINT, on_signal)
88
+ try:
89
+ yield
90
+ finally:
91
+ signal.signal(signal.SIGINT, previous)
92
+
93
+
94
+ class InteractiveTerminal:
95
+ def __init__(self, session: CodingSession, console: Console) -> None:
96
+ self.session = session
97
+ self.console = console
98
+ self._active: asyncio.Task[RunOutcome] | None = None
99
+
100
+ async def _stop_active(self) -> int:
101
+ task = self._active
102
+ if task is None:
103
+ return 0
104
+ if not task.done():
105
+ if self.session.is_running:
106
+ self.session.abort()
107
+ else:
108
+ # A newly scheduled turn may not have entered Agent.prompt yet.
109
+ task.cancel()
110
+ results = await asyncio.gather(task, return_exceptions=True)
111
+ self._active = None
112
+ result = results[0]
113
+ if isinstance(result, RunOutcome):
114
+ return result.exit_code if result.fatal else 0
115
+ if not isinstance(result, asyncio.CancelledError):
116
+ self.console.note(f"[任务退出失败] {result}")
117
+ return 2
118
+ return 0
119
+
120
+ def _command(self, text: str) -> bool:
121
+ command, _, argument = text.partition(" ")
122
+ argument = argument.strip()
123
+ active = self._active is not None
124
+ if command in ("/quit", "/exit"):
125
+ return True
126
+ if command == "/help":
127
+ self.console.note(HELP)
128
+ elif command == "/abort":
129
+ self.session.abort()
130
+ self.console.note("[取消] 正在等待任务收尾……" if active else "当前没有运行中的任务。")
131
+ elif command == "/status":
132
+ self.console.status(self.session)
133
+ elif command == "/history":
134
+ self.console.history(self.session)
135
+ elif command == "/tree":
136
+ self.console.tree(self.session)
137
+ elif command in ("/steer", "/follow"):
138
+ if not active:
139
+ raise ValueError("当前没有运行中的任务,请直接输入新问题。")
140
+ if not argument:
141
+ raise ValueError(f"用法:{command} <消息>")
142
+ if command == "/steer":
143
+ self.session.steer(argument)
144
+ else:
145
+ self.session.follow_up(argument)
146
+ self.console.note("[已排队] 消息将在运行时接纳后保存。")
147
+ elif command == "/clear-queue":
148
+ self.session.agent.clear_all_queues()
149
+ self.console.note("已清空尚未接纳的排队消息。")
150
+ elif command == "/checkout":
151
+ if not argument:
152
+ raise ValueError("用法:/checkout <节点ID或唯一前缀>")
153
+ candidates = [
154
+ entry.id for entry in self.session.session_manager.get_entries()
155
+ if entry.id.startswith(argument)
156
+ ]
157
+ if len(candidates) != 1:
158
+ raise ValueError("节点不存在或前缀不唯一,请用 /tree 查看。")
159
+ self.session.checkout(candidates[0])
160
+ self.console.note(f"已切换到 {candidates[0]}。项目文件仍保持当前磁盘状态。")
161
+ elif command == "/recover":
162
+ if active or self.session.has_queued_messages():
163
+ raise ValueError("恢复需要空闲且没有排队消息。")
164
+ count = recover_pending_tools(self.session.session_manager)
165
+ self.session.agent.replace_messages(
166
+ self.session.session_manager.build_context().messages
167
+ )
168
+ self.console.note(f"已补齐 {count} 条执行状态未知的工具结果。")
169
+ else:
170
+ raise ValueError("未知命令。输入 /help 查看帮助。")
171
+ return False
172
+
173
+ def _toolbar(self) -> FormattedText:
174
+ active = self._active is not None
175
+ estimate = self.session.compaction.last_estimate if self.session.compaction is not None else None
176
+ context = f"上下文约 {estimate / 1000:.1f}k / 1M" if estimate is not None else "1M 上下文 · 60% 压缩"
177
+ queued = " · 有排队消息" if self.session.has_queued_messages() else ""
178
+ return FormattedText([
179
+ ("class:status.busy" if active else "class:status", f" {self.console.phase}"),
180
+ ("class:status.dim", clean_text(f" · {self.console.model_name} · {context}{queued}")),
181
+ ("class:status.dim", "\n Enter 发送 Alt+Enter 换行 /help 命令 Ctrl+C 取消 Ctrl+D 退出"),
182
+ ])
183
+
184
+ async def run(self) -> int:
185
+ bindings = KeyBindings()
186
+
187
+ @bindings.add("enter")
188
+ def submit(event: KeyPressEvent) -> None:
189
+ buffer = event.current_buffer
190
+ if buffer.complete_state is not None:
191
+ if buffer.complete_state.current_completion is not None:
192
+ buffer.complete_state = None
193
+ elif buffer.complete_state.completions:
194
+ buffer.apply_completion(buffer.complete_state.completions[0])
195
+ return
196
+ buffer.validate_and_handle()
197
+
198
+ @bindings.add("escape", "enter")
199
+ @bindings.add("c-j")
200
+ def newline(event: KeyPressEvent) -> None:
201
+ event.current_buffer.insert_text("\n")
202
+
203
+ prompt = PromptSession[str](
204
+ message=FormattedText([("class:prompt", "› ")]),
205
+ interrupt_exception=PromptInterrupted,
206
+ completer=WordCompleter(
207
+ ["/help", "/status", "/history", "/tree", "/checkout", "/steer",
208
+ "/follow", "/clear-queue", "/recover", "/abort", "/quit"],
209
+ sentence=True,
210
+ ),
211
+ complete_while_typing=False,
212
+ multiline=True,
213
+ prompt_continuation=" ",
214
+ show_frame=True,
215
+ style=PROMPT_STYLE,
216
+ color_depth=ColorDepth.DEPTH_1_BIT if "NO_COLOR" in os.environ else None,
217
+ key_bindings=bindings,
218
+ auto_suggest=AutoSuggestFromHistory(),
219
+ bottom_toolbar=self._toolbar,
220
+ placeholder=FormattedText([("class:placeholder", "描述任务,或输入 /help")]),
221
+ reserve_space_for_menu=1,
222
+ refresh_interval=0.25,
223
+ erase_when_done=True,
224
+ )
225
+ prompt.app.min_redraw_interval = 0.08
226
+ preview = MarkdownPreview(self.console, prompt)
227
+ reader: asyncio.Task[str] | None = None
228
+ with patch_stdout(raw=True):
229
+ try:
230
+ while True:
231
+ if reader is None:
232
+ reader = asyncio.create_task(prompt.prompt_async(show_frame=True))
233
+ waiting: set[asyncio.Task[str] | asyncio.Task[RunOutcome]] = {reader}
234
+ if self._active is not None:
235
+ waiting.add(self._active)
236
+ done, _ = await asyncio.wait(waiting, return_when=asyncio.FIRST_COMPLETED)
237
+
238
+ if self._active is not None and self._active in done:
239
+ outcome = self._active.result()
240
+ self._active = None
241
+ if outcome.fatal:
242
+ return outcome.exit_code
243
+
244
+ if reader not in done:
245
+ continue
246
+ completed_reader = reader
247
+ reader = None
248
+ try:
249
+ text = completed_reader.result().strip()
250
+ except PromptInterrupted:
251
+ if self._active is None:
252
+ return 0
253
+ self.session.abort()
254
+ self.console.note("[取消] 正在等待任务收尾……")
255
+ continue
256
+ except EOFError:
257
+ return await self._stop_active()
258
+
259
+ if not text:
260
+ continue
261
+ if text.startswith("/"):
262
+ try:
263
+ if self._command(text):
264
+ return await self._stop_active()
265
+ except ValueError as exc:
266
+ self.console.note(f"[提示] {exc}")
267
+ except Exception as exc:
268
+ self.console.note(f"[会话操作失败] {type(exc).__name__}: {exc}")
269
+ return 2
270
+ elif self._active is not None:
271
+ self.session.steer(text)
272
+ self.console.note("[已排队] 引导消息等待接纳。")
273
+ else:
274
+ self._active = asyncio.create_task(run_turn(self.session, self.console, text))
275
+ finally:
276
+ if reader is not None:
277
+ reader.cancel()
278
+ await asyncio.gather(reader, return_exceptions=True)
279
+ await self._stop_active()
280
+ preview.close()
@@ -0,0 +1,51 @@
1
+ """Shared terminal colors, original pi emblem, and safe display text."""
2
+
3
+ import re
4
+
5
+ from prompt_toolkit.styles import Style
6
+ from rich.theme import Theme
7
+
8
+
9
+ ACCENT = "#d79570"
10
+ MUTED = "#8b929c"
11
+ BORDER = "#50555e"
12
+ SUCCESS = "#98c379"
13
+ ERROR = "#e06c75"
14
+ INFO = "#82b9d6"
15
+
16
+ LOGO = "\n".join((
17
+ " ▄▄▄▄▄▄▄▄▄",
18
+ " ▀██▀▀▀██▀",
19
+ " ██ ██ ",
20
+ " ██ ██▄",
21
+ " ▀▀ ▀▀▀",
22
+ ))
23
+
24
+ RICH_THEME = Theme({
25
+ "accent": f"bold {ACCENT}", "muted": MUTED, "border": BORDER,
26
+ "success": SUCCESS, "error": ERROR, "info": INFO,
27
+ "markdown.h1": f"bold {ACCENT}", "markdown.h2": f"bold {ACCENT}",
28
+ "markdown.h3": f"bold {INFO}", "markdown.code": f"{ACCENT} on #25272c",
29
+ "markdown.link": f"underline {INFO}", "markdown.link_url": MUTED,
30
+ "markdown.block_quote": MUTED,
31
+ })
32
+
33
+ PROMPT_STYLE = Style.from_dict({
34
+ "prompt": f"bold {ACCENT}", "placeholder": MUTED,
35
+ "frame.border": BORDER, "bottom-toolbar": "noreverse",
36
+ "bottom-toolbar.text": MUTED, "status": SUCCESS,
37
+ "status.busy": ACCENT, "status.dim": MUTED,
38
+ "completion-menu.completion": "bg:#25272c #c9cdd3",
39
+ "completion-menu.completion.current": f"bg:#45404a {ACCENT}",
40
+ "auto-suggestion": MUTED,
41
+ })
42
+
43
+ _CONTROL_SEQUENCES = re.compile(
44
+ r"\x1b(?:\[[0-?]*[ -/]*[@-~]|\][^\x07\x1b]*(?:\x07|\x1b\\)|[@-_])"
45
+ r"|[\x00-\x08\x0b-\x1f\x7f-\x9f]"
46
+ )
47
+
48
+
49
+ def clean_text(text: str) -> str:
50
+ """Keep newlines and tabs, but strip embedded terminal commands."""
51
+ return _CONTROL_SEQUENCES.sub("", text)
File without changes
@@ -0,0 +1,287 @@
1
+ import time
2
+
3
+ from collections.abc import AsyncGenerator
4
+ from contextlib import aclosing
5
+ from ..agent.agent import Agent
6
+ from ..agent.hooks import AgentHooks
7
+ from ..agent.types import AgentTool
8
+ from ..ai.provider import Provider
9
+ from ..ai.types import (
10
+ ModelOptions,
11
+ ModelSpec,
12
+ SystemMessage,
13
+ Message,
14
+ TextContent,
15
+ ToolResultMessage,
16
+ )
17
+ from .session import SessionManager
18
+ from .compaction import CompactionController
19
+
20
+ from .history import get_pending_tool_calls
21
+
22
+ from ..agent.events import (
23
+ AgentEvent,
24
+ AgentStartEvent,
25
+ AssistantMessageCommittedEvent,
26
+ ContextCompactedEvent,
27
+ MessageAcceptedEvent,
28
+ ToolExecutionEndEvent,
29
+ )
30
+
31
+ class CodingSession:
32
+ def __init__(
33
+ self,
34
+ provider: Provider,
35
+ model: ModelSpec,
36
+ tools: list[AgentTool],
37
+ session_manager: SessionManager,
38
+ system_prompt: str | None = None,
39
+ options: ModelOptions | None = None,
40
+ max_turns: int | None = None,
41
+ hooks: AgentHooks | None = None,
42
+ auto_compact: bool = True,
43
+ ) -> None:
44
+ if provider.id != model.provider:
45
+ raise ValueError(
46
+ "The provider does not match the model"
47
+ )
48
+
49
+ context = session_manager.build_context()
50
+
51
+ if context.provider is not None:
52
+ if (
53
+ context.provider != model.provider
54
+ or context.model_id != model.id
55
+ ):
56
+ raise ValueError(
57
+ "The selected model does not match "
58
+ "the model recorded in the session"
59
+ )
60
+ self._provider_id = model.provider
61
+ self._model_id = model.id
62
+ self.compaction = (
63
+ CompactionController(provider, model, session_manager)
64
+ if auto_compact else None
65
+ )
66
+ self.agent = Agent(
67
+ provider=provider,
68
+ model=model,
69
+ tools=tools,
70
+ options=options,
71
+ max_turns=max_turns,
72
+ hooks=hooks,
73
+ prepare_context=self.compaction.prepare if self.compaction is not None else None,
74
+ )
75
+
76
+ self.session_manager = session_manager
77
+
78
+ if context.provider is None:
79
+ self.session_manager.append_model_change(
80
+ provider=model.provider,
81
+ model_id=model.id,
82
+ )
83
+
84
+ if not context.messages and system_prompt is not None:
85
+ self.session_manager.append_message(
86
+ SystemMessage(
87
+ content=system_prompt,
88
+ timestamp=int(time.time() * 1000),
89
+ )
90
+ )
91
+
92
+ restored_context = (
93
+ self.session_manager.build_context()
94
+ )
95
+
96
+ self.agent.replace_messages(
97
+ restored_context.messages
98
+ )
99
+
100
+
101
+ async def prompt(
102
+ self,
103
+ text: str,
104
+ ) -> AsyncGenerator[AgentEvent, None]:
105
+ if self.agent.state.is_running:
106
+ raise RuntimeError(
107
+ "The coding session is already running"
108
+ )
109
+
110
+ context = self.session_manager.build_context()
111
+
112
+ if get_pending_tool_calls(context.messages):
113
+ raise RuntimeError(
114
+ "The session has unfinished tool calls. "
115
+ "Recover the session before continuing."
116
+ )
117
+
118
+ if context.messages != self.agent.state.messages:
119
+ raise RuntimeError(
120
+ "Agent history does not match the session. "
121
+ "Restore the session before continuing."
122
+ )
123
+
124
+ expected_leaf_id = self.session_manager.leaf_id
125
+
126
+ stream = self.agent.prompt(text)
127
+
128
+ async with aclosing(stream):
129
+ async for event in stream:
130
+ if(
131
+ self.session_manager.leaf_id
132
+ !=expected_leaf_id
133
+ ):
134
+ raise RuntimeError(
135
+ "The session branch changed "
136
+ "while the agent was running"
137
+ )
138
+
139
+ message: Message | None = None
140
+ task_start = False
141
+
142
+ if isinstance(event, AgentStartEvent):
143
+ message = self.agent.state.messages[-1]
144
+ task_start = True
145
+
146
+ elif isinstance(
147
+ event,
148
+ (
149
+ MessageAcceptedEvent,
150
+ AssistantMessageCommittedEvent,
151
+ ),
152
+ ):
153
+ message = event.message
154
+ task_start = isinstance(event, MessageAcceptedEvent) and event.source == "follow_up"
155
+
156
+ elif isinstance(
157
+ event,
158
+ ToolExecutionEndEvent,
159
+ ):
160
+ message = event.result.message
161
+
162
+ elif isinstance(event, ContextCompactedEvent):
163
+ if self.compaction is None:
164
+ raise RuntimeError("Received compaction without a controller")
165
+ expected_leaf_id = self.compaction.commit(event)
166
+
167
+ if message is not None:
168
+ expected_leaf_id = (
169
+ self.session_manager.append_message(
170
+ message, task_start=task_start,
171
+ )
172
+ )
173
+
174
+ yield event
175
+
176
+
177
+
178
+ @property
179
+ def is_running(self) -> bool:
180
+ return self.agent.state.is_running
181
+
182
+ def abort(self) -> None:
183
+ self.agent.abort()
184
+
185
+ def steer(self, text: str) -> None:
186
+ self.agent.steer(text)
187
+
188
+ def follow_up(self, text: str) -> None:
189
+ self.agent.follow_up(text)
190
+
191
+ def has_queued_messages(self) -> bool:
192
+ return self.agent.has_queued_messages()
193
+
194
+ def checkout(self, entry_id: str) -> None:
195
+ if self.is_running:
196
+ raise RuntimeError(
197
+ "Cannot switch branches while running"
198
+ )
199
+
200
+ if self.has_queued_messages():
201
+ raise RuntimeError(
202
+ "Cannot switch branches while queued messages exist"
203
+ )
204
+
205
+ context = (
206
+ self.session_manager.build_context_at(entry_id)
207
+ )
208
+
209
+ if (
210
+ context.provider != self._provider_id
211
+ or context.model_id != self._model_id
212
+ ):
213
+ raise ValueError(
214
+ "The target branch uses a different model"
215
+ )
216
+
217
+ if not context.messages:
218
+ raise ValueError(
219
+ "Select a branch containing messages"
220
+ )
221
+
222
+ if get_pending_tool_calls(context.messages):
223
+ raise ValueError(
224
+ "The target branch has unfinished tool calls"
225
+ )
226
+
227
+ self.session_manager.checkout(entry_id)
228
+
229
+ self.agent.replace_messages(
230
+ context.messages
231
+ )
232
+
233
+ def recover_pending_tools(self) -> int:
234
+ if self.is_running:
235
+ raise RuntimeError(
236
+ "Cannot recover while running"
237
+ )
238
+ if self.has_queued_messages():
239
+ raise RuntimeError("Cannot recover while queued messages exist")
240
+
241
+ context = self.session_manager.build_context()
242
+
243
+ if(
244
+ context.provider != self._provider_id
245
+ or context.model_id !=self._model_id
246
+ ):
247
+ raise ValueError(
248
+ "The session model does not match "
249
+ "the current agent"
250
+ )
251
+
252
+ pending_calls = get_pending_tool_calls(
253
+ context.messages
254
+ )
255
+
256
+ for call in pending_calls:
257
+ message = ToolResultMessage(
258
+ tool_call_id=call.id,
259
+ tool_name=call.name,
260
+ content=[
261
+ TextContent(
262
+ text=(
263
+ "The session was interrupted before "
264
+ "a result was recorded for this tool call. "
265
+ "Its execution status is unknown: "
266
+ "it may not have started, may have "
267
+ "partially run, or may have completed. "
268
+ "Inspect the actual state before "
269
+ "deciding whether to retry."
270
+ )
271
+ ),
272
+ ],
273
+ is_error=True,
274
+ timestamp=int(time.time() * 1000),
275
+ )
276
+
277
+ self.session_manager.append_message(message)
278
+
279
+ restored_context = (
280
+ self.session_manager.build_context()
281
+ )
282
+
283
+ self.agent.replace_messages(
284
+ restored_context.messages
285
+ )
286
+
287
+ return len(pending_calls)