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,526 @@
1
+ """适配器 1:OpenAI / 兼容接口(默认)。
2
+
3
+ DashScope(Qwen)、Ollama 等 OpenAI 兼容端点复用该实现;
4
+ Qwen 本地 tokenizer 与 tokenizer API 探测辅助也位于本模块。
5
+ """
6
+
7
+ from __future__ import annotations
8
+
9
+ import copy
10
+ import json
11
+ from collections.abc import AsyncIterator
12
+ from typing import Any
13
+
14
+ from agent_core.errors import ModelInvocationError
15
+ from agent_core.model.core import ContextUsage, StreamChunk
16
+ from agent_core.model.providers.common import known_context_window, logger
17
+ from agent_core.protocol.messages import Message, assistant_message
18
+
19
+
20
+ # ── token / 上下文窗口辅助函数 ────────────────────────────────
21
+ def _read_token_count(value: Any) -> int | None:
22
+ candidates = [value]
23
+ if isinstance(value, dict):
24
+ output = value.get("output")
25
+ candidates.extend([value.get("usage"), value.get("data"), output])
26
+ if isinstance(output, dict):
27
+ candidates.append(output.get("usage"))
28
+ for candidate in candidates:
29
+ if not isinstance(candidate, dict):
30
+ continue
31
+ for key in ("prompt_tokens", "input_tokens", "total_tokens", "token_count"):
32
+ token_count = candidate.get(key)
33
+ if isinstance(token_count, int) and token_count >= 0:
34
+ return token_count
35
+ token_ids = candidate.get("token_ids")
36
+ if isinstance(token_ids, list):
37
+ return len(token_ids)
38
+ return None
39
+
40
+
41
+ # ── token / 统计完整模型输入辅助函数 ────────────────────────────────
42
+ def _count_qwen_tokens(
43
+ messages: list[Message],
44
+ tools: list[dict[str, Any]] | None,
45
+ ) -> int | None:
46
+ """使用 DashScope SDK 随附的官方 Qwen tokenizer 统计完整模型输入。"""
47
+ try:
48
+ # 导入 DashScope tokenizer
49
+ from dashscope.tokenizers import get_tokenizer
50
+
51
+ # 获取 Qwen tokenizer
52
+ tokenizer = get_tokenizer("qwen-plus")
53
+ parts: list[str] = []
54
+ for message in messages:
55
+ # 构建消息内容
56
+ parts.extend(["<|im_start|>", message.role, "\n", message.content])
57
+ # 处理工具调用
58
+ if message.tool_calls:
59
+ parts.append(json.dumps(message.tool_calls, ensure_ascii=False, separators=(",", ":")))
60
+ if message.name:
61
+ parts.extend(["\nname:", message.name])
62
+ if message.tool_call_id:
63
+ parts.extend(["\ntool_call_id:", message.tool_call_id])
64
+ parts.append("<|im_end|>\n")
65
+ if tools:
66
+ # 构建工具定义
67
+ parts.extend(
68
+ [
69
+ "<|im_start|>system\n# Tools\n",
70
+ json.dumps(tools, ensure_ascii=False, separators=(",", ":")),
71
+ "<|im_end|>\n",
72
+ ]
73
+ )
74
+ # 构建助手消息
75
+ parts.append("<|im_start|>assistant\n")
76
+ # 编码消息
77
+ token_ids = tokenizer.encode("".join(parts))
78
+ # 返回 token 数
79
+ return len(token_ids)
80
+ except Exception as exc:
81
+ logger.debug("本地 Qwen tokenizer 不可用:%s", exc)
82
+ return None
83
+
84
+
85
+ # ── token / 上下文窗口辅助函数 ────────────────────────────────
86
+ def _read_context_window(value: Any) -> int | None:
87
+ if not isinstance(value, dict):
88
+ return None
89
+ candidates = [value, value.get("data"), value.get("model")] # 从多个字段中读取上下文窗口大小
90
+ for candidate in candidates: # 遍历候选字段
91
+ if not isinstance(candidate, dict): # 跳过非字典字段
92
+ continue
93
+ for key in (
94
+ "context_window_tokens",
95
+ "context_length",
96
+ "max_context_length",
97
+ "max_input_tokens",
98
+ "input_token_limit",
99
+ ):
100
+ context_window = candidate.get(key)
101
+ if isinstance(context_window, int) and context_window > 0:
102
+ return context_window
103
+ return None
104
+
105
+
106
+ class OpenAIProvider:
107
+ """OpenAI 兼容接口的模型适配器。
108
+
109
+ 功能:
110
+ 1. 普通 chat completion(支持 tool calling)
111
+ 2. 流式 chat(支持工具调用收集)
112
+ 3. JSON 结构化输出
113
+ """
114
+
115
+ provider_name = "openai"
116
+
117
+ def __init__(
118
+ self,
119
+ *,
120
+ api_key: str,
121
+ base_url: str = "https://api.openai.com/v1",
122
+ model: str = "gpt-4o-mini",
123
+ temperature: float = 0.0,
124
+ max_tokens: int = 4096,
125
+ context_window_tokens: int | None = None, # 上下文窗口 token 数
126
+ ) -> None:
127
+ # 只在真正选择 OpenAI 提供商时加载 SDK,导入 Core 其他能力不应依赖它。
128
+ try:
129
+ from openai import AsyncOpenAI
130
+ except ImportError as exc:
131
+ raise ModelInvocationError("OpenAI 提供商需要 openai 依赖,请先安装项目运行依赖。") from exc
132
+ self._client = AsyncOpenAI(api_key=api_key, base_url=base_url) # 初始化 OpenAI 客户端
133
+ self._api_key = api_key # API 密钥
134
+ self._base_url = base_url.rstrip("/") # 去掉末尾的斜杠
135
+ self._model = model # 模型名称
136
+ # 暴露只读语义的公开元数据,便于日志、路由和运行审计使用。
137
+ self.model = model # 模型名称
138
+ self._temperature = temperature # 温度参数
139
+ self._max_tokens = max_tokens # 最大 token 数
140
+ self._configured_context_window_tokens = context_window_tokens # 上下文窗口 token 数
141
+ self._context_window_cache: int | None = None # 上下文窗口 token 数缓存,用于优化调用
142
+
143
+ # ──────────────────────────────────────────────
144
+ # 普通对话
145
+ # ──────────────────────────────────────────────
146
+
147
+ async def chat(
148
+ self,
149
+ messages: list[Message],
150
+ *,
151
+ tools: list[dict[str, Any]] | None = None,
152
+ temperature: float | None = None,
153
+ max_tokens: int | None = None,
154
+ ) -> Message:
155
+ """调用 LLM 进行对话。
156
+
157
+ Args:
158
+ messages: 消息历史列表。
159
+ tools: 可选的工具定义列表(OpenAI function calling 格式)。
160
+ temperature: 覆盖默认温度。
161
+ max_tokens: 覆盖默认最大 token 数。
162
+
163
+ Returns:
164
+ LLM 的回复消息(assistant 角色)。
165
+
166
+ Raises:
167
+ ModelInvocationError: LLM 调用失败。
168
+ """
169
+ openai_messages = [msg.to_openai_dict() for msg in messages] # 转换为 OpenAI 格式的消息
170
+
171
+ # 构建请求参数
172
+ kwargs: dict[str, Any] = {
173
+ "model": self._model,
174
+ "messages": openai_messages,
175
+ "temperature": temperature if temperature is not None else self._temperature,
176
+ "max_tokens": max_tokens if max_tokens is not None else self._max_tokens,
177
+ }
178
+
179
+ # 如果有工具定义,添加到请求参数
180
+ if tools:
181
+ kwargs["tools"] = tools
182
+ kwargs["tool_choice"] = "auto"
183
+
184
+ try:
185
+ logger.debug(
186
+ "LLM call: model=%s, messages=%d, tools=%d",
187
+ self._model,
188
+ len(messages),
189
+ len(tools) if tools else 0,
190
+ )
191
+ # 调用 OpenAI API
192
+ response = await self._client.chat.completions.create(**kwargs)
193
+ except Exception as exc:
194
+ raise ModelInvocationError(f"OpenAI API call failed: {exc}") from exc
195
+
196
+ if not response.choices: # 检查是否有有效回复
197
+ raise ModelInvocationError("OpenAI API returned no choices.")
198
+ # 解析响应
199
+ choice = response.choices[0] # 取第一个回复
200
+ message = choice.message # 取回复消息
201
+ content = message.content or "" # 取回复内容
202
+ tool_calls: list[dict[str, Any]] = [] # 初始化工具调用列表
203
+ # 解析工具调用
204
+ if message.tool_calls:
205
+ for tc in message.tool_calls: # 遍历工具调用
206
+ try:
207
+ arguments = json.loads(tc.function.arguments)
208
+ except (json.JSONDecodeError, TypeError) as exc:
209
+ raise ModelInvocationError(
210
+ f"OpenAI 返回的工具 {tc.function.name!r} 参数不是有效 JSON:{exc}"
211
+ ) from exc
212
+ if not isinstance(arguments, dict):
213
+ raise ModelInvocationError(f"OpenAI 返回的工具 {tc.function.name!r} 参数必须是 JSON 对象")
214
+ tool_calls.append(
215
+ {
216
+ "id": tc.id, # 工具调用 ID
217
+ "type": "function", # 工具调用类型
218
+ # 工具名称
219
+ "name": tc.function.name,
220
+ # 工具参数
221
+ "args": arguments,
222
+ }
223
+ )
224
+
225
+ # 记录日志
226
+ logger.debug(
227
+ "LLM response: content_len=%d, tool_calls=%d, finish=%s",
228
+ len(content),
229
+ len(tool_calls),
230
+ choice.finish_reason,
231
+ )
232
+ # 返回回复消息
233
+ return assistant_message(content, tool_calls=tool_calls)
234
+
235
+ # ──────────────────────────────────────────────
236
+ # 上下文使用
237
+ # ──────────────────────────────────────────────
238
+ async def context_usage(
239
+ self,
240
+ messages: list[Message],
241
+ *,
242
+ tools: list[dict[str, Any]] | None = None,
243
+ ) -> ContextUsage:
244
+ """通过兼容接口的 Tokenize API 获取精确输入 token 数。"""
245
+ payload: dict[str, Any] = {
246
+ "model": self._model,
247
+ "messages": [message.to_openai_dict() for message in messages],
248
+ }
249
+ if tools:
250
+ payload["tools"] = tools
251
+
252
+ input_tokens: int | None = None
253
+ source = "unavailable" # 初始化来源为不可用
254
+ if self._model.lower().startswith("qwen"): # 如果是 Qwen 模型
255
+ input_tokens = _count_qwen_tokens(messages, tools) # 计算 Qwen 模型的 token 数
256
+ if input_tokens is not None: # 如果计算成功
257
+ source = "official_qwen_tokenizer"
258
+
259
+ try:
260
+ import httpx
261
+
262
+ requests: list[tuple[str, dict[str, Any]]] = [
263
+ (f"{self._base_url}/tokenizer", payload),
264
+ (f"{self._base_url}/tokenize", payload),
265
+ ]
266
+ if "dashscope.aliyuncs.com" in self._base_url:
267
+ native_payload = {
268
+ "model": self._model,
269
+ "input": {
270
+ "messages": payload["messages"],
271
+ **({"tools": tools} if tools else {}),
272
+ },
273
+ }
274
+ requests.insert(
275
+ 0,
276
+ (
277
+ "https://dashscope.aliyuncs.com/api/v1/tokenizer",
278
+ native_payload,
279
+ ),
280
+ )
281
+
282
+ async with httpx.AsyncClient(timeout=15) as client:
283
+ for url, request_payload in requests:
284
+ if input_tokens is not None:
285
+ break
286
+ response = await client.post(
287
+ url,
288
+ headers={"Authorization": f"Bearer {self._api_key}"},
289
+ json=request_payload,
290
+ )
291
+ if not response.is_success:
292
+ continue
293
+ input_tokens = _read_token_count(response.json())
294
+ if input_tokens is not None:
295
+ source = "provider_tokenizer"
296
+ break
297
+ except Exception as exc:
298
+ logger.debug("模型 tokenizer 不可用:%s", exc)
299
+
300
+ context_window_tokens = await self._context_window_tokens()
301
+ return ContextUsage(
302
+ input_tokens=input_tokens,
303
+ context_window_tokens=context_window_tokens,
304
+ exact=input_tokens is not None,
305
+ source=source,
306
+ )
307
+
308
+ # ──────────────────────────────────────────────
309
+ # 读取模型上下文上限
310
+ # ──────────────────────────────────────────────
311
+ async def _context_window_tokens(self) -> int | None:
312
+ if self._configured_context_window_tokens:
313
+ return self._configured_context_window_tokens
314
+ if self._context_window_cache is not None:
315
+ return self._context_window_cache
316
+
317
+ try:
318
+ import httpx
319
+
320
+ async with httpx.AsyncClient(timeout=8) as client:
321
+ response = await client.get(
322
+ f"{self._base_url}/models/{self._model}",
323
+ headers={"Authorization": f"Bearer {self._api_key}"},
324
+ )
325
+ if response.is_success:
326
+ value = _read_context_window(response.json())
327
+ if value is not None:
328
+ self._context_window_cache = value
329
+ return value
330
+ except Exception as exc:
331
+ logger.debug("无法读取模型上下文上限:%s", exc)
332
+
333
+ value = known_context_window(self._model)
334
+ self._context_window_cache = value
335
+ return value
336
+
337
+ # ──────────────────────────────────────────────
338
+ # 流式对话(支持工具调用收集)
339
+ # ──────────────────────────────────────────────
340
+
341
+ async def stream_chat(
342
+ self,
343
+ messages: list[Message],
344
+ *,
345
+ tools: list[dict[str, Any]] | None = None,
346
+ temperature: float | None = None,
347
+ max_tokens: int | None = None,
348
+ ) -> AsyncIterator[StreamChunk]:
349
+ """流式调用 LLM。
350
+
351
+ 每次 yield 一个 StreamChunk:
352
+ - 纯文本回答时:chunk.text 有内容,chunk.tool_calls 为空
353
+ - 工具调用时:先 yield 空文本块,最后一块 chunk.tool_calls 有完整调用列表
354
+
355
+ Args:
356
+ messages: 消息历史列表。
357
+ tools: 可选的工具定义列表。
358
+ temperature: 覆盖默认温度。
359
+ max_tokens: 覆盖默认最大 token 数。
360
+
361
+ Yields:
362
+ StreamChunk 数据块。
363
+
364
+ Raises:
365
+ ModelInvocationError: LLM 调用失败。
366
+ """
367
+ # 转换为 OpenAI 格式的消息
368
+ openai_messages = [msg.to_openai_dict() for msg in messages]
369
+ # 构建请求参数
370
+ kwargs: dict[str, Any] = {
371
+ "model": self._model,
372
+ "messages": openai_messages,
373
+ "temperature": temperature if temperature is not None else self._temperature,
374
+ "max_tokens": max_tokens if max_tokens is not None else self._max_tokens,
375
+ "stream": True,
376
+ }
377
+ # 如果有工具定义,添加到请求参数
378
+ if tools:
379
+ kwargs["tools"] = tools
380
+ kwargs["tool_choice"] = "auto"
381
+ # 调用 OpenAI API
382
+ try:
383
+ logger.debug(
384
+ "LLM stream call: model=%s, messages=%d, tools=%d",
385
+ self._model,
386
+ len(messages),
387
+ len(tools) if tools else 0,
388
+ )
389
+ # 调用 OpenAI API
390
+ stream = await self._client.chat.completions.create(**kwargs)
391
+ except Exception as exc:
392
+ raise ModelInvocationError(f"OpenAI API stream call failed: {exc}") from exc
393
+
394
+ # 收集工具调用的增量数据(各 index 独立积累)
395
+ # tool_call_accum: index -> {"id": str, "name": str, "args": str}
396
+ tool_call_accum: dict[int, dict[str, str]] = {}
397
+ finish_reason: str | None = None
398
+
399
+ # 处理流式响应
400
+ try:
401
+ async for chunk in stream:
402
+ # 跳过空 chunk
403
+ if not chunk.choices:
404
+ continue
405
+ # 提取增量内容
406
+ delta = chunk.choices[0].delta
407
+ finish_reason = chunk.choices[0].finish_reason
408
+
409
+ # ── 文本内容块,直接 yield ──────────────────────
410
+ if delta.content:
411
+ yield StreamChunk(text=delta.content)
412
+
413
+ # ── 工具调用增量,累积不 yield ─────────────────
414
+ if delta.tool_calls:
415
+ for tc_delta in delta.tool_calls:
416
+ idx = tc_delta.index
417
+ if idx not in tool_call_accum:
418
+ tool_call_accum[idx] = {"id": "", "name": "", "args": ""}
419
+
420
+ if tc_delta.id:
421
+ tool_call_accum[idx]["id"] += tc_delta.id
422
+ if tc_delta.function and tc_delta.function.name:
423
+ tool_call_accum[idx]["name"] += tc_delta.function.name
424
+ if tc_delta.function and tc_delta.function.arguments:
425
+ tool_call_accum[idx]["args"] += tc_delta.function.arguments
426
+
427
+ except Exception as exc:
428
+ raise ModelInvocationError(f"LLM stream failed: {exc}") from exc
429
+
430
+ # 流结束后,如果有工具调用,yield 最终 chunk 携带完整 tool_calls
431
+ if tool_call_accum:
432
+ tool_calls: list[dict[str, Any]] = []
433
+ for idx in sorted(tool_call_accum.keys()):
434
+ acc = tool_call_accum[idx]
435
+ try:
436
+ args = json.loads(acc["args"]) if acc["args"] else {}
437
+ except json.JSONDecodeError as exc:
438
+ raise ModelInvocationError(
439
+ f"OpenAI 流式工具 {acc['name']!r} 参数不是有效 JSON:{exc}"
440
+ ) from exc
441
+ if not isinstance(args, dict):
442
+ raise ModelInvocationError(f"OpenAI 流式工具 {acc['name']!r} 参数必须是 JSON 对象")
443
+ # 构建工具调用记录
444
+ tool_calls.append(
445
+ {
446
+ "id": acc["id"],
447
+ "type": "function",
448
+ "name": acc["name"],
449
+ "args": args,
450
+ }
451
+ )
452
+ # 最后一个 chunk,包含完整 tool_calls
453
+ logger.debug(
454
+ "LLM stream end: model=%s, messages=%d, tools=%d",
455
+ self._model,
456
+ len(messages),
457
+ len(tools) if tools else 0,
458
+ )
459
+ yield StreamChunk(
460
+ tool_calls=tool_calls,
461
+ is_tool_call=True,
462
+ finish_reason=finish_reason,
463
+ )
464
+ else:
465
+ # 纯文本结束,发一个 finish 信号
466
+ yield StreamChunk(finish_reason=finish_reason)
467
+
468
+ # ──────────────────────────────────────────────
469
+ # JSON 结构化输出
470
+ # ──────────────────────────────────────────────
471
+
472
+ async def parse_json(
473
+ self,
474
+ messages: list[Message],
475
+ *,
476
+ schema: dict[str, Any] | None = None,
477
+ ) -> dict[str, Any]:
478
+ """调用 LLM 并解析 JSON 输出。
479
+
480
+ Args:
481
+ messages: 消息历史列表。
482
+ schema: 可选的 JSON schema(用于验证)。
483
+
484
+ Returns:
485
+ 解析后的 JSON 对象。
486
+
487
+ Raises:
488
+ ModelInvocationError: LLM 调用失败或 JSON 解析失败。
489
+ """
490
+ # Message 是可变对象,浅拷贝会把 JSON 指令永久追加到调用方的 system 消息。
491
+ enhanced_messages = copy.deepcopy(messages)
492
+ if enhanced_messages and enhanced_messages[0].role == "system":
493
+ enhanced_messages[0].content += "\n\n**重要**:你必须返回有效的 JSON 格式,不要包含任何其他文本。"
494
+
495
+ response = await self.chat(enhanced_messages)
496
+ content = response.content.strip()
497
+
498
+ # 移除 markdown 代码块标记
499
+ if content.startswith("```json"):
500
+ content = content[7:]
501
+ if content.startswith("```"):
502
+ content = content[3:]
503
+ if content.endswith("```"):
504
+ content = content[:-3]
505
+ content = content.strip()
506
+
507
+ try:
508
+ result = json.loads(content)
509
+ except json.JSONDecodeError as exc:
510
+ raise ModelInvocationError(
511
+ f"Failed to parse JSON from LLM output: {exc}\nOutput: {content[:200]}"
512
+ ) from exc
513
+
514
+ if not isinstance(result, dict):
515
+ raise ModelInvocationError(f"LLM output is not a JSON object: {type(result)}")
516
+
517
+ if schema is not None:
518
+ from agent_core.errors import ModelOutputValidationError
519
+ from agent_core.model.structured import validate_json_schema
520
+
521
+ try:
522
+ validate_json_schema(result, schema)
523
+ except ModelOutputValidationError as exc:
524
+ raise ModelInvocationError(f"LLM JSON 输出不符合 schema:{exc}") from exc
525
+
526
+ return {str(key): value for key, value in result.items()}