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
File without changes
@@ -0,0 +1,5 @@
1
+ from .cli import main
2
+
3
+
4
+ if __name__ == "__main__":
5
+ raise SystemExit(main())
File without changes
@@ -0,0 +1,255 @@
1
+ import time
2
+ from collections.abc import AsyncGenerator
3
+ from contextlib import aclosing
4
+
5
+ from ..ai.events import DoneEvent, ErrorEvent, PartialEvent
6
+ from ..ai.provider import Provider
7
+ from ..ai.types import (
8
+ Message,
9
+ ModelOptions,
10
+ ModelSpec,
11
+ SystemMessage,
12
+ UserMessage,
13
+ )
14
+ from .cancellation import AbortSignal
15
+ from .context import ContextPreparer
16
+ from .events import (
17
+ AgentEndEvent,
18
+ AgentEvent,
19
+ AssistantMessageCommittedEvent,
20
+ ContextCompactedEvent,
21
+ ModelEvent,
22
+ ToolExecutionEndEvent,
23
+ TurnStartEvent,
24
+ MessageAcceptedEvent,
25
+ )
26
+ from .hooks import AgentHooks
27
+ from .loop import agent_loop
28
+ from .message_queue import (
29
+ PendingMessageQueue,
30
+ QueueMode,
31
+ )
32
+ from .state import AgentState
33
+ from .tool_runner import ToolRunner
34
+ from .types import AgentTool
35
+
36
+
37
+ class Agent:
38
+ def __init__(
39
+ self,
40
+ provider: Provider,
41
+ model: ModelSpec,
42
+ tools: list[AgentTool],
43
+ system_prompt: str | None = None,
44
+ options: ModelOptions | None = None,
45
+ max_turns: int | None = None,
46
+ hooks: AgentHooks | None = None,
47
+ steering_mode: QueueMode = "one-at-a-time",
48
+ follow_up_mode: QueueMode = "one-at-a-time",
49
+ prepare_context: ContextPreparer | None = None,
50
+ ) -> None:
51
+ if max_turns is not None and max_turns < 1:
52
+ raise ValueError("max_turns must be at least 1")
53
+
54
+ self._provider = provider
55
+ self._model = model
56
+ self._runner = ToolRunner(tools=tools)
57
+ self._options = options
58
+ self._max_turns = max_turns
59
+ self._hooks = hooks
60
+ self._prepare_context = prepare_context
61
+
62
+ self._abort_signal: AbortSignal | None = None
63
+
64
+ self._steering_queue = PendingMessageQueue(
65
+ mode=steering_mode,
66
+ )
67
+
68
+ self._follow_up_queue = PendingMessageQueue(
69
+ mode=follow_up_mode,
70
+ )
71
+
72
+ self.state = AgentState()
73
+
74
+ if system_prompt is not None:
75
+ self.state.messages.append(
76
+ SystemMessage(
77
+ content=system_prompt,
78
+ timestamp=int(time.time() * 1000),
79
+ )
80
+ )
81
+
82
+ def abort(self) -> None:
83
+ signal = self._abort_signal
84
+
85
+ if signal is not None:
86
+ signal.abort()
87
+
88
+ def steer(self, text: str) -> None:
89
+ message = self._create_user_message(text)
90
+
91
+ self._steering_queue.enqueue(message)
92
+
93
+
94
+ def replace_messages(
95
+ self,
96
+ messages: list[Message],
97
+ ) -> None:
98
+ """Replace conversation history while the Agent is idle."""
99
+ if self.state.is_running:
100
+ raise RuntimeError(
101
+ "Cannot replace messages while the agent is running"
102
+ )
103
+
104
+ if self.has_queued_messages():
105
+ raise RuntimeError(
106
+ "Cannot replace messages while queued messages exist"
107
+ )
108
+
109
+ self.state.messages = [
110
+ message.model_copy(deep=True)
111
+ for message in messages
112
+ ]
113
+
114
+ self.state.turn = 0
115
+ self.state.current_response = None
116
+ self.state.error_message = None
117
+
118
+
119
+ def follow_up(self, text: str) -> None:
120
+ message = self._create_user_message(text)
121
+
122
+ self._follow_up_queue.enqueue(message)
123
+
124
+ def has_queued_messages(self) -> bool:
125
+ return (
126
+ self._steering_queue.has_items()
127
+ or self._follow_up_queue.has_items()
128
+ )
129
+
130
+ def peek_queued_messages(self) -> list[Message]:
131
+ steering = self._steering_queue.peek()
132
+
133
+ if steering:
134
+ return steering
135
+
136
+ return self._follow_up_queue.peek()
137
+
138
+ def clear_all_queues(self) -> None:
139
+ self._steering_queue.clear()
140
+ self._follow_up_queue.clear()
141
+
142
+ def _create_user_message(
143
+ self,
144
+ text: str,
145
+ ) -> UserMessage:
146
+ if not text.strip():
147
+ raise ValueError(
148
+ "The message must not be empty"
149
+ )
150
+
151
+ return UserMessage(
152
+ content=text,
153
+ timestamp=int(time.time() * 1000),
154
+ )
155
+
156
+ async def prompt(
157
+ self,
158
+ text: str,
159
+ ) -> AsyncGenerator[AgentEvent, None]:
160
+ if self.state.is_running:
161
+ raise RuntimeError(
162
+ "The agent is already running. "
163
+ "Use steer() or follow_up() to queue messages."
164
+ )
165
+
166
+ message = self._create_user_message(text)
167
+
168
+ signal = AbortSignal()
169
+ self._abort_signal = signal
170
+
171
+ self.state.is_running = True
172
+ self.state.turn = 0
173
+ self.state.current_response = None
174
+ self.state.error_message = None
175
+
176
+ try:
177
+ self.state.messages.append(message)
178
+
179
+ stream = agent_loop(
180
+ provider=self._provider,
181
+ model=self._model,
182
+ messages=self.state.messages,
183
+ tool_runner=self._runner,
184
+ options=self._options,
185
+ max_turns=self._max_turns,
186
+ hooks=self._hooks,
187
+ abort_signal=signal,
188
+ get_steering_messages=(
189
+ self._steering_queue.drain
190
+ ),
191
+ get_follow_up_messages=(
192
+ self._follow_up_queue.drain
193
+ ),
194
+ prepare_context=self._prepare_context,
195
+ )
196
+
197
+ async with aclosing(stream):
198
+ async for event in stream:
199
+ self._apply_event(event)
200
+ yield event
201
+
202
+ except Exception as exc:
203
+ self.state.error_message = (
204
+ f"{type(exc).__name__}: {exc}"
205
+ )
206
+ raise
207
+
208
+ finally:
209
+ self._abort_signal = None
210
+ self.state.is_running = False
211
+ self.state.current_response = None
212
+
213
+ def _apply_event(self, event: AgentEvent) -> None:
214
+ if isinstance(event, TurnStartEvent):
215
+ self.state.turn = event.turn
216
+
217
+ elif isinstance(event, MessageAcceptedEvent):
218
+ self.state.messages.append(event.message)
219
+
220
+ elif isinstance(
221
+ event,
222
+ AssistantMessageCommittedEvent,
223
+ ):
224
+ self.state.messages.append(event.message)
225
+ self.state.current_response = None
226
+
227
+ elif isinstance(event, ModelEvent):
228
+ model_event = event.event
229
+
230
+ if isinstance(model_event, PartialEvent):
231
+ self.state.current_response = (
232
+ model_event.partial
233
+ )
234
+
235
+ elif isinstance(model_event, DoneEvent):
236
+ self.state.current_response = None
237
+
238
+ elif isinstance(model_event, ErrorEvent):
239
+ self.state.current_response = None
240
+
241
+ elif isinstance(event, ToolExecutionEndEvent):
242
+ self.state.messages.append(
243
+ event.result.message
244
+ )
245
+
246
+ elif isinstance(event, ContextCompactedEvent):
247
+ self.state.messages = event.messages.copy()
248
+ self.state.current_response = None
249
+
250
+ elif isinstance(event, AgentEndEvent):
251
+ self.state.messages = event.messages
252
+ self.state.error_message = (
253
+ event.error_message
254
+ )
255
+ self.state.current_response = None
@@ -0,0 +1,66 @@
1
+ import asyncio
2
+ from collections.abc import Awaitable
3
+ from typing import TypeVar
4
+
5
+ T = TypeVar("T")
6
+
7
+
8
+ class AgentAbortedError(Exception):
9
+ """The current Agent run was intentionally abroted."""
10
+
11
+
12
+ class AbortSignal:
13
+ def __init__(self) -> None:
14
+ self._event = asyncio.Event()
15
+
16
+ @property
17
+ def aborted(self) -> bool:
18
+ return self._event.is_set()
19
+
20
+ def abort(self) -> None:
21
+ self._event.set()
22
+
23
+ async def wait(self) -> None:
24
+ await self._event.wait()
25
+
26
+ def raise_if_aborted(self) -> None:
27
+ if self.aborted:
28
+ raise AgentAbortedError(
29
+ "The Agent run was aborted"
30
+ )
31
+
32
+
33
+
34
+ async def wait_with_abort(
35
+ operation: Awaitable[T],
36
+ signal: AbortSignal,
37
+ ) -> T:
38
+ operation_task = asyncio.ensure_future(operation)
39
+ abort_task = asyncio.create_task(signal.wait())
40
+
41
+ try:
42
+ signal.raise_if_aborted()
43
+
44
+ completed, _ = await asyncio.wait(
45
+ (operation_task, abort_task),
46
+ return_when=asyncio.FIRST_COMPLETED,
47
+ )
48
+
49
+ if operation_task in completed:
50
+ return operation_task.result()
51
+
52
+ raise AgentAbortedError(
53
+ "The Agent run was aborted"
54
+ )
55
+ finally:
56
+ if not operation_task.done():
57
+ operation_task.cancel()
58
+
59
+ if not abort_task.done():
60
+ abort_task.cancel()
61
+
62
+ await asyncio.gather(
63
+ operation_task,
64
+ abort_task,
65
+ return_exceptions=True,
66
+ )
@@ -0,0 +1,20 @@
1
+ """Application-supplied context preparation at a model-call boundary."""
2
+
3
+ from collections.abc import Awaitable, Callable
4
+ from dataclasses import dataclass
5
+
6
+ from ..ai.types import Message, ModelContext
7
+ from .cancellation import AbortSignal
8
+
9
+
10
+ @dataclass(frozen=True)
11
+ class PreparedContext:
12
+ messages: list[Message]
13
+ context: ModelContext
14
+ tokens_before: int
15
+ tokens_after: int
16
+
17
+
18
+ ContextPreparer = Callable[
19
+ [ModelContext, AbortSignal], Awaitable[PreparedContext | None]
20
+ ]
@@ -0,0 +1,97 @@
1
+ from typing import Annotated, Literal
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field
4
+
5
+ from ..ai.events import AssistantMessageEvent
6
+ from ..ai.types import (
7
+ AssistantMessage,
8
+ Message,
9
+ ToolCall,
10
+ )
11
+ from .types import ToolRunResult
12
+
13
+
14
+ class AgentEventBase(BaseModel):
15
+ model_config = ConfigDict(extra="forbid")
16
+
17
+
18
+ class AgentStartEvent(AgentEventBase):
19
+ type: Literal["agent_start"] = "agent_start"
20
+
21
+
22
+ class TurnStartEvent(AgentEventBase):
23
+ type: Literal["turn_start"] = "turn_start"
24
+ turn: int = Field(ge=1)
25
+
26
+
27
+ class MessageAcceptedEvent(AgentEventBase):
28
+ type: Literal["message_accepted"] = "message_accepted"
29
+
30
+ message: Message
31
+ source: Literal["steering", "follow_up"]
32
+
33
+
34
+ class ModelEvent(AgentEventBase):
35
+ type: Literal["model_event"] = "model_event"
36
+ event: AssistantMessageEvent
37
+
38
+
39
+ class AssistantMessageCommittedEvent(AgentEventBase):
40
+ type: Literal["assistant_message_committed"] = (
41
+ "assistant_message_committed"
42
+ )
43
+
44
+ message: AssistantMessage
45
+
46
+
47
+ class ToolExecutionStartEvent(AgentEventBase):
48
+ type: Literal["tool_execution_start"] = (
49
+ "tool_execution_start"
50
+ )
51
+
52
+ call: ToolCall
53
+
54
+
55
+ class ToolExecutionEndEvent(AgentEventBase):
56
+ type: Literal["tool_execution_end"] = (
57
+ "tool_execution_end"
58
+ )
59
+
60
+ call: ToolCall
61
+ result: ToolRunResult
62
+
63
+
64
+ class ContextCompactedEvent(AgentEventBase):
65
+ type: Literal["context_compacted"] = "context_compacted"
66
+ messages: list[Message]
67
+ tokens_before: int = Field(ge=0)
68
+ tokens_after: int = Field(ge=0)
69
+
70
+
71
+ class AgentEndEvent(AgentEventBase):
72
+ type: Literal["agent_end"] = "agent_end"
73
+
74
+ reason: Literal[
75
+ "stop",
76
+ "length",
77
+ "error",
78
+ "aborted",
79
+ "max_turns",
80
+ ]
81
+
82
+ messages: list[Message]
83
+ error_message: str | None = None
84
+
85
+
86
+ AgentEvent = Annotated[
87
+ AgentStartEvent
88
+ | TurnStartEvent
89
+ | MessageAcceptedEvent
90
+ | ModelEvent
91
+ | AssistantMessageCommittedEvent
92
+ | ToolExecutionStartEvent
93
+ | ToolExecutionEndEvent
94
+ | ContextCompactedEvent
95
+ | AgentEndEvent,
96
+ Field(discriminator="type"),
97
+ ]
@@ -0,0 +1,37 @@
1
+ from ..ai.types import (
2
+ AssistantMessage,
3
+ ModelContext,
4
+ ToolCall,
5
+ )
6
+ from .types import ToolRunResult
7
+
8
+
9
+ class AgentHooks:
10
+ async def before_model(
11
+ self,
12
+ context: ModelContext,
13
+ ) -> ModelContext:
14
+ """Prepare the context for one model request."""
15
+ return context
16
+
17
+ async def after_model(
18
+ self,
19
+ message: AssistantMessage,
20
+ ) -> None:
21
+ """Observe the final response of one model request."""
22
+ pass
23
+
24
+ async def before_tool(
25
+ self,
26
+ call: ToolCall,
27
+ ) -> str | None:
28
+ """Return a reason to block execution, or None to allow it."""
29
+ return None
30
+
31
+ async def after_tool(
32
+ self,
33
+ call: ToolCall,
34
+ result: ToolRunResult,
35
+ ) -> ToolRunResult:
36
+ """Process a tool result before adding it to the history."""
37
+ return result