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
|
@@ -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
|
+
)
|