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.
- citrus_code/__init__.py +0 -0
- citrus_code/__main__.py +5 -0
- citrus_code/agent/__init__.py +0 -0
- citrus_code/agent/agent.py +255 -0
- citrus_code/agent/cancellation.py +66 -0
- citrus_code/agent/context.py +20 -0
- citrus_code/agent/events.py +97 -0
- citrus_code/agent/hooks.py +37 -0
- citrus_code/agent/loop.py +429 -0
- citrus_code/agent/message_queue.py +44 -0
- citrus_code/agent/state.py +13 -0
- citrus_code/agent/tool_runner.py +161 -0
- citrus_code/agent/types.py +34 -0
- citrus_code/ai/__init__.py +0 -0
- citrus_code/ai/completion.py +30 -0
- citrus_code/ai/events.py +78 -0
- citrus_code/ai/provider.py +20 -0
- citrus_code/ai/providers/__init__.py +0 -0
- citrus_code/ai/providers/chat_completions.py +503 -0
- citrus_code/ai/providers/chat_stream_types.py +47 -0
- citrus_code/ai/providers/deepseek.py +104 -0
- citrus_code/ai/providers/fake.py +101 -0
- citrus_code/ai/providers/tool_call_buffer.py +73 -0
- citrus_code/ai/registry.py +40 -0
- citrus_code/ai/types.py +142 -0
- citrus_code/cli/__init__.py +5 -0
- citrus_code/cli/main.py +258 -0
- citrus_code/cli/preview.py +55 -0
- citrus_code/cli/prompts.py +52 -0
- citrus_code/cli/render.py +306 -0
- citrus_code/cli/session_files.py +75 -0
- citrus_code/cli/terminal.py +280 -0
- citrus_code/cli/theme.py +51 -0
- citrus_code/coding/__init__.py +0 -0
- citrus_code/coding/coding_session.py +287 -0
- citrus_code/coding/compaction/__init__.py +5 -0
- citrus_code/coding/compaction/controller.py +168 -0
- citrus_code/coding/compaction/summarize.py +125 -0
- citrus_code/coding/compaction/tokens.py +48 -0
- citrus_code/coding/context_hooks.py +41 -0
- citrus_code/coding/history.py +47 -0
- citrus_code/coding/project_context.py +44 -0
- citrus_code/coding/session/__init__.py +30 -0
- citrus_code/coding/session/manager.py +200 -0
- citrus_code/coding/session/projection.py +50 -0
- citrus_code/coding/session/recovery.py +33 -0
- citrus_code/coding/session/storage.py +138 -0
- citrus_code/coding/session/types.py +108 -0
- citrus_code/coding/tools/__init__.py +35 -0
- citrus_code/coding/tools/bash.py +5 -0
- citrus_code/coding/tools/edit.py +101 -0
- citrus_code/coding/tools/edit_text.py +172 -0
- citrus_code/coding/tools/factory.py +57 -0
- citrus_code/coding/tools/find.py +86 -0
- citrus_code/coding/tools/grep.py +151 -0
- citrus_code/coding/tools/ls.py +61 -0
- citrus_code/coding/tools/mutation.py +39 -0
- citrus_code/coding/tools/paths.py +11 -0
- citrus_code/coding/tools/powershell.py +5 -0
- citrus_code/coding/tools/processes.py +103 -0
- citrus_code/coding/tools/read.py +133 -0
- citrus_code/coding/tools/shell.py +218 -0
- citrus_code/coding/tools/truncate.py +87 -0
- citrus_code/coding/tools/write.py +94 -0
- citrus_code/config.py +34 -0
- citrus_code-0.1.0.dist-info/METADATA +412 -0
- citrus_code-0.1.0.dist-info/RECORD +70 -0
- citrus_code-0.1.0.dist-info/WHEEL +4 -0
- citrus_code-0.1.0.dist-info/entry_points.txt +3 -0
- citrus_code-0.1.0.dist-info/licenses/LICENSE +22 -0
citrus_code/__init__.py
ADDED
|
File without changes
|
citrus_code/__main__.py
ADDED
|
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
|