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,429 @@
1
+ import time
2
+ from collections.abc import (
3
+ AsyncGenerator,
4
+ Callable,
5
+ Iterator,
6
+ )
7
+ from contextlib import aclosing
8
+ from typing import Literal
9
+
10
+ from ..ai.events import DoneEvent, ErrorEvent
11
+ from ..ai.provider import Provider
12
+ from ..ai.types import (
13
+ Message,
14
+ ModelContext,
15
+ ModelOptions,
16
+ ModelSpec,
17
+ TextContent,
18
+ ToolCall,
19
+ ToolResultMessage,
20
+ )
21
+ from .cancellation import (
22
+ AgentAbortedError,
23
+ AbortSignal,
24
+ wait_with_abort,
25
+ )
26
+ from .events import (
27
+ AgentEndEvent,
28
+ AgentEvent,
29
+ AgentStartEvent,
30
+ AssistantMessageCommittedEvent,
31
+ ContextCompactedEvent,
32
+ MessageAcceptedEvent,
33
+ ModelEvent,
34
+ ToolExecutionEndEvent,
35
+ ToolExecutionStartEvent,
36
+ TurnStartEvent,
37
+ )
38
+ from .hooks import AgentHooks
39
+ from .context import ContextPreparer
40
+ from .tool_runner import ToolRunner
41
+ from .types import ToolRunResult
42
+
43
+
44
+ MessageReader = Callable[[], list[Message]]
45
+
46
+
47
+ def _accept_messages(
48
+ history: list[Message],
49
+ messages: list[Message],
50
+ source: Literal["steering", "follow_up"],
51
+ ) -> Iterator[AgentEvent]:
52
+ # 先将整批消息写入历史,再对外发送事件。
53
+ history.extend(messages)
54
+
55
+ for message in messages:
56
+ yield MessageAcceptedEvent(
57
+ message=message,
58
+ source=source,
59
+ )
60
+
61
+
62
+ def _finish_pending_tools(
63
+ history: list[Message],
64
+ pending_calls: list[ToolCall],
65
+ started_call_id: str | None,
66
+ reason: Literal["aborted", "error"],
67
+ ) -> Iterator[AgentEvent]:
68
+ """Give every unfinished call a corresponding result."""
69
+ for call in pending_calls:
70
+ started = call.id == started_call_id
71
+
72
+ if not started:
73
+ yield ToolExecutionStartEvent(call=call)
74
+
75
+ if started:
76
+ text = (
77
+ f"Tool call was interrupted because the Agent "
78
+ f"ended with reason '{reason}'. "
79
+ "Side effects may have occurred. "
80
+ "Inspect the actual state before retrying."
81
+ )
82
+ else:
83
+ text = (
84
+ f"Tool call was not executed because the Agent "
85
+ f"ended with reason '{reason}'."
86
+ )
87
+
88
+ result = ToolRunResult(
89
+ message=ToolResultMessage(
90
+ tool_call_id=call.id,
91
+ tool_name=call.name,
92
+ content=[
93
+ TextContent(text=text),
94
+ ],
95
+ is_error=True,
96
+ timestamp=int(time.time() * 1000),
97
+ ),
98
+ details={
99
+ "reason": reason,
100
+ "status": (
101
+ "interrupted"
102
+ if started
103
+ else "not_started"
104
+ ),
105
+ },
106
+ )
107
+
108
+ history.append(result.message)
109
+
110
+ yield ToolExecutionEndEvent(
111
+ call=call,
112
+ result=result,
113
+ )
114
+
115
+ pending_calls.clear()
116
+
117
+
118
+ async def agent_loop(
119
+ provider: Provider,
120
+ model: ModelSpec,
121
+ messages: list[Message],
122
+ tool_runner: ToolRunner,
123
+ options: ModelOptions | None = None,
124
+ max_turns: int | None = None,
125
+ hooks: AgentHooks | None = None,
126
+ abort_signal: AbortSignal | None = None,
127
+ get_steering_messages: MessageReader | None = None,
128
+ get_follow_up_messages: MessageReader | None = None,
129
+ prepare_context: ContextPreparer | None = None,
130
+ ) -> AsyncGenerator[AgentEvent, None]:
131
+ if max_turns is not None and max_turns < 1:
132
+ raise ValueError("max_turns must be at least 1")
133
+
134
+ if not messages:
135
+ raise ValueError(
136
+ "messages must contain at least one message"
137
+ )
138
+
139
+ signal = (
140
+ abort_signal
141
+ if abort_signal is not None
142
+ else AbortSignal()
143
+ )
144
+
145
+ history = messages.copy()
146
+ turn = 0
147
+
148
+ pending_calls: list[ToolCall] = []
149
+ started_call_id: str | None = None
150
+
151
+ # 上一轮结束时,可能已经为下一轮接纳了队列消息。
152
+ skip_steering_poll = False
153
+
154
+ yield AgentStartEvent()
155
+
156
+ try:
157
+ while True:
158
+ signal.raise_if_aborted()
159
+
160
+ if (
161
+ max_turns is not None
162
+ and turn >= max_turns
163
+ ):
164
+ yield AgentEndEvent(
165
+ reason="max_turns",
166
+ messages=history,
167
+ )
168
+ return
169
+
170
+ if (
171
+ not skip_steering_poll
172
+ and get_steering_messages is not None
173
+ ):
174
+ queued = get_steering_messages()
175
+
176
+ for event in _accept_messages(
177
+ history=history,
178
+ messages=queued,
179
+ source="steering",
180
+ ):
181
+ yield event
182
+
183
+ skip_steering_poll = False
184
+
185
+ signal.raise_if_aborted()
186
+
187
+ turn += 1
188
+
189
+ yield TurnStartEvent(turn=turn)
190
+
191
+ signal.raise_if_aborted()
192
+
193
+ context = ModelContext(
194
+ messages=history,
195
+ tools=tool_runner.get_definitions(),
196
+ )
197
+
198
+ if hooks is not None:
199
+ context = await wait_with_abort(
200
+ operation=hooks.before_model(
201
+ context.model_copy(deep=True)
202
+ ),
203
+ signal=signal,
204
+ )
205
+
206
+ if not isinstance(context, ModelContext):
207
+ raise TypeError(
208
+ "before_model must return a ModelContext"
209
+ )
210
+
211
+ if prepare_context is not None:
212
+ prepared = await wait_with_abort(
213
+ prepare_context(context.model_copy(deep=True), signal), signal
214
+ )
215
+ if prepared is not None:
216
+ history = prepared.messages.copy()
217
+ context = prepared.context
218
+ # The consumer persists this change before requesting another event.
219
+ yield ContextCompactedEvent(
220
+ messages=history.copy(),
221
+ tokens_before=prepared.tokens_before,
222
+ tokens_after=prepared.tokens_after,
223
+ )
224
+
225
+ signal.raise_if_aborted()
226
+
227
+ terminal: DoneEvent | ErrorEvent | None = None
228
+
229
+ stream = provider.stream(
230
+ model=model,
231
+ context=context,
232
+ options=options,
233
+ )
234
+
235
+ if not isinstance(stream, AsyncGenerator):
236
+ raise TypeError(
237
+ "Provider.stream must return an "
238
+ "async generator that can be closed"
239
+ )
240
+
241
+ async with aclosing(stream):
242
+ while True:
243
+ try:
244
+ event = await wait_with_abort(
245
+ operation=anext(stream),
246
+ signal=signal,
247
+ )
248
+ except StopAsyncIteration:
249
+ break
250
+
251
+ if terminal is not None:
252
+ raise RuntimeError(
253
+ "Received a model event "
254
+ "after a terminal event"
255
+ )
256
+
257
+ if isinstance(
258
+ event,
259
+ (DoneEvent, ErrorEvent),
260
+ ):
261
+ terminal = event
262
+
263
+ yield ModelEvent(event=event)
264
+
265
+ if terminal is None:
266
+ raise RuntimeError(
267
+ "The model stream ended "
268
+ "without a terminal event"
269
+ )
270
+
271
+ if isinstance(terminal, ErrorEvent):
272
+ assistant = terminal.error
273
+ else:
274
+ assistant = terminal.message
275
+
276
+ if hooks is not None:
277
+ await wait_with_abort(
278
+ operation=hooks.after_model(
279
+ assistant.model_copy(deep=True)
280
+ ),
281
+ signal=signal,
282
+ )
283
+
284
+ if isinstance(terminal, ErrorEvent):
285
+ yield AgentEndEvent(
286
+ reason=terminal.reason,
287
+ messages=history,
288
+ error_message=assistant.error_message,
289
+ )
290
+ return
291
+
292
+ reason = terminal.reason
293
+
294
+ if assistant.stop_reason != reason:
295
+ raise RuntimeError(
296
+ "The final message stop reason "
297
+ "does not match the terminal event"
298
+ )
299
+
300
+ calls: list[ToolCall] = []
301
+
302
+ for block in assistant.content:
303
+ if isinstance(block, ToolCall):
304
+ calls.append(block)
305
+
306
+ if reason == "toolUse" and not calls:
307
+ raise RuntimeError(
308
+ "The model requested tool use "
309
+ "without tool calls"
310
+ )
311
+
312
+ if reason != "toolUse" and calls:
313
+ raise RuntimeError(
314
+ "Tool calls require "
315
+ "the toolUse stop reason"
316
+ )
317
+
318
+ history.append(assistant)
319
+
320
+ pending_calls = calls.copy()
321
+
322
+ yield AssistantMessageCommittedEvent(
323
+ message=assistant,
324
+ )
325
+
326
+ if reason == "toolUse":
327
+ while pending_calls:
328
+ signal.raise_if_aborted()
329
+
330
+ call = pending_calls[0]
331
+ started_call_id = call.id
332
+
333
+ yield ToolExecutionStartEvent(
334
+ call=call,
335
+ )
336
+
337
+ result = await wait_with_abort(
338
+ operation=tool_runner.execute(
339
+ call=call,
340
+ hooks=hooks,
341
+ ),
342
+ signal=signal,
343
+ )
344
+
345
+ history.append(result.message)
346
+
347
+ pending_calls.pop(0)
348
+ started_call_id = None
349
+
350
+ yield ToolExecutionEndEvent(
351
+ call=call,
352
+ result=result,
353
+ )
354
+
355
+ continue
356
+
357
+ # 只有正常 stop,才检查是否有排队消息需要续接。
358
+ # 轮数预算耗尽时保留队列,不取出无法处理的消息。
359
+ can_continue = (
360
+ max_turns is None
361
+ or turn < max_turns
362
+ )
363
+
364
+ if reason == "stop" and can_continue:
365
+ signal.raise_if_aborted()
366
+
367
+ queued: list[Message] = []
368
+ source: Literal[
369
+ "steering", "follow_up"
370
+ ] = "steering"
371
+
372
+ if get_steering_messages is not None:
373
+ queued = get_steering_messages()
374
+
375
+ if (
376
+ not queued
377
+ and get_follow_up_messages is not None
378
+ ):
379
+ queued = get_follow_up_messages()
380
+ source = "follow_up"
381
+
382
+ if queued:
383
+ for event in _accept_messages(
384
+ history=history,
385
+ messages=queued,
386
+ source=source,
387
+ ):
388
+ yield event
389
+
390
+ skip_steering_poll = True
391
+ continue
392
+
393
+ yield AgentEndEvent(
394
+ reason=reason,
395
+ messages=history,
396
+ error_message=assistant.error_message,
397
+ )
398
+ return
399
+
400
+ except AgentAbortedError:
401
+ for event in _finish_pending_tools(
402
+ history=history,
403
+ pending_calls=pending_calls,
404
+ started_call_id=started_call_id,
405
+ reason="aborted",
406
+ ):
407
+ yield event
408
+
409
+ yield AgentEndEvent(
410
+ reason="aborted",
411
+ messages=history,
412
+ )
413
+
414
+ except Exception as exc:
415
+ for event in _finish_pending_tools(
416
+ history=history,
417
+ pending_calls=pending_calls,
418
+ started_call_id=started_call_id,
419
+ reason="error",
420
+ ):
421
+ yield event
422
+
423
+ yield AgentEndEvent(
424
+ reason="error",
425
+ messages=history,
426
+ error_message=(
427
+ f"{type(exc).__name__}: {exc}"
428
+ ),
429
+ )
@@ -0,0 +1,44 @@
1
+ from typing import Literal
2
+
3
+ from ..ai.types import Message
4
+
5
+
6
+ QueueMode = Literal["one-at-a-time", "all"]
7
+
8
+
9
+ class PendingMessageQueue:
10
+ def __init__(
11
+ self,
12
+ mode: QueueMode = "one-at-a-time",
13
+ ) -> None:
14
+ if mode not in ("one-at-a-time", "all"):
15
+ raise ValueError(
16
+ f"Unsupported queue mode: {mode}"
17
+ )
18
+
19
+ self._mode = mode
20
+ self._messages: list[Message] = []
21
+
22
+ def enqueue(self, message: Message) -> None:
23
+ self._messages.append(message)
24
+
25
+ def has_items(self) -> bool:
26
+ return bool(self._messages)
27
+
28
+ def peek(self) -> list[Message]:
29
+ if self._mode == "all":
30
+ return self._messages.copy()
31
+
32
+ return self._messages[:1]
33
+
34
+ def drain(self) -> list[Message]:
35
+ messages = self.peek()
36
+
37
+ self._messages = self._messages[
38
+ len(messages):
39
+ ]
40
+
41
+ return messages
42
+
43
+ def clear(self) -> None:
44
+ self._messages.clear()
@@ -0,0 +1,13 @@
1
+ from pydantic import BaseModel, ConfigDict, Field
2
+
3
+ from ..ai.types import AssistantMessage, Message
4
+
5
+
6
+ class AgentState(BaseModel):
7
+ model_config = ConfigDict(extra="forbid")
8
+
9
+ messages: list[Message] = Field(default_factory=list)
10
+ is_running: bool = False
11
+ turn: int = Field(default=0, ge=0)
12
+ current_response: AssistantMessage | None = None
13
+ error_message: str | None = None
@@ -0,0 +1,161 @@
1
+ import time
2
+
3
+ from pydantic import JsonValue
4
+
5
+ from ..ai.types import (
6
+ TextContent,
7
+ ToolCall,
8
+ ToolContent,
9
+ ToolDefinition,
10
+ ToolResultMessage,
11
+ )
12
+ from .hooks import AgentHooks
13
+ from .types import AgentTool, ToolRunResult
14
+
15
+
16
+ class ToolRunner:
17
+ def __init__(
18
+ self,
19
+ tools: list[AgentTool],
20
+ ) -> None:
21
+ self._tools: dict[str, AgentTool] = {}
22
+
23
+ for tool in tools:
24
+ if tool.name in self._tools:
25
+ raise ValueError(
26
+ f"Tool already registered: {tool.name}"
27
+ )
28
+
29
+ self._tools[tool.name] = tool
30
+
31
+ def get_definitions(self) -> list[ToolDefinition]:
32
+ definitions: list[ToolDefinition] = []
33
+
34
+ for tool in self._tools.values():
35
+ definition = tool.get_definition()
36
+
37
+ if definition.name != tool.name:
38
+ raise ValueError(
39
+ "The tool definition name does not match "
40
+ f"the registered name: {tool.name}"
41
+ )
42
+
43
+ definitions.append(definition)
44
+
45
+ return definitions
46
+
47
+ async def execute(
48
+ self,
49
+ call: ToolCall,
50
+ hooks: AgentHooks | None = None,
51
+ ) -> ToolRunResult:
52
+ block_reason: str | None = None
53
+
54
+ # 1. 执行前,询问 Hook 是否允许这次调用。
55
+ if hooks is not None:
56
+ block_reason = await hooks.before_tool(
57
+ call.model_copy(deep=True)
58
+ )
59
+
60
+ if (
61
+ block_reason is not None
62
+ and not isinstance(block_reason, str)
63
+ ):
64
+ raise TypeError(
65
+ "before_tool must return a string or None"
66
+ )
67
+
68
+ content: list[ToolContent]
69
+ details: dict[str, JsonValue]
70
+ is_error: bool
71
+
72
+ # 2. 被拒绝时,不执行工具,直接准备失败结果。
73
+ if block_reason is not None:
74
+ content = [
75
+ TextContent(
76
+ text=(
77
+ f"Tool '{call.name}' was blocked: "
78
+ f"{block_reason}"
79
+ )
80
+ )
81
+ ]
82
+
83
+ details = {
84
+ "blocked": True,
85
+ "block_reason": block_reason,
86
+ }
87
+
88
+ is_error = True
89
+
90
+ else:
91
+ # 3. 允许执行,工具异常转成失败结果。
92
+ try:
93
+ tool = self._tools.get(call.name)
94
+
95
+ if tool is None:
96
+ raise ValueError(
97
+ f"Unknown tool: {call.name}"
98
+ )
99
+
100
+ execution = await tool.execute(
101
+ tool_call_id=call.id,
102
+ arguments=call.arguments,
103
+ )
104
+
105
+ content = execution.content
106
+ details = execution.details
107
+ is_error = False
108
+
109
+ except Exception as exc:
110
+ error_type = type(exc).__name__
111
+
112
+ content = [
113
+ TextContent(
114
+ text=(
115
+ f"Tool '{call.name}' failed "
116
+ f"with {error_type}: {exc}"
117
+ )
118
+ )
119
+ ]
120
+
121
+ details = {
122
+ "error_type": error_type,
123
+ }
124
+
125
+ is_error = True
126
+
127
+ # 4. 三种情况都包装成相同的结果类型。
128
+ result = ToolRunResult(
129
+ message=ToolResultMessage(
130
+ tool_call_id=call.id,
131
+ tool_name=call.name,
132
+ content=content,
133
+ is_error=is_error,
134
+ timestamp=int(time.time() * 1000),
135
+ ),
136
+ details=details,
137
+ )
138
+
139
+ # 5. 结果进入历史之前,交给 Hook 处理。
140
+ if hooks is not None:
141
+ result = await hooks.after_tool(
142
+ call.model_copy(deep=True),
143
+ result.model_copy(deep=True),
144
+ )
145
+
146
+ if not isinstance(result, ToolRunResult):
147
+ raise TypeError(
148
+ "after_tool must return a ToolRunResult"
149
+ )
150
+
151
+ if result.message.tool_call_id != call.id:
152
+ raise ValueError(
153
+ "after_tool must preserve tool_call_id"
154
+ )
155
+
156
+ if result.message.tool_name != call.name:
157
+ raise ValueError(
158
+ "after_tool must preserve tool_name"
159
+ )
160
+
161
+ return result
@@ -0,0 +1,34 @@
1
+ from typing import Protocol
2
+
3
+ from pydantic import BaseModel, ConfigDict, Field, JsonValue
4
+
5
+ from ..ai.types import ToolContent, ToolDefinition, ToolResultMessage
6
+
7
+
8
+ class ToolExecutionResult(BaseModel):
9
+ model_config = ConfigDict(extra="forbid")
10
+
11
+ content: list[ToolContent]
12
+ details: dict[str, JsonValue] = Field(default_factory=dict)
13
+
14
+
15
+ class AgentTool(Protocol):
16
+ name: str
17
+ description: str
18
+
19
+ def get_definition(self) -> ToolDefinition:
20
+ ...
21
+
22
+ async def execute(
23
+ self,
24
+ tool_call_id: str,
25
+ arguments: dict[str, JsonValue],
26
+ ) -> ToolExecutionResult:
27
+ ...
28
+
29
+
30
+ class ToolRunResult(BaseModel):
31
+ model_config = ConfigDict(extra="forbid")
32
+
33
+ message: ToolResultMessage
34
+ details: dict[str, JsonValue] = Field(default_factory=dict)
File without changes
@@ -0,0 +1,30 @@
1
+ from .events import DoneEvent, ErrorEvent
2
+ from .provider import Provider
3
+ from .types import (
4
+ AssistantMessage,
5
+ ModelContext,
6
+ ModelOptions,
7
+ ModelSpec,
8
+ )
9
+
10
+
11
+ async def complete(
12
+ provider: Provider,
13
+ model: ModelSpec,
14
+ context: ModelContext,
15
+ options: ModelOptions | None = None,
16
+ ) -> AssistantMessage:
17
+ async for event in provider.stream(
18
+ model,
19
+ context,
20
+ options,
21
+ ):
22
+ if isinstance(event,DoneEvent):
23
+ return event.message
24
+
25
+ if isinstance(event,ErrorEvent):
26
+ return event.error
27
+
28
+ raise RuntimeError(
29
+ "The model event stream ended without a final result"
30
+ )