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.
Files changed (104) hide show
  1. agent_core/__init__.py +525 -0
  2. agent_core/access/__init__.py +6 -0
  3. agent_core/access/context.py +45 -0
  4. agent_core/access/guard.py +40 -0
  5. agent_core/acp/README.md +236 -0
  6. agent_core/acp/__init__.py +20 -0
  7. agent_core/acp/server.py +351 -0
  8. agent_core/acp/types.py +86 -0
  9. agent_core/callbacks/__init__.py +17 -0
  10. agent_core/callbacks/manager.py +105 -0
  11. agent_core/checkpoint/__init__.py +19 -0
  12. agent_core/checkpoint/naming.py +93 -0
  13. agent_core/checkpoint/store.py +186 -0
  14. agent_core/checkpoint/travel.py +448 -0
  15. agent_core/compaction/__init__.py +57 -0
  16. agent_core/compaction/middleware.py +428 -0
  17. agent_core/compaction/prompts.py +32 -0
  18. agent_core/compaction/pruner.py +121 -0
  19. agent_core/compaction/region.py +145 -0
  20. agent_core/compaction/spill.py +81 -0
  21. agent_core/compaction/spill_tool.py +42 -0
  22. agent_core/compaction/summarizer.py +95 -0
  23. agent_core/compaction/token_limit.py +74 -0
  24. agent_core/compaction/types.py +93 -0
  25. agent_core/docs.py +15 -0
  26. agent_core/documents/__init__.py +23 -0
  27. agent_core/documents/loaders.py +79 -0
  28. agent_core/documents/splitters.py +94 -0
  29. agent_core/documents/types.py +197 -0
  30. agent_core/errors.py +81 -0
  31. agent_core/guardrails/__init__.py +19 -0
  32. agent_core/guardrails/core.py +186 -0
  33. agent_core/hitl/__init__.py +15 -0
  34. agent_core/hitl/core.py +517 -0
  35. agent_core/mcp/__init__.py +14 -0
  36. agent_core/mcp/client.py +212 -0
  37. agent_core/mcp/errors.py +26 -0
  38. agent_core/middleware/__init__.py +26 -0
  39. agent_core/middleware/base.py +283 -0
  40. agent_core/model/__init__.py +73 -0
  41. agent_core/model/catalog.py +73 -0
  42. agent_core/model/core.py +179 -0
  43. agent_core/model/governance.py +317 -0
  44. agent_core/model/providers/__init__.py +43 -0
  45. agent_core/model/providers/anthropic.py +294 -0
  46. agent_core/model/providers/common.py +28 -0
  47. agent_core/model/providers/factory.py +107 -0
  48. agent_core/model/providers/gemini.py +273 -0
  49. agent_core/model/providers/ollama.py +80 -0
  50. agent_core/model/providers/openai_compatible.py +526 -0
  51. agent_core/model/structured.py +200 -0
  52. agent_core/observability/__init__.py +21 -0
  53. agent_core/observability/core.py +311 -0
  54. agent_core/ports/__init__.py +31 -0
  55. agent_core/ports/memory.py +130 -0
  56. agent_core/ports/model_selection.py +64 -0
  57. agent_core/ports/storage.py +114 -0
  58. agent_core/prompts/__init__.py +19 -0
  59. agent_core/prompts/registry.py +236 -0
  60. agent_core/prompts/templates.py +166 -0
  61. agent_core/protocol/__init__.py +31 -0
  62. agent_core/protocol/capabilities.py +49 -0
  63. agent_core/protocol/messages.py +100 -0
  64. agent_core/protocol/runtime.py +293 -0
  65. agent_core/py.typed +1 -0
  66. agent_core/retrieval/__init__.py +40 -0
  67. agent_core/retrieval/_common.py +121 -0
  68. agent_core/retrieval/chroma.py +269 -0
  69. agent_core/retrieval/core.py +63 -0
  70. agent_core/retrieval/factory.py +72 -0
  71. agent_core/retrieval/memory.py +232 -0
  72. agent_core/retrieval/pgvector.py +239 -0
  73. agent_core/retrieval/types.py +95 -0
  74. agent_core/runtime/__init__.py +28 -0
  75. agent_core/runtime/compat.py +98 -0
  76. agent_core/runtime/react.py +888 -0
  77. agent_core/runtime/service.py +557 -0
  78. agent_core/runtime/store.py +55 -0
  79. agent_core/serialization/__init__.py +21 -0
  80. agent_core/serialization/registry.py +318 -0
  81. agent_core/skills/__init__.py +24 -0
  82. agent_core/skills/errors.py +27 -0
  83. agent_core/skills/loader.py +258 -0
  84. agent_core/skills/registry.py +205 -0
  85. agent_core/skills/types.py +56 -0
  86. agent_core/storage/__init__.py +107 -0
  87. agent_core/storage/_sqlite_utils.py +92 -0
  88. agent_core/storage/context.py +382 -0
  89. agent_core/storage/lease.py +435 -0
  90. agent_core/storage/memory.py +604 -0
  91. agent_core/storage/model_selection.py +360 -0
  92. agent_core/storage/runtime.py +899 -0
  93. agent_core/storage/session.py +904 -0
  94. agent_core/storage/workflow.py +411 -0
  95. agent_core/tools/__init__.py +5 -0
  96. agent_core/tools/executor.py +306 -0
  97. agent_core/workflow/__init__.py +25 -0
  98. agent_core/workflow/durable.py +175 -0
  99. agent_core/workflow/state_machine.py +427 -0
  100. zero_agent_core-0.2.0.dist-info/METADATA +237 -0
  101. zero_agent_core-0.2.0.dist-info/RECORD +104 -0
  102. zero_agent_core-0.2.0.dist-info/WHEEL +4 -0
  103. zero_agent_core-0.2.0.dist-info/entry_points.txt +3 -0
  104. 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)