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
agent_core/mcp/client.py
ADDED
|
@@ -0,0 +1,212 @@
|
|
|
1
|
+
"""MCP stdio 客户端适配器。
|
|
2
|
+
|
|
3
|
+
Core 提供标准 MCP 会话管理和工具调用,不包含任何 Cesium、航线或业务白名单。
|
|
4
|
+
应用层可以基于 ``McpToolCaller`` 包装自己的受限客户端。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import asyncio
|
|
10
|
+
import json
|
|
11
|
+
import os
|
|
12
|
+
from collections.abc import Mapping
|
|
13
|
+
from contextlib import AsyncExitStack
|
|
14
|
+
from pathlib import Path
|
|
15
|
+
from typing import Protocol, cast
|
|
16
|
+
|
|
17
|
+
from agent_core.mcp.errors import McpConnectionError, McpTimeoutError, McpToolError
|
|
18
|
+
|
|
19
|
+
JsonObject = dict[str, object]
|
|
20
|
+
_INHERITED_ENV_KEYS = frozenset(
|
|
21
|
+
{
|
|
22
|
+
"COMSPEC",
|
|
23
|
+
"HOME",
|
|
24
|
+
"LANG",
|
|
25
|
+
"PATH",
|
|
26
|
+
"PATHEXT",
|
|
27
|
+
"SYSTEMROOT",
|
|
28
|
+
"TEMP",
|
|
29
|
+
"TMP",
|
|
30
|
+
"USERPROFILE",
|
|
31
|
+
}
|
|
32
|
+
)
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
class McpToolCaller(Protocol):
|
|
36
|
+
"""MCP 工具调用端口。"""
|
|
37
|
+
|
|
38
|
+
async def list_tools(self) -> list[str]: ...
|
|
39
|
+
|
|
40
|
+
async def call_tool(self, name: str, arguments: Mapping[str, object] | None = None) -> JsonObject: ...
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
class McpToolClient:
|
|
44
|
+
"""按需启动 MCP stdio 服务,并在生命周期内复用同一个会话。"""
|
|
45
|
+
|
|
46
|
+
def __init__(
|
|
47
|
+
self,
|
|
48
|
+
*,
|
|
49
|
+
command: str,
|
|
50
|
+
entrypoint: Path,
|
|
51
|
+
timeout_seconds: float,
|
|
52
|
+
env: Mapping[str, str] | None = None,
|
|
53
|
+
) -> None:
|
|
54
|
+
if timeout_seconds <= 0:
|
|
55
|
+
raise ValueError("timeout_seconds 必须大于 0")
|
|
56
|
+
self._command = command
|
|
57
|
+
self._entrypoint = entrypoint
|
|
58
|
+
self._timeout_seconds = timeout_seconds
|
|
59
|
+
self._env = dict(env or {})
|
|
60
|
+
self._session: object | None = None
|
|
61
|
+
self._runner_task: asyncio.Task[None] | None = None
|
|
62
|
+
self._ready: asyncio.Future[None] | None = None
|
|
63
|
+
self._close_requested: asyncio.Event | None = None
|
|
64
|
+
self._terminal_error: McpConnectionError | McpTimeoutError | None = None
|
|
65
|
+
self._start_lock = asyncio.Lock()
|
|
66
|
+
|
|
67
|
+
async def start(self) -> None:
|
|
68
|
+
"""启动会话并等待 MCP initialize 完成。"""
|
|
69
|
+
if self._session is not None:
|
|
70
|
+
return
|
|
71
|
+
async with self._start_lock:
|
|
72
|
+
if self._session is not None:
|
|
73
|
+
return
|
|
74
|
+
if not await asyncio.to_thread(self._entrypoint.is_file):
|
|
75
|
+
raise McpConnectionError(f"MCP 入口不存在:{self._entrypoint}")
|
|
76
|
+
if self._runner_task is None or self._runner_task.done():
|
|
77
|
+
loop = asyncio.get_running_loop()
|
|
78
|
+
self._ready = loop.create_future()
|
|
79
|
+
self._close_requested = asyncio.Event()
|
|
80
|
+
self._terminal_error = None
|
|
81
|
+
self._runner_task = asyncio.create_task(
|
|
82
|
+
self._run_session(self._ready, self._close_requested),
|
|
83
|
+
name=f"mcp-session:{self._entrypoint.name}",
|
|
84
|
+
)
|
|
85
|
+
ready = self._ready
|
|
86
|
+
if ready is None:
|
|
87
|
+
raise McpConnectionError("MCP 会话启动状态无效")
|
|
88
|
+
await asyncio.shield(ready)
|
|
89
|
+
|
|
90
|
+
async def list_tools(self) -> list[str]:
|
|
91
|
+
"""列出 MCP 服务公开的工具名称。"""
|
|
92
|
+
session = await self._require_session()
|
|
93
|
+
try:
|
|
94
|
+
async with asyncio.timeout(self._timeout_seconds):
|
|
95
|
+
result = await session.list_tools() # type: ignore[attr-defined]
|
|
96
|
+
except TimeoutError as exc:
|
|
97
|
+
raise McpTimeoutError("MCP tools/list 超时") from exc
|
|
98
|
+
except Exception as exc:
|
|
99
|
+
raise McpToolError(f"MCP tools/list 调用失败:{exc}") from exc
|
|
100
|
+
return [str(tool.name) for tool in result.tools]
|
|
101
|
+
|
|
102
|
+
async def call_tool(self, name: str, arguments: Mapping[str, object] | None = None) -> JsonObject:
|
|
103
|
+
"""调用 MCP 工具并要求其返回结构化对象。"""
|
|
104
|
+
session = await self._require_session()
|
|
105
|
+
try:
|
|
106
|
+
async with asyncio.timeout(self._timeout_seconds):
|
|
107
|
+
result = await session.call_tool(name, dict(arguments or {})) # type: ignore[attr-defined]
|
|
108
|
+
except TimeoutError as exc:
|
|
109
|
+
raise McpTimeoutError(f"MCP 工具 {name} 调用超时") from exc
|
|
110
|
+
except Exception as exc:
|
|
111
|
+
raise McpToolError(f"MCP 工具 {name} 调用失败:{exc}") from exc
|
|
112
|
+
if result.isError:
|
|
113
|
+
message = self._text_content(result.content) or "服务返回 isError=true"
|
|
114
|
+
raise McpToolError(f"MCP 工具 {name} 失败:{message}")
|
|
115
|
+
structured = result.structuredContent
|
|
116
|
+
if isinstance(structured, dict):
|
|
117
|
+
return cast(JsonObject, structured)
|
|
118
|
+
text = self._text_content(result.content)
|
|
119
|
+
if not text:
|
|
120
|
+
raise McpToolError(f"MCP 工具 {name} 未返回可读取的内容")
|
|
121
|
+
try:
|
|
122
|
+
decoded = json.loads(text)
|
|
123
|
+
except json.JSONDecodeError:
|
|
124
|
+
return {"content": text}
|
|
125
|
+
if isinstance(decoded, dict):
|
|
126
|
+
return cast(JsonObject, decoded)
|
|
127
|
+
return {"content": decoded}
|
|
128
|
+
|
|
129
|
+
async def aclose(self) -> None:
|
|
130
|
+
"""关闭 MCP 会话及其 stdio 子进程。"""
|
|
131
|
+
async with self._start_lock:
|
|
132
|
+
task = self._runner_task
|
|
133
|
+
close_requested = self._close_requested
|
|
134
|
+
self._runner_task = None
|
|
135
|
+
self._ready = None
|
|
136
|
+
self._close_requested = None
|
|
137
|
+
if task is None:
|
|
138
|
+
return
|
|
139
|
+
if close_requested is not None:
|
|
140
|
+
close_requested.set()
|
|
141
|
+
await asyncio.shield(task)
|
|
142
|
+
|
|
143
|
+
async def __aenter__(self) -> McpToolClient:
|
|
144
|
+
await self.start()
|
|
145
|
+
return self
|
|
146
|
+
|
|
147
|
+
async def __aexit__(self, exc_type: object, exc: object, tb: object) -> None:
|
|
148
|
+
await self.aclose()
|
|
149
|
+
|
|
150
|
+
async def _require_session(self) -> object:
|
|
151
|
+
await self.start()
|
|
152
|
+
if self._session is not None:
|
|
153
|
+
return self._session
|
|
154
|
+
if self._terminal_error is not None:
|
|
155
|
+
raise self._terminal_error
|
|
156
|
+
raise McpConnectionError("MCP 会话初始化失败")
|
|
157
|
+
|
|
158
|
+
async def _run_session(self, ready: asyncio.Future[None], close_requested: asyncio.Event) -> None:
|
|
159
|
+
"""在同一协程内持有 MCP 的异步上下文,保证会话生命周期完整。"""
|
|
160
|
+
stack = AsyncExitStack()
|
|
161
|
+
try:
|
|
162
|
+
try:
|
|
163
|
+
from mcp import ClientSession, StdioServerParameters
|
|
164
|
+
from mcp.client.stdio import stdio_client
|
|
165
|
+
except ImportError as exc:
|
|
166
|
+
raise McpConnectionError("MCP stdio 适配器需要安装 agent-core[mcp]") from exc
|
|
167
|
+
params = StdioServerParameters(
|
|
168
|
+
command=self._command,
|
|
169
|
+
args=[str(self._entrypoint)],
|
|
170
|
+
# 只继承启动子进程必需的系统变量,避免把模型密钥等无关秘密透传给 MCP。
|
|
171
|
+
env={
|
|
172
|
+
**{key: value for key, value in os.environ.items() if key.upper() in _INHERITED_ENV_KEYS},
|
|
173
|
+
**self._env,
|
|
174
|
+
},
|
|
175
|
+
)
|
|
176
|
+
async with asyncio.timeout(self._timeout_seconds):
|
|
177
|
+
read, write = await stack.enter_async_context(stdio_client(params))
|
|
178
|
+
session = await stack.enter_async_context(ClientSession(read, write))
|
|
179
|
+
await session.initialize()
|
|
180
|
+
self._session = session
|
|
181
|
+
ready.set_result(None)
|
|
182
|
+
await close_requested.wait()
|
|
183
|
+
except TimeoutError:
|
|
184
|
+
timeout_error = McpTimeoutError(f"MCP 初始化超时:{self._entrypoint.name}")
|
|
185
|
+
self._terminal_error = timeout_error
|
|
186
|
+
if not ready.done():
|
|
187
|
+
ready.set_exception(timeout_error)
|
|
188
|
+
except BaseException as exc:
|
|
189
|
+
connection_error = (
|
|
190
|
+
exc
|
|
191
|
+
if isinstance(exc, McpConnectionError)
|
|
192
|
+
else McpConnectionError(f"无法启动或维持 MCP 服务:{self._entrypoint}({exc})")
|
|
193
|
+
)
|
|
194
|
+
self._terminal_error = connection_error
|
|
195
|
+
if not ready.done():
|
|
196
|
+
ready.set_exception(connection_error)
|
|
197
|
+
finally:
|
|
198
|
+
self._session = None
|
|
199
|
+
try:
|
|
200
|
+
await stack.aclose()
|
|
201
|
+
except BaseException as exc:
|
|
202
|
+
self._terminal_error = McpConnectionError(f"关闭 MCP 服务失败:{self._entrypoint}({exc})")
|
|
203
|
+
|
|
204
|
+
@staticmethod
|
|
205
|
+
def _text_content(content: object) -> str | None:
|
|
206
|
+
if not isinstance(content, list):
|
|
207
|
+
return None
|
|
208
|
+
texts = [text for item in content if isinstance((text := getattr(item, "text", None)), str)]
|
|
209
|
+
return "\n".join(texts) if texts else None
|
|
210
|
+
|
|
211
|
+
|
|
212
|
+
__all__ = ["JsonObject", "McpToolCaller", "McpToolClient"]
|
agent_core/mcp/errors.py
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""MCP 通用错误类型。
|
|
2
|
+
|
|
3
|
+
这些错误描述连接、超时和工具调用语义,与 Cesium、地图或任何具体业务无关。
|
|
4
|
+
具体集成可以在此基础上继续定义领域错误。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from agent_core.errors import AgentError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class McpError(AgentError):
|
|
11
|
+
"""MCP 基础错误。"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class McpConnectionError(McpError):
|
|
15
|
+
"""MCP 连接或会话初始化失败。"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class McpTimeoutError(McpError):
|
|
19
|
+
"""MCP 初始化、列举工具或调用工具超时。"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class McpToolError(McpError):
|
|
23
|
+
"""MCP 服务返回工具执行失败或无效结果。"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
__all__ = ["McpConnectionError", "McpError", "McpTimeoutError", "McpToolError"]
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
"""Agent 中间件。"""
|
|
2
|
+
|
|
3
|
+
# 兼容旧导入路径;能力实现分别归属 hitl 和 compaction 领域模块。
|
|
4
|
+
from agent_core.compaction.token_limit import TokenLimitMiddleware
|
|
5
|
+
from agent_core.hitl.core import HumanInTheLoopMiddleware
|
|
6
|
+
from agent_core.middleware.base import (
|
|
7
|
+
LoggingMiddleware,
|
|
8
|
+
Middleware,
|
|
9
|
+
MiddlewareAction,
|
|
10
|
+
MiddlewareContext,
|
|
11
|
+
MiddlewareManager,
|
|
12
|
+
MiddlewareResult,
|
|
13
|
+
RetryMiddleware,
|
|
14
|
+
)
|
|
15
|
+
|
|
16
|
+
__all__ = [
|
|
17
|
+
"HumanInTheLoopMiddleware",
|
|
18
|
+
"LoggingMiddleware",
|
|
19
|
+
"Middleware",
|
|
20
|
+
"MiddlewareAction",
|
|
21
|
+
"MiddlewareContext",
|
|
22
|
+
"MiddlewareManager",
|
|
23
|
+
"MiddlewareResult",
|
|
24
|
+
"RetryMiddleware",
|
|
25
|
+
"TokenLimitMiddleware",
|
|
26
|
+
]
|
|
@@ -0,0 +1,283 @@
|
|
|
1
|
+
"""中间件机制实现。
|
|
2
|
+
|
|
3
|
+
中间件可以在以下时机注入逻辑:
|
|
4
|
+
1. before_model : LLM 调用前
|
|
5
|
+
2. after_model : LLM 调用后
|
|
6
|
+
3. before_tool : 工具执行前
|
|
7
|
+
4. after_tool : 工具执行后
|
|
8
|
+
|
|
9
|
+
用法:
|
|
10
|
+
agent = ReActAgent(
|
|
11
|
+
llm=llm,
|
|
12
|
+
tool_executor=executor,
|
|
13
|
+
middleware=[
|
|
14
|
+
LoggingMiddleware(),
|
|
15
|
+
RetryMiddleware(max_retries=3),
|
|
16
|
+
TokenLimitMiddleware(max_tokens=4000),
|
|
17
|
+
HumanInTheLoopMiddleware(approval_needed=["takeoff_drone"]),
|
|
18
|
+
],
|
|
19
|
+
)
|
|
20
|
+
"""
|
|
21
|
+
|
|
22
|
+
from __future__ import annotations
|
|
23
|
+
|
|
24
|
+
import asyncio
|
|
25
|
+
import logging
|
|
26
|
+
from collections.abc import Callable
|
|
27
|
+
from dataclasses import dataclass, field
|
|
28
|
+
from enum import Enum
|
|
29
|
+
from typing import Any
|
|
30
|
+
|
|
31
|
+
from agent_core.errors import (
|
|
32
|
+
ModelRateLimitError,
|
|
33
|
+
ModelTimeoutError,
|
|
34
|
+
ModelUnavailableError,
|
|
35
|
+
)
|
|
36
|
+
from agent_core.protocol.messages import Message
|
|
37
|
+
|
|
38
|
+
logger = logging.getLogger(__name__)
|
|
39
|
+
|
|
40
|
+
|
|
41
|
+
# ──────────────────────────────────────────────
|
|
42
|
+
# 基础数据类型
|
|
43
|
+
# ──────────────────────────────────────────────
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MiddlewareAction(Enum):
|
|
47
|
+
CONTINUE = "continue" # 继续执行
|
|
48
|
+
STOP = "stop" # 停止执行(中止整个循环)
|
|
49
|
+
MODIFY = "modify" # 修改数据(如裁剪消息历史)
|
|
50
|
+
RETRY = "retry" # 重试当前 LLM 调用
|
|
51
|
+
ERROR = "error" # 返回错误
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@dataclass
|
|
55
|
+
class MiddlewareResult:
|
|
56
|
+
action: MiddlewareAction
|
|
57
|
+
data: dict[str, Any] = field(default_factory=dict)
|
|
58
|
+
error: str | None = None
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
@dataclass
|
|
62
|
+
class MiddlewareContext:
|
|
63
|
+
messages: list[Message] = field(default_factory=list)
|
|
64
|
+
iteration: int = 0
|
|
65
|
+
tool_name: str | None = None
|
|
66
|
+
tool_args: dict[str, Any] = field(default_factory=dict)
|
|
67
|
+
tool_result: str | None = None
|
|
68
|
+
llm_response: Message | None = None
|
|
69
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
70
|
+
# 运行时事件上报入口。它让通用中间件可以记录压缩、重试等事件,
|
|
71
|
+
# 同时保持对独立单元测试和旧 Vanilla 调用方的兼容。
|
|
72
|
+
emit: Callable[..., Any] | None = None
|
|
73
|
+
|
|
74
|
+
|
|
75
|
+
# ──────────────────────────────────────────────
|
|
76
|
+
# 基类
|
|
77
|
+
# ──────────────────────────────────────────────
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class Middleware:
|
|
81
|
+
"""中间件基类,子类按需重写钩子方法。"""
|
|
82
|
+
|
|
83
|
+
@property
|
|
84
|
+
def name(self) -> str:
|
|
85
|
+
return self.__class__.__name__
|
|
86
|
+
|
|
87
|
+
async def before_model(self, context: MiddlewareContext) -> MiddlewareResult:
|
|
88
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
89
|
+
|
|
90
|
+
async def after_model(self, context: MiddlewareContext) -> MiddlewareResult:
|
|
91
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
92
|
+
|
|
93
|
+
async def before_tool(self, context: MiddlewareContext) -> MiddlewareResult:
|
|
94
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
95
|
+
|
|
96
|
+
async def after_tool(self, context: MiddlewareContext) -> MiddlewareResult:
|
|
97
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
98
|
+
|
|
99
|
+
|
|
100
|
+
# ──────────────────────────────────────────────
|
|
101
|
+
# 中间件管理器
|
|
102
|
+
# ──────────────────────────────────────────────
|
|
103
|
+
|
|
104
|
+
|
|
105
|
+
class MiddlewareManager:
|
|
106
|
+
def __init__(self, middlewares: list[Middleware] | None = None) -> None:
|
|
107
|
+
self._middlewares = middlewares or []
|
|
108
|
+
|
|
109
|
+
def add(self, middleware: Middleware) -> None:
|
|
110
|
+
self._middlewares.append(middleware)
|
|
111
|
+
|
|
112
|
+
async def execute_before_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
113
|
+
return await self._run_chain("before_model", ctx)
|
|
114
|
+
|
|
115
|
+
async def execute_after_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
116
|
+
return await self._run_chain("after_model", ctx)
|
|
117
|
+
|
|
118
|
+
async def execute_before_tool(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
119
|
+
return await self._run_chain("before_tool", ctx)
|
|
120
|
+
|
|
121
|
+
async def execute_after_tool(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
122
|
+
return await self._run_chain("after_tool", ctx)
|
|
123
|
+
|
|
124
|
+
async def handle_exception(self, exc: Exception, ctx: MiddlewareContext) -> bool:
|
|
125
|
+
"""让支持异常重试的中间件决定是否再次调用模型。"""
|
|
126
|
+
for mw in self._middlewares:
|
|
127
|
+
handler = getattr(mw, "handle_exception", None)
|
|
128
|
+
if handler is None:
|
|
129
|
+
continue
|
|
130
|
+
if await handler(exc, ctx):
|
|
131
|
+
return True
|
|
132
|
+
return False
|
|
133
|
+
|
|
134
|
+
async def _run_chain(self, hook: str, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
135
|
+
modified_data: dict[str, Any] = {}
|
|
136
|
+
for mw in self._middlewares:
|
|
137
|
+
logger.debug("中间件 %s.%s", mw.name, hook)
|
|
138
|
+
previous_messages = ctx.messages
|
|
139
|
+
result: MiddlewareResult = await getattr(mw, hook)(ctx)
|
|
140
|
+
if result.action == MiddlewareAction.MODIFY:
|
|
141
|
+
# MODIFY 有两种合法写法:替换 ctx 字段(推荐),或在 data 中携带修改。
|
|
142
|
+
# 此处对账:本轮替换了 ctx.messages 时以 ctx 为准,防止 data 携带的
|
|
143
|
+
# 同名旧值把 ctx 的修改覆盖掉(Agent Loop 只读取聚合后的 data)。
|
|
144
|
+
if result.data:
|
|
145
|
+
modified_data.update(result.data)
|
|
146
|
+
if ctx.messages is not previous_messages:
|
|
147
|
+
modified_data["messages"] = ctx.messages
|
|
148
|
+
continue
|
|
149
|
+
if result.action != MiddlewareAction.CONTINUE:
|
|
150
|
+
logger.info("中间件 %s 阻断: action=%s", mw.name, result.action)
|
|
151
|
+
return result
|
|
152
|
+
if modified_data:
|
|
153
|
+
return MiddlewareResult(action=MiddlewareAction.MODIFY, data=modified_data)
|
|
154
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
155
|
+
|
|
156
|
+
|
|
157
|
+
# ──────────────────────────────────────────────
|
|
158
|
+
# 内置中间件 1:日志
|
|
159
|
+
# ──────────────────────────────────────────────
|
|
160
|
+
|
|
161
|
+
|
|
162
|
+
class LoggingMiddleware(Middleware):
|
|
163
|
+
"""记录所有 LLM / 工具调用的日志。"""
|
|
164
|
+
|
|
165
|
+
async def before_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
166
|
+
logger.info("[Log] before_model: iter=%d, msgs=%d", ctx.iteration, len(ctx.messages))
|
|
167
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
168
|
+
|
|
169
|
+
async def after_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
170
|
+
r = ctx.llm_response
|
|
171
|
+
logger.info(
|
|
172
|
+
"[Log] after_model: content_len=%d, tool_calls=%d",
|
|
173
|
+
len(r.content) if r else 0,
|
|
174
|
+
len(r.tool_calls) if r else 0,
|
|
175
|
+
)
|
|
176
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
177
|
+
|
|
178
|
+
async def before_tool(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
179
|
+
logger.info("[Log] before_tool: %s args=%s", ctx.tool_name, ctx.tool_args)
|
|
180
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
181
|
+
|
|
182
|
+
async def after_tool(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
183
|
+
logger.info(
|
|
184
|
+
"[Log] after_tool: %s result_len=%d",
|
|
185
|
+
ctx.tool_name,
|
|
186
|
+
len(ctx.tool_result) if ctx.tool_result else 0,
|
|
187
|
+
)
|
|
188
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
189
|
+
|
|
190
|
+
|
|
191
|
+
# ──────────────────────────────────────────────
|
|
192
|
+
# 内置中间件 2:重试(生产级,支持指数退避)
|
|
193
|
+
# ──────────────────────────────────────────────
|
|
194
|
+
|
|
195
|
+
|
|
196
|
+
class RetryMiddleware(Middleware):
|
|
197
|
+
"""自动重试失败的 LLM 调用。
|
|
198
|
+
|
|
199
|
+
处理场景:
|
|
200
|
+
- 空响应
|
|
201
|
+
- 模型适配器统一转换后的限流、超时和服务不可用错误
|
|
202
|
+
|
|
203
|
+
退避策略:wait = base_delay * (backoff_factor ** attempt)
|
|
204
|
+
"""
|
|
205
|
+
|
|
206
|
+
def __init__(
|
|
207
|
+
self,
|
|
208
|
+
max_retries: int = 3,
|
|
209
|
+
base_delay: float = 1.0,
|
|
210
|
+
backoff_factor: float = 2.0,
|
|
211
|
+
max_delay: float = 60.0,
|
|
212
|
+
) -> None:
|
|
213
|
+
self._max_retries = max_retries
|
|
214
|
+
self._base_delay = base_delay
|
|
215
|
+
self._backoff_factor = backoff_factor
|
|
216
|
+
self._max_delay = max_delay
|
|
217
|
+
|
|
218
|
+
def _should_retry(self, exc: Exception) -> bool:
|
|
219
|
+
"""判断异常是否值得重试。"""
|
|
220
|
+
return isinstance(
|
|
221
|
+
exc,
|
|
222
|
+
(ModelRateLimitError, ModelTimeoutError, ModelUnavailableError),
|
|
223
|
+
) or bool(getattr(exc, "retryable", False))
|
|
224
|
+
|
|
225
|
+
async def after_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
226
|
+
r = ctx.llm_response
|
|
227
|
+
retry_count = ctx.metadata.get("retry_count", 0)
|
|
228
|
+
|
|
229
|
+
if retry_count >= self._max_retries:
|
|
230
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
231
|
+
|
|
232
|
+
# 空响应触发重试
|
|
233
|
+
if r is not None and not r.content and not r.tool_calls:
|
|
234
|
+
wait = min(
|
|
235
|
+
self._base_delay * (self._backoff_factor**retry_count),
|
|
236
|
+
self._max_delay,
|
|
237
|
+
)
|
|
238
|
+
logger.warning(
|
|
239
|
+
"[Retry] 空响应,第 %d/%d 次重试,等待 %.1fs",
|
|
240
|
+
retry_count + 1,
|
|
241
|
+
self._max_retries,
|
|
242
|
+
wait,
|
|
243
|
+
)
|
|
244
|
+
await asyncio.sleep(wait)
|
|
245
|
+
ctx.metadata["retry_count"] = retry_count + 1
|
|
246
|
+
return MiddlewareResult(
|
|
247
|
+
action=MiddlewareAction.RETRY,
|
|
248
|
+
data={"retry_count": retry_count + 1},
|
|
249
|
+
)
|
|
250
|
+
|
|
251
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
252
|
+
|
|
253
|
+
async def handle_exception(
|
|
254
|
+
self,
|
|
255
|
+
exc: Exception,
|
|
256
|
+
ctx: MiddlewareContext,
|
|
257
|
+
) -> bool:
|
|
258
|
+
"""外部调用:判断异常是否应重试,并执行等待。
|
|
259
|
+
|
|
260
|
+
Returns:
|
|
261
|
+
True 表示调用方应重试,False 表示直接抛出。
|
|
262
|
+
"""
|
|
263
|
+
if not self._should_retry(exc):
|
|
264
|
+
return False
|
|
265
|
+
|
|
266
|
+
retry_count = ctx.metadata.get("retry_count", 0)
|
|
267
|
+
if retry_count >= self._max_retries:
|
|
268
|
+
return False
|
|
269
|
+
|
|
270
|
+
wait = min(
|
|
271
|
+
self._base_delay * (self._backoff_factor**retry_count),
|
|
272
|
+
self._max_delay,
|
|
273
|
+
)
|
|
274
|
+
logger.warning(
|
|
275
|
+
"[Retry] %s,第 %d/%d 次重试,等待 %.1fs",
|
|
276
|
+
type(exc).__name__,
|
|
277
|
+
retry_count + 1,
|
|
278
|
+
self._max_retries,
|
|
279
|
+
wait,
|
|
280
|
+
)
|
|
281
|
+
await asyncio.sleep(wait)
|
|
282
|
+
ctx.metadata["retry_count"] = retry_count + 1
|
|
283
|
+
return True
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""模型协议和模型工厂公共导出。"""
|
|
2
|
+
|
|
3
|
+
from agent_core.model.catalog import (
|
|
4
|
+
ModelOption,
|
|
5
|
+
catalog_payload,
|
|
6
|
+
default_model_catalog,
|
|
7
|
+
provider_display_name,
|
|
8
|
+
)
|
|
9
|
+
from agent_core.model.core import (
|
|
10
|
+
ContextUsage,
|
|
11
|
+
ModelAdapter,
|
|
12
|
+
ModelProviderFactory,
|
|
13
|
+
ModelProviderRegistry,
|
|
14
|
+
StreamChunk,
|
|
15
|
+
build_tool_definition,
|
|
16
|
+
create_model_provider,
|
|
17
|
+
default_model_provider_registry,
|
|
18
|
+
register_model_provider,
|
|
19
|
+
)
|
|
20
|
+
from agent_core.model.governance import (
|
|
21
|
+
GovernedModelAdapter,
|
|
22
|
+
ModelExecutionPolicy,
|
|
23
|
+
SlidingWindowRateLimiter,
|
|
24
|
+
model_execution_scope,
|
|
25
|
+
)
|
|
26
|
+
from agent_core.model.providers import (
|
|
27
|
+
MODEL_PROVIDER_REGISTRY,
|
|
28
|
+
AnthropicProvider,
|
|
29
|
+
GeminiProvider,
|
|
30
|
+
OllamaProvider,
|
|
31
|
+
OpenAIProvider,
|
|
32
|
+
create_llm_provider,
|
|
33
|
+
known_context_window,
|
|
34
|
+
)
|
|
35
|
+
from agent_core.model.structured import (
|
|
36
|
+
StructuredOutputResult,
|
|
37
|
+
StructuredOutputSpec,
|
|
38
|
+
chat_structured,
|
|
39
|
+
parse_structured_output,
|
|
40
|
+
validate_json_schema,
|
|
41
|
+
)
|
|
42
|
+
|
|
43
|
+
__all__ = [
|
|
44
|
+
"MODEL_PROVIDER_REGISTRY",
|
|
45
|
+
"AnthropicProvider",
|
|
46
|
+
"ContextUsage",
|
|
47
|
+
"GeminiProvider",
|
|
48
|
+
"GovernedModelAdapter",
|
|
49
|
+
"ModelAdapter",
|
|
50
|
+
"ModelExecutionPolicy",
|
|
51
|
+
"ModelOption",
|
|
52
|
+
"ModelProviderFactory",
|
|
53
|
+
"ModelProviderRegistry",
|
|
54
|
+
"OllamaProvider",
|
|
55
|
+
"OpenAIProvider",
|
|
56
|
+
"SlidingWindowRateLimiter",
|
|
57
|
+
"StreamChunk",
|
|
58
|
+
"StructuredOutputResult",
|
|
59
|
+
"StructuredOutputSpec",
|
|
60
|
+
"build_tool_definition",
|
|
61
|
+
"catalog_payload",
|
|
62
|
+
"chat_structured",
|
|
63
|
+
"create_llm_provider",
|
|
64
|
+
"create_model_provider",
|
|
65
|
+
"default_model_catalog",
|
|
66
|
+
"default_model_provider_registry",
|
|
67
|
+
"known_context_window",
|
|
68
|
+
"model_execution_scope",
|
|
69
|
+
"parse_structured_output",
|
|
70
|
+
"provider_display_name",
|
|
71
|
+
"register_model_provider",
|
|
72
|
+
"validate_json_schema",
|
|
73
|
+
]
|
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""模型目录和运行时选择协议。
|
|
2
|
+
|
|
3
|
+
目录只包含浏览器可以看到的模型标识和能力,不包含 API Key、地址或其他密钥。
|
|
4
|
+
具体应用可以传入自己的目录,模型工厂仍由 ``model.core`` 负责创建适配器。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
from dataclasses import dataclass, field
|
|
10
|
+
from typing import Any
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
@dataclass(frozen=True)
|
|
14
|
+
class ModelOption:
|
|
15
|
+
"""一个可供用户选择的模型。"""
|
|
16
|
+
|
|
17
|
+
provider: str
|
|
18
|
+
model: str
|
|
19
|
+
label: str
|
|
20
|
+
context_window_tokens: int | None = None
|
|
21
|
+
capabilities: frozenset[str] = field(
|
|
22
|
+
default_factory=lambda: frozenset({"chat", "stream", "tool_calling"})
|
|
23
|
+
)
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"provider": self.provider,
|
|
28
|
+
"model": self.model,
|
|
29
|
+
"label": self.label,
|
|
30
|
+
"context_window_tokens": self.context_window_tokens,
|
|
31
|
+
"capabilities": sorted(self.capabilities),
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
|
|
35
|
+
def default_model_catalog() -> tuple[ModelOption, ...]:
|
|
36
|
+
"""返回不依赖第三方 SDK 的安全默认目录。"""
|
|
37
|
+
return (
|
|
38
|
+
ModelOption("openai", "gpt-4o-mini", "OpenAI · GPT-4o mini", 128_000),
|
|
39
|
+
ModelOption("openai", "gpt-4.1-mini", "OpenAI · GPT-4.1 mini", 1_047_576),
|
|
40
|
+
ModelOption("anthropic", "claude-3-5-sonnet-latest", "Anthropic · Claude Sonnet"),
|
|
41
|
+
ModelOption("gemini", "gemini-2.0-flash", "Gemini · 2.0 Flash"),
|
|
42
|
+
ModelOption("ollama", "qwen2.5:7b", "Ollama · Qwen 2.5 7B"),
|
|
43
|
+
)
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
#: provider 内部标识(小写)到 UI 显示名的映射。
|
|
47
|
+
PROVIDER_DISPLAY_NAMES: dict[str, str] = {
|
|
48
|
+
"openai": "OpenAI",
|
|
49
|
+
"anthropic": "Anthropic",
|
|
50
|
+
"gemini": "Gemini",
|
|
51
|
+
"ollama": "Ollama",
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
|
|
55
|
+
def provider_display_name(provider: str) -> str:
|
|
56
|
+
"""把 provider 标识转成展示名;未知 provider 原样返回(不强行改写)。"""
|
|
57
|
+
return PROVIDER_DISPLAY_NAMES.get(provider.strip().lower(), provider)
|
|
58
|
+
|
|
59
|
+
|
|
60
|
+
def catalog_payload(
|
|
61
|
+
options: tuple[ModelOption, ...] | list[ModelOption] | None = None,
|
|
62
|
+
) -> list[dict[str, Any]]:
|
|
63
|
+
"""把模型目录转换为 API 可返回的 JSON。"""
|
|
64
|
+
return [item.to_dict() for item in (options or default_model_catalog())]
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
__all__ = [
|
|
68
|
+
"PROVIDER_DISPLAY_NAMES",
|
|
69
|
+
"ModelOption",
|
|
70
|
+
"catalog_payload",
|
|
71
|
+
"default_model_catalog",
|
|
72
|
+
"provider_display_name",
|
|
73
|
+
]
|