zero-agent-core 0.2.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.
- agent_core/__init__.py +525 -0
- agent_core/access/__init__.py +6 -0
- agent_core/access/context.py +45 -0
- agent_core/access/guard.py +40 -0
- agent_core/acp/README.md +236 -0
- agent_core/acp/__init__.py +20 -0
- agent_core/acp/server.py +351 -0
- agent_core/acp/types.py +86 -0
- agent_core/callbacks/__init__.py +17 -0
- agent_core/callbacks/manager.py +105 -0
- agent_core/checkpoint/__init__.py +19 -0
- agent_core/checkpoint/naming.py +93 -0
- agent_core/checkpoint/store.py +186 -0
- agent_core/checkpoint/travel.py +448 -0
- agent_core/compaction/__init__.py +57 -0
- agent_core/compaction/middleware.py +428 -0
- agent_core/compaction/prompts.py +32 -0
- agent_core/compaction/pruner.py +121 -0
- agent_core/compaction/region.py +145 -0
- agent_core/compaction/spill.py +81 -0
- agent_core/compaction/spill_tool.py +42 -0
- agent_core/compaction/summarizer.py +95 -0
- agent_core/compaction/token_limit.py +74 -0
- agent_core/compaction/types.py +93 -0
- agent_core/docs.py +15 -0
- agent_core/documents/__init__.py +23 -0
- agent_core/documents/loaders.py +79 -0
- agent_core/documents/splitters.py +94 -0
- agent_core/documents/types.py +197 -0
- agent_core/errors.py +81 -0
- agent_core/guardrails/__init__.py +19 -0
- agent_core/guardrails/core.py +186 -0
- agent_core/hitl/__init__.py +15 -0
- agent_core/hitl/core.py +517 -0
- agent_core/mcp/__init__.py +14 -0
- agent_core/mcp/client.py +212 -0
- agent_core/mcp/errors.py +26 -0
- agent_core/middleware/__init__.py +26 -0
- agent_core/middleware/base.py +283 -0
- agent_core/model/__init__.py +73 -0
- agent_core/model/catalog.py +73 -0
- agent_core/model/core.py +179 -0
- agent_core/model/governance.py +317 -0
- agent_core/model/providers/__init__.py +43 -0
- agent_core/model/providers/anthropic.py +294 -0
- agent_core/model/providers/common.py +28 -0
- agent_core/model/providers/factory.py +107 -0
- agent_core/model/providers/gemini.py +273 -0
- agent_core/model/providers/ollama.py +80 -0
- agent_core/model/providers/openai_compatible.py +526 -0
- agent_core/model/structured.py +200 -0
- agent_core/observability/__init__.py +21 -0
- agent_core/observability/core.py +311 -0
- agent_core/ports/__init__.py +31 -0
- agent_core/ports/memory.py +130 -0
- agent_core/ports/model_selection.py +64 -0
- agent_core/ports/storage.py +114 -0
- agent_core/prompts/__init__.py +19 -0
- agent_core/prompts/registry.py +236 -0
- agent_core/prompts/templates.py +166 -0
- agent_core/protocol/__init__.py +31 -0
- agent_core/protocol/capabilities.py +49 -0
- agent_core/protocol/messages.py +100 -0
- agent_core/protocol/runtime.py +293 -0
- agent_core/py.typed +1 -0
- agent_core/retrieval/__init__.py +40 -0
- agent_core/retrieval/_common.py +121 -0
- agent_core/retrieval/chroma.py +269 -0
- agent_core/retrieval/core.py +63 -0
- agent_core/retrieval/factory.py +72 -0
- agent_core/retrieval/memory.py +232 -0
- agent_core/retrieval/pgvector.py +239 -0
- agent_core/retrieval/types.py +95 -0
- agent_core/runtime/__init__.py +28 -0
- agent_core/runtime/compat.py +98 -0
- agent_core/runtime/react.py +888 -0
- agent_core/runtime/service.py +557 -0
- agent_core/runtime/store.py +55 -0
- agent_core/serialization/__init__.py +21 -0
- agent_core/serialization/registry.py +318 -0
- agent_core/skills/__init__.py +24 -0
- agent_core/skills/errors.py +27 -0
- agent_core/skills/loader.py +258 -0
- agent_core/skills/registry.py +205 -0
- agent_core/skills/types.py +56 -0
- agent_core/storage/__init__.py +107 -0
- agent_core/storage/_sqlite_utils.py +92 -0
- agent_core/storage/context.py +382 -0
- agent_core/storage/lease.py +435 -0
- agent_core/storage/memory.py +604 -0
- agent_core/storage/model_selection.py +360 -0
- agent_core/storage/runtime.py +899 -0
- agent_core/storage/session.py +904 -0
- agent_core/storage/workflow.py +411 -0
- agent_core/tools/__init__.py +5 -0
- agent_core/tools/executor.py +306 -0
- agent_core/workflow/__init__.py +25 -0
- agent_core/workflow/durable.py +175 -0
- agent_core/workflow/state_machine.py +427 -0
- zero_agent_core-0.2.0.dist-info/METADATA +237 -0
- zero_agent_core-0.2.0.dist-info/RECORD +104 -0
- zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
- zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
- zero_agent_core-0.2.0.dist-info/licenses/LICENSE +201 -0
|
@@ -0,0 +1,888 @@
|
|
|
1
|
+
"""手写 ReAct 循环(tool-calling agent)。
|
|
2
|
+
|
|
3
|
+
执行模型:
|
|
4
|
+
1. LLM 收到用户问题 + 工具描述
|
|
5
|
+
2. LLM 决定调用哪些工具(或直接回答)
|
|
6
|
+
3. 若有 tool_calls → 执行工具 → 把结果追加到消息历史 → 再问 LLM
|
|
7
|
+
4. 若无 tool_calls → 输出最终文本答案
|
|
8
|
+
5. 超过 max_iterations 且仍有工具调用 → 明确失败,避免把中间文本当成最终回答
|
|
9
|
+
|
|
10
|
+
stream() 方法的工具调用处理:
|
|
11
|
+
- 模型文本到达时立即逐块 yield
|
|
12
|
+
- 工具调用收集完整后并发执行,工具结果写入消息,再进入下一轮模型调用
|
|
13
|
+
|
|
14
|
+
共享内核与策略点:
|
|
15
|
+
- ``run`` / ``stream`` 是两个薄包装,只负责 Run 生命周期收口(状态、事件、异常)。
|
|
16
|
+
- 消息构造、checkpoint 恢复、中间件 before/after、工具预算守卫、工具执行和
|
|
17
|
+
checkpoint 保存只在私有异步生成器 ``_iterate`` 中实现一次。
|
|
18
|
+
- 两条路径的差异点集中在 ``_LoopMode``:非流式一轮调用 ``llm.chat``;
|
|
19
|
+
流式一轮消费 ``llm.stream_chat``,文本增量经 ``_LoopEvent.text_delta``
|
|
20
|
+
逐块向上游转发,tool_calls 增量按 ID 归并后组装成 assistant 消息。
|
|
21
|
+
流式"已发文本不可重试"的守卫也由内核依据 text_delta 是否非空统一执行。
|
|
22
|
+
|
|
23
|
+
Checkpoint 集成:
|
|
24
|
+
- 提供 checkpointer 参数后,每轮 LLM 调用后自动保存快照
|
|
25
|
+
- 支持时间旅行:可通过 agent.get_history(thread_id) 查看历史版本
|
|
26
|
+
"""
|
|
27
|
+
|
|
28
|
+
from __future__ import annotations
|
|
29
|
+
|
|
30
|
+
import asyncio
|
|
31
|
+
import json
|
|
32
|
+
import logging
|
|
33
|
+
import uuid
|
|
34
|
+
from collections.abc import AsyncIterator, Awaitable, Callable
|
|
35
|
+
from dataclasses import dataclass
|
|
36
|
+
from typing import Any, cast
|
|
37
|
+
|
|
38
|
+
from agent_core.checkpoint import Checkpointer
|
|
39
|
+
from agent_core.errors import (
|
|
40
|
+
ModelInvocationError,
|
|
41
|
+
ModelOutputValidationError,
|
|
42
|
+
)
|
|
43
|
+
from agent_core.middleware import (
|
|
44
|
+
Middleware,
|
|
45
|
+
MiddlewareAction,
|
|
46
|
+
MiddlewareContext,
|
|
47
|
+
MiddlewareManager,
|
|
48
|
+
)
|
|
49
|
+
from agent_core.model import ModelAdapter, StreamChunk
|
|
50
|
+
from agent_core.protocol.messages import (
|
|
51
|
+
Message,
|
|
52
|
+
assistant_message,
|
|
53
|
+
system_message,
|
|
54
|
+
tool_message,
|
|
55
|
+
user_message,
|
|
56
|
+
)
|
|
57
|
+
from agent_core.protocol.runtime import RunContext, RunStatus
|
|
58
|
+
from agent_core.tools import ToolExecutor
|
|
59
|
+
|
|
60
|
+
logger = logging.getLogger(__name__)
|
|
61
|
+
|
|
62
|
+
|
|
63
|
+
@dataclass(frozen=True)
|
|
64
|
+
class _LoopMode:
|
|
65
|
+
"""run / stream 两条路径的差异点。
|
|
66
|
+
|
|
67
|
+
共享内核 ``_iterate`` 只通过该模式对象感知两条路径的区别:
|
|
68
|
+
- 模型一轮调用的实现方式(``streaming`` 分派到 ``_chat_round`` /
|
|
69
|
+
``_stream_round``);
|
|
70
|
+
- 用户可见的错误文案与日志标识(``label`` / ``invocation_failure``)。
|
|
71
|
+
对外行为由 ``run`` / ``stream`` 两个薄包装保持不变。
|
|
72
|
+
"""
|
|
73
|
+
|
|
74
|
+
streaming: bool
|
|
75
|
+
# 日志文案中的路径标识:普通路径为空,流式路径为 "流式"。
|
|
76
|
+
label: str
|
|
77
|
+
# 模型调用失败文案中"轮"之后、冒号之前的片段,保持历史措辞逐字不变。
|
|
78
|
+
invocation_failure: str
|
|
79
|
+
|
|
80
|
+
@property
|
|
81
|
+
def empty_final_error(self) -> str:
|
|
82
|
+
"""最终响应为空(无工具调用也没有文本)的失败文案。"""
|
|
83
|
+
return f"ReAct agent {self.label}最终响应为空,不能使用上一轮中间文本作为答案。"
|
|
84
|
+
|
|
85
|
+
@property
|
|
86
|
+
def missing_answer_error(self) -> str:
|
|
87
|
+
"""循环结束仍未提取到有效回答的失败文案。"""
|
|
88
|
+
return f"ReAct agent {self.label}未能提取到有效回答。"
|
|
89
|
+
|
|
90
|
+
def invocation_error(self, iteration: int, exc: Exception) -> ModelInvocationError:
|
|
91
|
+
"""构造一轮模型调用的失败异常,保持历史措辞逐字不变。"""
|
|
92
|
+
return ModelInvocationError(
|
|
93
|
+
f"ReAct agent 第 {iteration + 1} 轮{self.invocation_failure}:{exc}"
|
|
94
|
+
)
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
# 普通路径:一轮模型调用 = llm.chat,无文本增量。
|
|
98
|
+
_RUN_MODE = _LoopMode(streaming=False, label="", invocation_failure=" LLM 调用失败")
|
|
99
|
+
# 流式路径:一轮模型调用 = llm.stream_chat,文本增量逐块转发。
|
|
100
|
+
_STREAM_MODE = _LoopMode(streaming=True, label="流式", invocation_failure="流式调用失败")
|
|
101
|
+
|
|
102
|
+
|
|
103
|
+
@dataclass(frozen=True)
|
|
104
|
+
class _LoopEvent:
|
|
105
|
+
"""共享内核向薄包装暴露的单个事件。
|
|
106
|
+
|
|
107
|
+
Attributes:
|
|
108
|
+
text_delta: 流式文本增量;普通路径恒为空。
|
|
109
|
+
answer: Agent Loop 正常结束时的最终回答;循环中不产出。
|
|
110
|
+
"""
|
|
111
|
+
|
|
112
|
+
text_delta: str = ""
|
|
113
|
+
answer: str | None = None
|
|
114
|
+
|
|
115
|
+
|
|
116
|
+
class ReActAgent:
|
|
117
|
+
"""手写 ReAct 循环的 tool-calling agent。"""
|
|
118
|
+
|
|
119
|
+
def __init__(
|
|
120
|
+
self,
|
|
121
|
+
llm: ModelAdapter,
|
|
122
|
+
tool_executor: ToolExecutor,
|
|
123
|
+
*,
|
|
124
|
+
system_prompt: str,
|
|
125
|
+
tool_definitions: list[dict[str, Any]],
|
|
126
|
+
max_iterations: int = 5,
|
|
127
|
+
max_tool_calls: int = 20,
|
|
128
|
+
middleware: list[Middleware] | None = None,
|
|
129
|
+
checkpointer: Checkpointer | None = None,
|
|
130
|
+
thread_id: str = "default",
|
|
131
|
+
) -> None:
|
|
132
|
+
self._llm = llm
|
|
133
|
+
self._tool_executor = tool_executor
|
|
134
|
+
self._system_prompt = system_prompt
|
|
135
|
+
self._tool_definitions = tool_definitions
|
|
136
|
+
self._max_iterations = max_iterations
|
|
137
|
+
self._max_tool_calls = max_tool_calls
|
|
138
|
+
self._middleware_manager = MiddlewareManager(middleware)
|
|
139
|
+
self._checkpointer = checkpointer
|
|
140
|
+
self._thread_id = thread_id
|
|
141
|
+
|
|
142
|
+
# ──────────────────────────────────────────────
|
|
143
|
+
# 普通(非流式)执行
|
|
144
|
+
# ──────────────────────────────────────────────
|
|
145
|
+
|
|
146
|
+
async def run(
|
|
147
|
+
self,
|
|
148
|
+
user_input: str,
|
|
149
|
+
*,
|
|
150
|
+
memory_context: str | None = None,
|
|
151
|
+
thread_id: str | None = None,
|
|
152
|
+
run_metadata: dict[str, Any] | None = None,
|
|
153
|
+
run_context: RunContext | None = None,
|
|
154
|
+
checkpoint_state: dict[str, Any] | None = None,
|
|
155
|
+
) -> str:
|
|
156
|
+
"""执行完整的 Agent Loop,并统一收口运行状态。"""
|
|
157
|
+
runtime_context = self._prepare_context(
|
|
158
|
+
user_input,
|
|
159
|
+
thread_id=thread_id,
|
|
160
|
+
run_metadata=run_metadata,
|
|
161
|
+
run_context=run_context,
|
|
162
|
+
checkpoint_state=checkpoint_state,
|
|
163
|
+
)
|
|
164
|
+
try:
|
|
165
|
+
answer = ""
|
|
166
|
+
async for event in self._iterate(
|
|
167
|
+
_RUN_MODE,
|
|
168
|
+
user_input,
|
|
169
|
+
memory_context=memory_context,
|
|
170
|
+
thread_id=thread_id,
|
|
171
|
+
run_metadata=run_metadata,
|
|
172
|
+
run_context=runtime_context,
|
|
173
|
+
checkpoint_state=checkpoint_state,
|
|
174
|
+
):
|
|
175
|
+
if event.answer is not None:
|
|
176
|
+
answer = event.answer
|
|
177
|
+
except asyncio.CancelledError:
|
|
178
|
+
await self._finish_run(runtime_context, "cancelled", "run_cancelled")
|
|
179
|
+
raise
|
|
180
|
+
except Exception as exc:
|
|
181
|
+
await self._finish_run(
|
|
182
|
+
runtime_context,
|
|
183
|
+
"failed",
|
|
184
|
+
"run_failed",
|
|
185
|
+
error=str(exc),
|
|
186
|
+
)
|
|
187
|
+
raise
|
|
188
|
+
|
|
189
|
+
await self._finish_run(
|
|
190
|
+
runtime_context,
|
|
191
|
+
"completed",
|
|
192
|
+
"run_completed",
|
|
193
|
+
answer=answer,
|
|
194
|
+
)
|
|
195
|
+
return answer
|
|
196
|
+
|
|
197
|
+
# ──────────────────────────────────────────────
|
|
198
|
+
# 流式执行(完整支持工具调用)
|
|
199
|
+
# ──────────────────────────────────────────────
|
|
200
|
+
|
|
201
|
+
async def stream(
|
|
202
|
+
self,
|
|
203
|
+
user_input: str,
|
|
204
|
+
*,
|
|
205
|
+
memory_context: str | None = None,
|
|
206
|
+
thread_id: str | None = None,
|
|
207
|
+
run_metadata: dict[str, Any] | None = None,
|
|
208
|
+
run_context: RunContext | None = None,
|
|
209
|
+
checkpoint_state: dict[str, Any] | None = None,
|
|
210
|
+
) -> AsyncIterator[str]:
|
|
211
|
+
"""逐块流式执行 Agent Loop,并统一收口运行状态。"""
|
|
212
|
+
runtime_context = self._prepare_context(
|
|
213
|
+
user_input,
|
|
214
|
+
thread_id=thread_id,
|
|
215
|
+
run_metadata=run_metadata,
|
|
216
|
+
run_context=run_context,
|
|
217
|
+
checkpoint_state=checkpoint_state,
|
|
218
|
+
)
|
|
219
|
+
try:
|
|
220
|
+
async for event in self._iterate(
|
|
221
|
+
_STREAM_MODE,
|
|
222
|
+
user_input,
|
|
223
|
+
memory_context=memory_context,
|
|
224
|
+
thread_id=thread_id,
|
|
225
|
+
run_metadata=run_metadata,
|
|
226
|
+
run_context=runtime_context,
|
|
227
|
+
checkpoint_state=checkpoint_state,
|
|
228
|
+
):
|
|
229
|
+
if event.text_delta:
|
|
230
|
+
yield event.text_delta
|
|
231
|
+
except (asyncio.CancelledError, GeneratorExit):
|
|
232
|
+
await self._finish_run(runtime_context, "cancelled", "run_cancelled")
|
|
233
|
+
raise
|
|
234
|
+
except Exception as exc:
|
|
235
|
+
await self._finish_run(
|
|
236
|
+
runtime_context,
|
|
237
|
+
"failed",
|
|
238
|
+
"run_failed",
|
|
239
|
+
error=str(exc),
|
|
240
|
+
)
|
|
241
|
+
raise
|
|
242
|
+
|
|
243
|
+
await self._finish_run(runtime_context, "completed", "run_completed")
|
|
244
|
+
|
|
245
|
+
# ──────────────────────────────────────────────
|
|
246
|
+
# 共享 Agent Loop 内核
|
|
247
|
+
# ──────────────────────────────────────────────
|
|
248
|
+
|
|
249
|
+
def _prepare_context(
|
|
250
|
+
self,
|
|
251
|
+
user_input: str,
|
|
252
|
+
*,
|
|
253
|
+
thread_id: str | None,
|
|
254
|
+
run_metadata: dict[str, Any] | None,
|
|
255
|
+
run_context: RunContext | None,
|
|
256
|
+
checkpoint_state: dict[str, Any] | None,
|
|
257
|
+
) -> RunContext:
|
|
258
|
+
"""构造或复用 RunContext,并回填 checkpoint 快照基底。
|
|
259
|
+
|
|
260
|
+
直接 API 以 checkpoint_state 恢复时回填快照基底;否则恢复批次期间
|
|
261
|
+
``_persist_tool_progress`` 会以空 dict 为基底,丢失
|
|
262
|
+
iteration/last_answer/tool_calls_used。
|
|
263
|
+
"""
|
|
264
|
+
metadata = dict(run_metadata or {})
|
|
265
|
+
runtime_context = run_context or RunContext(
|
|
266
|
+
thread_id=thread_id or self._thread_id,
|
|
267
|
+
run_id=str(metadata.get("run_id") or uuid.uuid4()),
|
|
268
|
+
metadata=metadata,
|
|
269
|
+
)
|
|
270
|
+
if run_context is None:
|
|
271
|
+
runtime_context.emit("run_started", input=user_input)
|
|
272
|
+
if checkpoint_state and not runtime_context.checkpoint:
|
|
273
|
+
runtime_context.checkpoint = checkpoint_state
|
|
274
|
+
return runtime_context
|
|
275
|
+
|
|
276
|
+
async def _iterate(
|
|
277
|
+
self,
|
|
278
|
+
mode: _LoopMode,
|
|
279
|
+
user_input: str,
|
|
280
|
+
*,
|
|
281
|
+
memory_context: str | None = None,
|
|
282
|
+
thread_id: str | None = None,
|
|
283
|
+
run_metadata: dict[str, Any] | None = None,
|
|
284
|
+
run_context: RunContext,
|
|
285
|
+
checkpoint_state: dict[str, Any] | None = None,
|
|
286
|
+
) -> AsyncIterator[_LoopEvent]:
|
|
287
|
+
"""run / stream 共享的 Agent Loop 内核。
|
|
288
|
+
|
|
289
|
+
生命周期状态由 ``run`` / ``stream`` 统一管理;本方法只驱动循环本身。
|
|
290
|
+
流式路径每次 yield 一个文本增量事件;循环正常结束时统一产出
|
|
291
|
+
``_LoopEvent.answer``(含 completed checkpoint 直接恢复的场景)。
|
|
292
|
+
|
|
293
|
+
Yields:
|
|
294
|
+
``_LoopEvent``:text_delta 为流式文本增量,answer 为最终回答。
|
|
295
|
+
"""
|
|
296
|
+
checkpoint = checkpoint_state or {}
|
|
297
|
+
messages = (
|
|
298
|
+
self._messages_from_checkpoint(checkpoint)
|
|
299
|
+
if checkpoint.get("messages")
|
|
300
|
+
else self._build_initial_messages(user_input, memory_context)
|
|
301
|
+
)
|
|
302
|
+
# 3.2 初始化运行变量
|
|
303
|
+
last_answer = str(checkpoint.get("last_answer", ""))
|
|
304
|
+
metadata = self._runtime_metadata(run_context, dict(run_metadata or {}))
|
|
305
|
+
run_context.status = "running"
|
|
306
|
+
# 恢复执行时沿用已有上下文的会话 ID,避免快照落到默认会话;
|
|
307
|
+
# 流式与普通执行使用同一套会话隔离规则。
|
|
308
|
+
checkpoint_thread = thread_id or run_context.thread_id
|
|
309
|
+
|
|
310
|
+
if checkpoint.get("phase") == "completed":
|
|
311
|
+
if last_answer:
|
|
312
|
+
yield _LoopEvent(answer=last_answer)
|
|
313
|
+
return
|
|
314
|
+
raise ModelOutputValidationError("completed checkpoint 缺少最终回答")
|
|
315
|
+
|
|
316
|
+
# 4. checkpoint 恢复阶段
|
|
317
|
+
start_iteration = int(checkpoint.get("next_iteration", checkpoint.get("iteration", 0)))
|
|
318
|
+
pending_tool_calls = checkpoint.get("pending_tool_calls")
|
|
319
|
+
# 4.1 如果上次停在工具执行前,于是构造一个 middleware 上下文:
|
|
320
|
+
if checkpoint.get("phase") == "execute_tools" and isinstance(pending_tool_calls, list):
|
|
321
|
+
restore_ctx = MiddlewareContext(
|
|
322
|
+
messages=messages,
|
|
323
|
+
iteration=int(checkpoint.get("iteration", start_iteration)),
|
|
324
|
+
metadata=metadata,
|
|
325
|
+
emit=run_context.emit,
|
|
326
|
+
)
|
|
327
|
+
# 然后补执行之前未完成的工具(只重放 pending 集合,已完成的不重放):
|
|
328
|
+
messages = await self._execute_tools(pending_tool_calls, messages, restore_ctx, run_context)
|
|
329
|
+
# 工具执行完成后,把阶段改回模型调用:
|
|
330
|
+
start_iteration = int(checkpoint.get("iteration", 0)) + 1
|
|
331
|
+
# 然后保存一次新的 checkpoint
|
|
332
|
+
await self._save_checkpoint(
|
|
333
|
+
checkpoint_thread,
|
|
334
|
+
messages,
|
|
335
|
+
start_iteration - 1,
|
|
336
|
+
last_answer,
|
|
337
|
+
int(checkpoint.get("tool_calls_used", 0)),
|
|
338
|
+
run_context,
|
|
339
|
+
phase="model",
|
|
340
|
+
pending_tool_calls=[],
|
|
341
|
+
)
|
|
342
|
+
|
|
343
|
+
tool_calls_used = int(checkpoint.get("tool_calls_used", 0))
|
|
344
|
+
# 5. Agent 主循环
|
|
345
|
+
for iteration in range(start_iteration, self._max_iterations):
|
|
346
|
+
# 5.1 设置当前轮次
|
|
347
|
+
run_context.iteration = iteration
|
|
348
|
+
ctx = MiddlewareContext(
|
|
349
|
+
messages=messages,
|
|
350
|
+
iteration=iteration,
|
|
351
|
+
metadata=metadata,
|
|
352
|
+
emit=run_context.emit,
|
|
353
|
+
)
|
|
354
|
+
|
|
355
|
+
# before_model
|
|
356
|
+
mw_result = await self._middleware_manager.execute_before_model(ctx)
|
|
357
|
+
if mw_result.action == MiddlewareAction.STOP:
|
|
358
|
+
raise ModelOutputValidationError(
|
|
359
|
+
mw_result.error or mw_result.data.get("reason", "模型调用被中间件阻止")
|
|
360
|
+
)
|
|
361
|
+
if mw_result.action == MiddlewareAction.MODIFY:
|
|
362
|
+
messages = mw_result.data.get("messages", messages)
|
|
363
|
+
|
|
364
|
+
# ── 策略点:一轮模型调用 ──────────────────────
|
|
365
|
+
# 非流式:llm.chat 返回完整响应;流式:消费 stream_chat,
|
|
366
|
+
# 文本增量逐块转发给调用方,tool_calls 增量按 ID 归并。
|
|
367
|
+
response: Message | None = None
|
|
368
|
+
text_parts: list[str] = []
|
|
369
|
+
async for item in self._model_round(mode, messages, iteration, ctx, run_context):
|
|
370
|
+
if isinstance(item, Message):
|
|
371
|
+
response = item
|
|
372
|
+
elif item.text:
|
|
373
|
+
text_parts.append(item.text)
|
|
374
|
+
# 真正逐块向上游发送;调用方可以立即转成 SSE 数据帧。
|
|
375
|
+
yield _LoopEvent(text_delta=item.text)
|
|
376
|
+
assert response is not None
|
|
377
|
+
|
|
378
|
+
# 7. 保存模型响应
|
|
379
|
+
messages.append(response)
|
|
380
|
+
# 这一步非常关键,因为下一轮调用模型时,模型必须知道自己上一轮说了什么。
|
|
381
|
+
run_context.emit(
|
|
382
|
+
"model_finished",
|
|
383
|
+
iteration=iteration,
|
|
384
|
+
tool_calls=len(response.tool_calls),
|
|
385
|
+
)
|
|
386
|
+
ctx.llm_response = response
|
|
387
|
+
|
|
388
|
+
# after_model
|
|
389
|
+
mw_result = await self._middleware_manager.execute_after_model(ctx)
|
|
390
|
+
if mw_result.action == MiddlewareAction.RETRY:
|
|
391
|
+
messages.pop()
|
|
392
|
+
if text_parts:
|
|
393
|
+
# 流式路径特有:文本已经发给上游,无法安全重试当前响应。
|
|
394
|
+
raise ModelOutputValidationError("流式文本已经发送,after_model 不能安全重试当前响应。")
|
|
395
|
+
continue
|
|
396
|
+
if mw_result.action == MiddlewareAction.STOP:
|
|
397
|
+
raise ModelOutputValidationError(
|
|
398
|
+
mw_result.error or mw_result.data.get("reason", "模型输出被中间件阻止")
|
|
399
|
+
)
|
|
400
|
+
|
|
401
|
+
if response.content:
|
|
402
|
+
last_answer = response.content
|
|
403
|
+
|
|
404
|
+
next_tool_calls_used = tool_calls_used + len(response.tool_calls)
|
|
405
|
+
if next_tool_calls_used > self._max_tool_calls:
|
|
406
|
+
raise ModelOutputValidationError(
|
|
407
|
+
f"ReAct agent 工具调用次数超过上限({self._max_tool_calls})"
|
|
408
|
+
)
|
|
409
|
+
|
|
410
|
+
# ── Checkpoint:每轮 LLM 回复后自动保存快照 ──────────
|
|
411
|
+
await self._save_checkpoint(
|
|
412
|
+
checkpoint_thread,
|
|
413
|
+
messages,
|
|
414
|
+
iteration,
|
|
415
|
+
last_answer,
|
|
416
|
+
next_tool_calls_used,
|
|
417
|
+
run_context,
|
|
418
|
+
phase="execute_tools" if response.tool_calls else "completed",
|
|
419
|
+
pending_tool_calls=response.tool_calls,
|
|
420
|
+
metadata={**metadata, "iteration": iteration, "answer_preview": last_answer[:80]},
|
|
421
|
+
)
|
|
422
|
+
|
|
423
|
+
if not response.tool_calls:
|
|
424
|
+
if not response.content:
|
|
425
|
+
raise ModelOutputValidationError(mode.empty_final_error)
|
|
426
|
+
logger.debug("ReAct %s第 %d 轮结束(无工具调用)", mode.label, iteration + 1)
|
|
427
|
+
break
|
|
428
|
+
# 11. 有工具调用:检查数量
|
|
429
|
+
tool_calls_used = next_tool_calls_used
|
|
430
|
+
run_context.tool_calls_used = tool_calls_used
|
|
431
|
+
|
|
432
|
+
if mode.streaming:
|
|
433
|
+
logger.debug(
|
|
434
|
+
"ReAct 流式第 %d 轮执行工具: %s",
|
|
435
|
+
iteration + 1,
|
|
436
|
+
[tc["name"] for tc in response.tool_calls],
|
|
437
|
+
)
|
|
438
|
+
# 执行工具调用
|
|
439
|
+
messages = await self._execute_tools(response.tool_calls, messages, ctx, run_context)
|
|
440
|
+
# 13. 工具执行后再次保存 checkpoint
|
|
441
|
+
await self._save_checkpoint(
|
|
442
|
+
checkpoint_thread,
|
|
443
|
+
messages,
|
|
444
|
+
iteration,
|
|
445
|
+
last_answer,
|
|
446
|
+
tool_calls_used,
|
|
447
|
+
run_context,
|
|
448
|
+
phase="model",
|
|
449
|
+
pending_tool_calls=[],
|
|
450
|
+
metadata={**metadata, "iteration": iteration, "answer_preview": last_answer[:80]},
|
|
451
|
+
)
|
|
452
|
+
|
|
453
|
+
else:
|
|
454
|
+
logger.warning("ReAct %s达到最大迭代次数 %d", mode.label, self._max_iterations)
|
|
455
|
+
raise ModelOutputValidationError(
|
|
456
|
+
f"ReAct agent 达到最大迭代次数({self._max_iterations}),尚未生成不含工具调用的最终回答。"
|
|
457
|
+
)
|
|
458
|
+
|
|
459
|
+
if not last_answer:
|
|
460
|
+
raise ModelOutputValidationError(mode.missing_answer_error)
|
|
461
|
+
|
|
462
|
+
yield _LoopEvent(answer=last_answer)
|
|
463
|
+
|
|
464
|
+
async def _model_round(
|
|
465
|
+
self,
|
|
466
|
+
mode: _LoopMode,
|
|
467
|
+
messages: list[Message],
|
|
468
|
+
iteration: int,
|
|
469
|
+
ctx: MiddlewareContext,
|
|
470
|
+
runtime_context: RunContext,
|
|
471
|
+
) -> AsyncIterator[Message | StreamChunk]:
|
|
472
|
+
"""按路径策略执行一轮模型调用。
|
|
473
|
+
|
|
474
|
+
非流式策略产出单个 ``Message``;流式策略把 ``StreamChunk`` 原样
|
|
475
|
+
转发(文本增量由内核继续向上游 yield),最后产出组装好的
|
|
476
|
+
assistant ``Message``。异常重试由策略内部闭环。
|
|
477
|
+
"""
|
|
478
|
+
if mode.streaming:
|
|
479
|
+
async for item in self._stream_round(mode, messages, iteration, ctx, runtime_context):
|
|
480
|
+
yield item
|
|
481
|
+
else:
|
|
482
|
+
async for item in self._chat_round(mode, messages, iteration, ctx, runtime_context):
|
|
483
|
+
yield item
|
|
484
|
+
|
|
485
|
+
async def _chat_round(
|
|
486
|
+
self,
|
|
487
|
+
mode: _LoopMode,
|
|
488
|
+
messages: list[Message],
|
|
489
|
+
iteration: int,
|
|
490
|
+
ctx: MiddlewareContext,
|
|
491
|
+
runtime_context: RunContext,
|
|
492
|
+
) -> AsyncIterator[Message]:
|
|
493
|
+
"""非流式策略:一轮 ``llm.chat``,中间件允许时重试后返回完整响应。"""
|
|
494
|
+
while True:
|
|
495
|
+
try:
|
|
496
|
+
yield await self._llm.chat(
|
|
497
|
+
messages,
|
|
498
|
+
tools=self._tool_definitions or None,
|
|
499
|
+
)
|
|
500
|
+
return
|
|
501
|
+
except Exception as exc:
|
|
502
|
+
if await self._middleware_manager.handle_exception(exc, ctx):
|
|
503
|
+
runtime_context.emit("model_retry", iteration=iteration, error=str(exc))
|
|
504
|
+
continue
|
|
505
|
+
raise mode.invocation_error(iteration, exc) from exc
|
|
506
|
+
|
|
507
|
+
async def _stream_round(
|
|
508
|
+
self,
|
|
509
|
+
mode: _LoopMode,
|
|
510
|
+
messages: list[Message],
|
|
511
|
+
iteration: int,
|
|
512
|
+
ctx: MiddlewareContext,
|
|
513
|
+
runtime_context: RunContext,
|
|
514
|
+
) -> AsyncIterator[Message | StreamChunk]:
|
|
515
|
+
"""流式策略:消费 ``stream_chat`` 并归并增量。
|
|
516
|
+
|
|
517
|
+
文本增量原样转发;工具调用增量按调用 ID(缺省按位置)归并,
|
|
518
|
+
args 字典逐块合并;重试时文本与工具调用增量都从头重新收集
|
|
519
|
+
(已转发给上游的文本无法撤回,与历史行为一致)。
|
|
520
|
+
"""
|
|
521
|
+
text_parts: list[str] = []
|
|
522
|
+
tool_calls_by_key: dict[str, dict[str, Any]] = {}
|
|
523
|
+
while True:
|
|
524
|
+
text_parts.clear()
|
|
525
|
+
tool_calls_by_key.clear()
|
|
526
|
+
try:
|
|
527
|
+
async for chunk in self._llm.stream_chat(
|
|
528
|
+
messages,
|
|
529
|
+
tools=self._tool_definitions or None,
|
|
530
|
+
):
|
|
531
|
+
if chunk.text:
|
|
532
|
+
text_parts.append(chunk.text)
|
|
533
|
+
yield chunk
|
|
534
|
+
|
|
535
|
+
if chunk.is_tool_call and chunk.tool_calls:
|
|
536
|
+
for call_index, tool_call in enumerate(chunk.tool_calls):
|
|
537
|
+
key = str(tool_call.get("id") or f"index:{call_index}")
|
|
538
|
+
current = tool_calls_by_key.setdefault(key, {})
|
|
539
|
+
current.update(
|
|
540
|
+
{
|
|
541
|
+
field: value
|
|
542
|
+
for field, value in tool_call.items()
|
|
543
|
+
if field != "args" and value is not None and value != ""
|
|
544
|
+
}
|
|
545
|
+
)
|
|
546
|
+
args = tool_call.get("args")
|
|
547
|
+
if isinstance(args, dict):
|
|
548
|
+
current_args = current.setdefault("args", {})
|
|
549
|
+
if isinstance(current_args, dict):
|
|
550
|
+
current_args.update(args)
|
|
551
|
+
else:
|
|
552
|
+
current["args"] = dict(args)
|
|
553
|
+
|
|
554
|
+
except Exception as exc:
|
|
555
|
+
if await self._middleware_manager.handle_exception(exc, ctx):
|
|
556
|
+
runtime_context.emit("model_retry", iteration=iteration, error=str(exc))
|
|
557
|
+
continue
|
|
558
|
+
raise mode.invocation_error(iteration, exc) from exc
|
|
559
|
+
break
|
|
560
|
+
|
|
561
|
+
# 组装 assistant message
|
|
562
|
+
yield assistant_message("".join(text_parts), tool_calls=list(tool_calls_by_key.values()))
|
|
563
|
+
|
|
564
|
+
# ──────────────────────────────────────────────
|
|
565
|
+
# 内部辅助方法
|
|
566
|
+
# ──────────────────────────────────────────────
|
|
567
|
+
|
|
568
|
+
@staticmethod
|
|
569
|
+
def _runtime_metadata(
|
|
570
|
+
runtime_context: RunContext,
|
|
571
|
+
metadata: dict[str, Any],
|
|
572
|
+
) -> dict[str, Any]:
|
|
573
|
+
"""把 Run 身份写入中间件元数据,供审批和审计中间件使用。"""
|
|
574
|
+
result = {**runtime_context.metadata, **metadata}
|
|
575
|
+
result["run_id"] = runtime_context.run_id
|
|
576
|
+
result["thread_id"] = runtime_context.thread_id
|
|
577
|
+
if runtime_context.user_id is not None:
|
|
578
|
+
result["user_id"] = runtime_context.user_id
|
|
579
|
+
if runtime_context.tenant_id is not None:
|
|
580
|
+
result["tenant_id"] = runtime_context.tenant_id
|
|
581
|
+
return result
|
|
582
|
+
|
|
583
|
+
def _build_initial_messages(
|
|
584
|
+
self,
|
|
585
|
+
user_input: str,
|
|
586
|
+
memory_context: str | None,
|
|
587
|
+
) -> list[Message]:
|
|
588
|
+
"""构造初始消息列表。"""
|
|
589
|
+
msgs: list[Message] = [system_message(self._system_prompt)]
|
|
590
|
+
user_content = (
|
|
591
|
+
f"会话相关记忆:\n{memory_context}\n\n用户问题:{user_input}" if memory_context else user_input
|
|
592
|
+
)
|
|
593
|
+
msgs.append(user_message(user_content))
|
|
594
|
+
return msgs
|
|
595
|
+
|
|
596
|
+
@staticmethod
|
|
597
|
+
# 这个方法负责把 JSON 字典恢复为内部消息对象。
|
|
598
|
+
def _messages_from_checkpoint(state: dict[str, Any]) -> list[Message]:
|
|
599
|
+
"""从 checkpoint 恢复内部消息,兼容 OpenAI tool-call 格式。"""
|
|
600
|
+
messages: list[Message] = []
|
|
601
|
+
for item in state.get("messages", []):
|
|
602
|
+
if not isinstance(item, dict):
|
|
603
|
+
continue
|
|
604
|
+
tool_calls: list[dict[str, Any]] = []
|
|
605
|
+
for call in item.get("tool_calls", []) or []:
|
|
606
|
+
if not isinstance(call, dict):
|
|
607
|
+
continue
|
|
608
|
+
function = call.get("function", {})
|
|
609
|
+
if isinstance(function, dict) and function:
|
|
610
|
+
raw_args = function.get("arguments", "{}")
|
|
611
|
+
try:
|
|
612
|
+
args = json.loads(raw_args) if isinstance(raw_args, str) else raw_args
|
|
613
|
+
except json.JSONDecodeError:
|
|
614
|
+
args = {}
|
|
615
|
+
name = function.get("name", call.get("name", ""))
|
|
616
|
+
else:
|
|
617
|
+
# 新 checkpoint 使用 Core 内部扁平格式;兼容旧 OpenAI 快照。
|
|
618
|
+
args = call.get("args", {})
|
|
619
|
+
name = call.get("name", "")
|
|
620
|
+
tool_calls.append(
|
|
621
|
+
{
|
|
622
|
+
"id": call.get("id", ""),
|
|
623
|
+
"type": "function",
|
|
624
|
+
"name": name,
|
|
625
|
+
"args": args if isinstance(args, dict) else {},
|
|
626
|
+
}
|
|
627
|
+
)
|
|
628
|
+
messages.append(
|
|
629
|
+
Message(
|
|
630
|
+
role=item.get("role", "user"),
|
|
631
|
+
content=str(item.get("content") or ""),
|
|
632
|
+
name=item.get("name"),
|
|
633
|
+
tool_call_id=item.get("tool_call_id"),
|
|
634
|
+
tool_calls=tool_calls,
|
|
635
|
+
)
|
|
636
|
+
)
|
|
637
|
+
return messages
|
|
638
|
+
|
|
639
|
+
async def _save_checkpoint(
|
|
640
|
+
self,
|
|
641
|
+
thread_id: str,
|
|
642
|
+
messages: list[Message],
|
|
643
|
+
iteration: int,
|
|
644
|
+
last_answer: str,
|
|
645
|
+
tool_calls_used: int,
|
|
646
|
+
runtime_context: RunContext,
|
|
647
|
+
*,
|
|
648
|
+
phase: str,
|
|
649
|
+
pending_tool_calls: list[dict[str, Any]],
|
|
650
|
+
metadata: dict[str, Any] | None = None,
|
|
651
|
+
) -> None:
|
|
652
|
+
"""保存可恢复的完整循环状态,并通知 RuntimeStore。"""
|
|
653
|
+
state = {
|
|
654
|
+
"messages": [message.to_dict() for message in messages],
|
|
655
|
+
"iteration": iteration,
|
|
656
|
+
"next_iteration": iteration + 1 if phase in {"model", "completed"} else iteration,
|
|
657
|
+
"last_answer": last_answer,
|
|
658
|
+
"tool_calls_used": tool_calls_used,
|
|
659
|
+
"phase": phase,
|
|
660
|
+
"pending_tool_calls": pending_tool_calls,
|
|
661
|
+
}
|
|
662
|
+
runtime_context.checkpoint = state
|
|
663
|
+
if runtime_context.checkpoint_callback is not None:
|
|
664
|
+
result = runtime_context.checkpoint_callback(state)
|
|
665
|
+
if hasattr(result, "__await__"):
|
|
666
|
+
await result
|
|
667
|
+
if self._checkpointer:
|
|
668
|
+
await self._checkpointer.save(
|
|
669
|
+
thread_id,
|
|
670
|
+
{**state, "run_context": runtime_context.to_dict()},
|
|
671
|
+
metadata=metadata or {"iteration": iteration},
|
|
672
|
+
)
|
|
673
|
+
|
|
674
|
+
async def _finish_run(
|
|
675
|
+
self,
|
|
676
|
+
runtime_context: RunContext,
|
|
677
|
+
status: RunStatus,
|
|
678
|
+
event_type: str,
|
|
679
|
+
**payload: Any,
|
|
680
|
+
) -> None:
|
|
681
|
+
"""设置终态并立即通知持久化回调,所有退出路径共用。"""
|
|
682
|
+
if runtime_context.status != status:
|
|
683
|
+
runtime_context.status = status
|
|
684
|
+
runtime_context.emit(event_type, **payload)
|
|
685
|
+
|
|
686
|
+
callback = runtime_context.checkpoint_callback
|
|
687
|
+
if callback is None:
|
|
688
|
+
return
|
|
689
|
+
try:
|
|
690
|
+
result = callback(runtime_context.checkpoint)
|
|
691
|
+
if hasattr(result, "__await__"):
|
|
692
|
+
await result
|
|
693
|
+
except Exception:
|
|
694
|
+
# 状态收口不能被二次持久化异常覆盖;RuntimeCompat 的 finally 还会再保存一次。
|
|
695
|
+
logger.exception(
|
|
696
|
+
"持久化 Agent run 终态失败: %s/%s",
|
|
697
|
+
runtime_context.thread_id,
|
|
698
|
+
runtime_context.run_id,
|
|
699
|
+
)
|
|
700
|
+
|
|
701
|
+
# 工具执行细节
|
|
702
|
+
async def _execute_tools(
|
|
703
|
+
self,
|
|
704
|
+
tool_calls: list[dict[str, Any]],
|
|
705
|
+
messages: list[Message],
|
|
706
|
+
ctx: MiddlewareContext,
|
|
707
|
+
runtime_context: RunContext | None = None,
|
|
708
|
+
) -> list[Message]:
|
|
709
|
+
"""并发执行所有工具,把结果追加到 messages。"""
|
|
710
|
+
|
|
711
|
+
async def execute_one(tc: dict[str, Any]) -> Message:
|
|
712
|
+
# 每个并发任务使用独立上下文,避免工具名、参数和结果互相覆盖。
|
|
713
|
+
tool_ctx = MiddlewareContext(
|
|
714
|
+
messages=list(messages),
|
|
715
|
+
iteration=ctx.iteration,
|
|
716
|
+
tool_name=tc["name"],
|
|
717
|
+
tool_args=tc.get("args", {}),
|
|
718
|
+
llm_response=ctx.llm_response,
|
|
719
|
+
metadata=dict(ctx.metadata),
|
|
720
|
+
emit=runtime_context.emit if runtime_context is not None else ctx.emit,
|
|
721
|
+
)
|
|
722
|
+
if runtime_context is not None:
|
|
723
|
+
runtime_context.emit(
|
|
724
|
+
"tool_started",
|
|
725
|
+
tool_name=tc["name"],
|
|
726
|
+
arguments=tool_ctx.tool_args,
|
|
727
|
+
)
|
|
728
|
+
mw_result = await self._middleware_manager.execute_before_tool(tool_ctx)
|
|
729
|
+
|
|
730
|
+
if mw_result.action == MiddlewareAction.STOP:
|
|
731
|
+
logger.warning("中间件阻止工具 %s 执行", tc["name"])
|
|
732
|
+
result = f"[工具 {tc['name']} 被阻止执行]"
|
|
733
|
+
if runtime_context is not None:
|
|
734
|
+
runtime_context.emit(
|
|
735
|
+
"tool_finished",
|
|
736
|
+
tool_name=tc["name"],
|
|
737
|
+
success=False,
|
|
738
|
+
result={"error_kind": "approval", "error": result},
|
|
739
|
+
)
|
|
740
|
+
tool_ctx.metadata["tool_success"] = False
|
|
741
|
+
else:
|
|
742
|
+
# 审批通过后才真正执行副作用工具。
|
|
743
|
+
structured_result = await self._tool_executor.execute_result(tc["name"], tc.get("args", {}))
|
|
744
|
+
result = structured_result.to_text()
|
|
745
|
+
tool_ctx.metadata["tool_success"] = structured_result.success
|
|
746
|
+
if runtime_context is not None:
|
|
747
|
+
runtime_context.emit(
|
|
748
|
+
"tool_finished",
|
|
749
|
+
tool_name=tc["name"],
|
|
750
|
+
success=structured_result.success,
|
|
751
|
+
result=structured_result.to_dict(),
|
|
752
|
+
)
|
|
753
|
+
|
|
754
|
+
# after_tool 中间件
|
|
755
|
+
tool_ctx.tool_result = result
|
|
756
|
+
await self._middleware_manager.execute_after_tool(tool_ctx)
|
|
757
|
+
return tool_message(
|
|
758
|
+
content=result,
|
|
759
|
+
tool_call_id=tc["id"],
|
|
760
|
+
name=tc["name"],
|
|
761
|
+
)
|
|
762
|
+
|
|
763
|
+
async def execute_indexed(index: int, tool_call: dict[str, Any]) -> tuple[int, Message | Exception]:
|
|
764
|
+
try:
|
|
765
|
+
return index, await execute_one(tool_call)
|
|
766
|
+
except Exception as exc:
|
|
767
|
+
return index, exc
|
|
768
|
+
|
|
769
|
+
tasks = [
|
|
770
|
+
asyncio.create_task(execute_indexed(index, tool_call))
|
|
771
|
+
for index, tool_call in enumerate(tool_calls)
|
|
772
|
+
]
|
|
773
|
+
completed_indices: set[int] = set()
|
|
774
|
+
progress_messages: list[Message] = []
|
|
775
|
+
ordered_messages: list[Message | None] = [None] * len(tool_calls)
|
|
776
|
+
try:
|
|
777
|
+
for completed in asyncio.as_completed(tasks):
|
|
778
|
+
index, outcome = await completed
|
|
779
|
+
tool_call = tool_calls[index]
|
|
780
|
+
if isinstance(outcome, Exception):
|
|
781
|
+
error_text = str(outcome)
|
|
782
|
+
logger.exception(
|
|
783
|
+
"工具 %s 的执行链异常",
|
|
784
|
+
tool_call["name"],
|
|
785
|
+
exc_info=(type(outcome), outcome, outcome.__traceback__),
|
|
786
|
+
)
|
|
787
|
+
content = f"[工具 {tool_call['name']} 执行异常] {error_text}"
|
|
788
|
+
outcome = tool_message(
|
|
789
|
+
content=content,
|
|
790
|
+
tool_call_id=tool_call["id"],
|
|
791
|
+
name=tool_call["name"],
|
|
792
|
+
)
|
|
793
|
+
if runtime_context is not None:
|
|
794
|
+
runtime_context.emit(
|
|
795
|
+
"tool_finished",
|
|
796
|
+
tool_name=tool_call["name"],
|
|
797
|
+
success=False,
|
|
798
|
+
result={"error_kind": "execution", "error": error_text},
|
|
799
|
+
)
|
|
800
|
+
ordered_messages[index] = outcome
|
|
801
|
+
completed_indices.add(index)
|
|
802
|
+
progress_messages.append(outcome)
|
|
803
|
+
if runtime_context is not None:
|
|
804
|
+
pending = [
|
|
805
|
+
tool_call
|
|
806
|
+
for pending_index, tool_call in enumerate(tool_calls)
|
|
807
|
+
if pending_index not in completed_indices
|
|
808
|
+
]
|
|
809
|
+
if pending:
|
|
810
|
+
# 只保存"仍有未完成工具"的阶段快照;批末的 phase=model 快照
|
|
811
|
+
# 由主循环统一保存,避免与最后一次进度快照重复全量持久化。
|
|
812
|
+
await self._persist_tool_progress(
|
|
813
|
+
runtime_context,
|
|
814
|
+
[*messages, *progress_messages],
|
|
815
|
+
pending,
|
|
816
|
+
)
|
|
817
|
+
finally:
|
|
818
|
+
for task in tasks:
|
|
819
|
+
if not task.done():
|
|
820
|
+
task.cancel()
|
|
821
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
822
|
+
|
|
823
|
+
# 最终消息按模型原始调用顺序追加;阶段性 checkpoint 允许按完成顺序恢复。
|
|
824
|
+
messages.extend(message for message in ordered_messages if message is not None)
|
|
825
|
+
return messages
|
|
826
|
+
|
|
827
|
+
async def _persist_tool_progress(
|
|
828
|
+
self,
|
|
829
|
+
runtime_context: RunContext,
|
|
830
|
+
messages: list[Message],
|
|
831
|
+
pending_tool_calls: list[dict[str, Any]],
|
|
832
|
+
) -> None:
|
|
833
|
+
"""保存并发工具批次的阶段性进度,恢复时只重放尚未完成的调用。"""
|
|
834
|
+
state = {
|
|
835
|
+
**runtime_context.checkpoint,
|
|
836
|
+
"messages": [message.to_dict() for message in messages],
|
|
837
|
+
"phase": "execute_tools" if pending_tool_calls else "model",
|
|
838
|
+
"pending_tool_calls": pending_tool_calls,
|
|
839
|
+
}
|
|
840
|
+
if not pending_tool_calls:
|
|
841
|
+
state["next_iteration"] = int(state.get("iteration", 0)) + 1
|
|
842
|
+
runtime_context.checkpoint = state
|
|
843
|
+
callback = runtime_context.checkpoint_callback
|
|
844
|
+
if callback is not None:
|
|
845
|
+
result = callback(state)
|
|
846
|
+
if hasattr(result, "__await__"):
|
|
847
|
+
await result
|
|
848
|
+
if self._checkpointer is not None:
|
|
849
|
+
await self._checkpointer.save(
|
|
850
|
+
runtime_context.thread_id,
|
|
851
|
+
{**state, "run_context": runtime_context.to_dict()},
|
|
852
|
+
metadata={"iteration": int(state.get("iteration", 0)), "phase": state["phase"]},
|
|
853
|
+
)
|
|
854
|
+
|
|
855
|
+
# ──────────────────────────────────────────────
|
|
856
|
+
# 时间旅行接口
|
|
857
|
+
# ──────────────────────────────────────────────
|
|
858
|
+
|
|
859
|
+
async def get_checkpoint_history(self, thread_id: str | None = None) -> list[dict[str, Any]]:
|
|
860
|
+
"""返回指定 thread 的 checkpoint 版本列表(需要 checkpointer)。
|
|
861
|
+
|
|
862
|
+
checkpoint 保存使用的是每次运行传入的 ``thread_id``;当运行时会话与构造
|
|
863
|
+
Agent 时的默认会话不同(例如经由 AgentRuntime 运行),必须显式传入。
|
|
864
|
+
"""
|
|
865
|
+
if not self._checkpointer:
|
|
866
|
+
return []
|
|
867
|
+
list_versions = getattr(self._checkpointer, "list_versions", None)
|
|
868
|
+
if list_versions is None:
|
|
869
|
+
# 内存和文件快照只有当前状态,没有时间旅行版本列表。
|
|
870
|
+
return []
|
|
871
|
+
loader = cast(
|
|
872
|
+
Callable[[str], Awaitable[list[dict[str, Any]]]],
|
|
873
|
+
list_versions,
|
|
874
|
+
)
|
|
875
|
+
return await loader(thread_id or self._thread_id)
|
|
876
|
+
|
|
877
|
+
async def rollback_to(self, version_id: str, *, thread_id: str | None = None) -> bool:
|
|
878
|
+
"""回滚指定 thread 的 checkpoint 版本(需要 checkpointer)。"""
|
|
879
|
+
if not self._checkpointer:
|
|
880
|
+
return False
|
|
881
|
+
rollback = getattr(self._checkpointer, "rollback", None)
|
|
882
|
+
if rollback is None:
|
|
883
|
+
return False
|
|
884
|
+
rollback_version = cast(
|
|
885
|
+
Callable[[str, str], Awaitable[bool]],
|
|
886
|
+
rollback,
|
|
887
|
+
)
|
|
888
|
+
return await rollback_version(thread_id or self._thread_id, version_id)
|