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,94 @@
|
|
|
1
|
+
"""文档切分协议和轻量递归字符切分器。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from typing import Protocol, runtime_checkable
|
|
7
|
+
|
|
8
|
+
from agent_core.documents.types import Document, DocumentChunk, DocumentLocator
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
@runtime_checkable
|
|
12
|
+
class TextSplitter(Protocol):
|
|
13
|
+
"""文本和文档切分协议。"""
|
|
14
|
+
|
|
15
|
+
def split_text(self, text: str) -> list[str]: ...
|
|
16
|
+
|
|
17
|
+
def split_documents(self, documents: list[Document]) -> list[DocumentChunk]: ...
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
class RecursiveCharacterTextSplitter:
|
|
21
|
+
"""按段落、换行和标点优先寻找边界的零依赖切分器。"""
|
|
22
|
+
|
|
23
|
+
def __init__(
|
|
24
|
+
self,
|
|
25
|
+
*,
|
|
26
|
+
chunk_size: int = 1000,
|
|
27
|
+
chunk_overlap: int = 100,
|
|
28
|
+
separators: tuple[str, ...] = ("\n\n", "\n", "。", ";", ". ", " "),
|
|
29
|
+
) -> None:
|
|
30
|
+
if chunk_size <= 0:
|
|
31
|
+
raise ValueError("chunk_size 必须大于 0")
|
|
32
|
+
if chunk_overlap < 0 or chunk_overlap >= chunk_size:
|
|
33
|
+
raise ValueError("chunk_overlap 必须大于等于 0 且小于 chunk_size")
|
|
34
|
+
self._chunk_size = chunk_size
|
|
35
|
+
self._chunk_overlap = chunk_overlap
|
|
36
|
+
self._separators = tuple(separator for separator in separators if separator)
|
|
37
|
+
|
|
38
|
+
def split_text(self, text: str) -> list[str]:
|
|
39
|
+
return [text[start:end] for start, end in self._split_offsets(text)]
|
|
40
|
+
|
|
41
|
+
def split_documents(self, documents: list[Document]) -> list[DocumentChunk]:
|
|
42
|
+
chunks: list[DocumentChunk] = []
|
|
43
|
+
for document in documents:
|
|
44
|
+
base_offset = document.locator.start_offset if document.locator else 0
|
|
45
|
+
for chunk_index, (start, end) in enumerate(self._split_offsets(document.content)):
|
|
46
|
+
locator = DocumentLocator(
|
|
47
|
+
uri=document.locator.uri if document.locator else document.source,
|
|
48
|
+
page=document.locator.page if document.locator else None,
|
|
49
|
+
block_id=document.locator.block_id if document.locator else None,
|
|
50
|
+
start_offset=(base_offset or 0) + start,
|
|
51
|
+
end_offset=(base_offset or 0) + end,
|
|
52
|
+
)
|
|
53
|
+
chunks.append(
|
|
54
|
+
DocumentChunk(
|
|
55
|
+
document_id=str(
|
|
56
|
+
uuid.uuid5(
|
|
57
|
+
uuid.NAMESPACE_URL,
|
|
58
|
+
f"{document.document_id}:{chunk_index}:{start}:{end}",
|
|
59
|
+
)
|
|
60
|
+
),
|
|
61
|
+
content=document.content[start:end],
|
|
62
|
+
parent_document_id=document.document_id,
|
|
63
|
+
chunk_index=chunk_index,
|
|
64
|
+
source=document.source,
|
|
65
|
+
mime_type=document.mime_type,
|
|
66
|
+
metadata=dict(document.metadata),
|
|
67
|
+
locator=locator,
|
|
68
|
+
)
|
|
69
|
+
)
|
|
70
|
+
return chunks
|
|
71
|
+
|
|
72
|
+
def _split_offsets(self, text: str) -> list[tuple[int, int]]:
|
|
73
|
+
if not text:
|
|
74
|
+
return []
|
|
75
|
+
offsets: list[tuple[int, int]] = []
|
|
76
|
+
start = 0
|
|
77
|
+
length = len(text)
|
|
78
|
+
while start < length:
|
|
79
|
+
hard_end = min(start + self._chunk_size, length)
|
|
80
|
+
end = hard_end
|
|
81
|
+
if hard_end < length:
|
|
82
|
+
minimum = start + max(1, self._chunk_size // 2)
|
|
83
|
+
for separator in self._separators:
|
|
84
|
+
position = text.rfind(separator, minimum, hard_end)
|
|
85
|
+
if position >= minimum:
|
|
86
|
+
end = position + len(separator)
|
|
87
|
+
break
|
|
88
|
+
if end <= start:
|
|
89
|
+
end = hard_end
|
|
90
|
+
offsets.append((start, end))
|
|
91
|
+
if end >= length:
|
|
92
|
+
break
|
|
93
|
+
start = max(start + 1, end - self._chunk_overlap)
|
|
94
|
+
return offsets
|
|
@@ -0,0 +1,197 @@
|
|
|
1
|
+
"""与模型和存储实现无关的文档值对象。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import hashlib
|
|
6
|
+
import uuid
|
|
7
|
+
from dataclasses import dataclass, field
|
|
8
|
+
from typing import Any
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def _sha256(value: bytes) -> str:
|
|
12
|
+
return hashlib.sha256(value).hexdigest()
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
@dataclass(frozen=True)
|
|
16
|
+
class DocumentLocator:
|
|
17
|
+
"""文档中的稳定位置,用于引用、续读和追踪切分来源。"""
|
|
18
|
+
|
|
19
|
+
uri: str | None = None
|
|
20
|
+
page: int | None = None
|
|
21
|
+
block_id: str | None = None
|
|
22
|
+
start_offset: int | None = None
|
|
23
|
+
end_offset: int | None = None
|
|
24
|
+
|
|
25
|
+
def to_dict(self) -> dict[str, Any]:
|
|
26
|
+
return {
|
|
27
|
+
"uri": self.uri,
|
|
28
|
+
"page": self.page,
|
|
29
|
+
"block_id": self.block_id,
|
|
30
|
+
"start_offset": self.start_offset,
|
|
31
|
+
"end_offset": self.end_offset,
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
@classmethod
|
|
35
|
+
def from_dict(cls, value: dict[str, Any]) -> DocumentLocator:
|
|
36
|
+
return cls(
|
|
37
|
+
uri=value.get("uri"),
|
|
38
|
+
page=int(value["page"]) if value.get("page") is not None else None,
|
|
39
|
+
block_id=value.get("block_id"),
|
|
40
|
+
start_offset=(int(value["start_offset"]) if value.get("start_offset") is not None else None),
|
|
41
|
+
end_offset=int(value["end_offset"]) if value.get("end_offset") is not None else None,
|
|
42
|
+
)
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
@dataclass(frozen=True)
|
|
46
|
+
class Blob:
|
|
47
|
+
"""尚未解析的原始二进制内容。"""
|
|
48
|
+
|
|
49
|
+
data: bytes
|
|
50
|
+
source: str | None = None
|
|
51
|
+
mime_type: str | None = None
|
|
52
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
53
|
+
checksum: str = ""
|
|
54
|
+
|
|
55
|
+
def __post_init__(self) -> None:
|
|
56
|
+
if not self.checksum:
|
|
57
|
+
object.__setattr__(self, "checksum", _sha256(self.data))
|
|
58
|
+
|
|
59
|
+
@classmethod
|
|
60
|
+
def from_text(
|
|
61
|
+
cls,
|
|
62
|
+
text: str,
|
|
63
|
+
*,
|
|
64
|
+
source: str | None = None,
|
|
65
|
+
mime_type: str = "text/plain",
|
|
66
|
+
metadata: dict[str, Any] | None = None,
|
|
67
|
+
encoding: str = "utf-8",
|
|
68
|
+
) -> Blob:
|
|
69
|
+
return cls(
|
|
70
|
+
text.encode(encoding),
|
|
71
|
+
source=source,
|
|
72
|
+
mime_type=mime_type,
|
|
73
|
+
metadata=dict(metadata or {}),
|
|
74
|
+
)
|
|
75
|
+
|
|
76
|
+
def to_dict(self) -> dict[str, Any]:
|
|
77
|
+
"""Blob 的 JSON 编码由 serialization 模块负责,这里保留原始 bytes。"""
|
|
78
|
+
|
|
79
|
+
return {
|
|
80
|
+
"data": self.data,
|
|
81
|
+
"source": self.source,
|
|
82
|
+
"mime_type": self.mime_type,
|
|
83
|
+
"metadata": dict(self.metadata),
|
|
84
|
+
"checksum": self.checksum,
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
@dataclass(frozen=True)
|
|
89
|
+
class Document:
|
|
90
|
+
"""解析后的文本及其来源信息。"""
|
|
91
|
+
|
|
92
|
+
content: str
|
|
93
|
+
document_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
94
|
+
source: str | None = None
|
|
95
|
+
mime_type: str | None = None
|
|
96
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
97
|
+
locator: DocumentLocator | None = None
|
|
98
|
+
checksum: str = ""
|
|
99
|
+
|
|
100
|
+
def __post_init__(self) -> None:
|
|
101
|
+
if not self.document_id.strip():
|
|
102
|
+
raise ValueError("document_id 不能为空")
|
|
103
|
+
if not self.checksum:
|
|
104
|
+
object.__setattr__(self, "checksum", _sha256(self.content.encode("utf-8")))
|
|
105
|
+
|
|
106
|
+
def to_dict(self) -> dict[str, Any]:
|
|
107
|
+
return {
|
|
108
|
+
"document_id": self.document_id,
|
|
109
|
+
"content": self.content,
|
|
110
|
+
"source": self.source,
|
|
111
|
+
"mime_type": self.mime_type,
|
|
112
|
+
"metadata": dict(self.metadata),
|
|
113
|
+
"locator": self.locator.to_dict() if self.locator else None,
|
|
114
|
+
"checksum": self.checksum,
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
@classmethod
|
|
118
|
+
def from_dict(cls, value: dict[str, Any]) -> Document:
|
|
119
|
+
locator = value.get("locator")
|
|
120
|
+
return cls(
|
|
121
|
+
document_id=str(value["document_id"]),
|
|
122
|
+
content=str(value.get("content", "")),
|
|
123
|
+
source=value.get("source"),
|
|
124
|
+
mime_type=value.get("mime_type"),
|
|
125
|
+
metadata=dict(value.get("metadata") or {}),
|
|
126
|
+
locator=DocumentLocator.from_dict(locator) if isinstance(locator, dict) else None,
|
|
127
|
+
checksum=str(value.get("checksum", "")),
|
|
128
|
+
)
|
|
129
|
+
|
|
130
|
+
|
|
131
|
+
@dataclass(frozen=True)
|
|
132
|
+
class DocumentChunk:
|
|
133
|
+
"""可索引的文档片段,保留父文档和字符区间。"""
|
|
134
|
+
|
|
135
|
+
content: str
|
|
136
|
+
parent_document_id: str
|
|
137
|
+
chunk_index: int
|
|
138
|
+
document_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
139
|
+
source: str | None = None
|
|
140
|
+
mime_type: str | None = None
|
|
141
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
142
|
+
locator: DocumentLocator | None = None
|
|
143
|
+
checksum: str = ""
|
|
144
|
+
|
|
145
|
+
def __post_init__(self) -> None:
|
|
146
|
+
if self.chunk_index < 0:
|
|
147
|
+
raise ValueError("chunk_index 不能小于 0")
|
|
148
|
+
if not self.parent_document_id.strip():
|
|
149
|
+
raise ValueError("parent_document_id 不能为空")
|
|
150
|
+
if not self.document_id.strip():
|
|
151
|
+
raise ValueError("document_id 不能为空")
|
|
152
|
+
if not self.checksum:
|
|
153
|
+
object.__setattr__(self, "checksum", _sha256(self.content.encode("utf-8")))
|
|
154
|
+
|
|
155
|
+
def to_document(self) -> Document:
|
|
156
|
+
metadata = {
|
|
157
|
+
**self.metadata,
|
|
158
|
+
"parent_document_id": self.parent_document_id,
|
|
159
|
+
"chunk_index": self.chunk_index,
|
|
160
|
+
}
|
|
161
|
+
return Document(
|
|
162
|
+
document_id=self.document_id,
|
|
163
|
+
content=self.content,
|
|
164
|
+
source=self.source,
|
|
165
|
+
mime_type=self.mime_type,
|
|
166
|
+
metadata=metadata,
|
|
167
|
+
locator=self.locator,
|
|
168
|
+
checksum=self.checksum,
|
|
169
|
+
)
|
|
170
|
+
|
|
171
|
+
def to_dict(self) -> dict[str, Any]:
|
|
172
|
+
return {
|
|
173
|
+
"document_id": self.document_id,
|
|
174
|
+
"content": self.content,
|
|
175
|
+
"parent_document_id": self.parent_document_id,
|
|
176
|
+
"chunk_index": self.chunk_index,
|
|
177
|
+
"source": self.source,
|
|
178
|
+
"mime_type": self.mime_type,
|
|
179
|
+
"metadata": dict(self.metadata),
|
|
180
|
+
"locator": self.locator.to_dict() if self.locator else None,
|
|
181
|
+
"checksum": self.checksum,
|
|
182
|
+
}
|
|
183
|
+
|
|
184
|
+
@classmethod
|
|
185
|
+
def from_dict(cls, value: dict[str, Any]) -> DocumentChunk:
|
|
186
|
+
locator = value.get("locator")
|
|
187
|
+
return cls(
|
|
188
|
+
document_id=str(value["document_id"]),
|
|
189
|
+
content=str(value.get("content", "")),
|
|
190
|
+
parent_document_id=str(value["parent_document_id"]),
|
|
191
|
+
chunk_index=int(value["chunk_index"]),
|
|
192
|
+
source=value.get("source"),
|
|
193
|
+
mime_type=value.get("mime_type"),
|
|
194
|
+
metadata=dict(value.get("metadata") or {}),
|
|
195
|
+
locator=DocumentLocator.from_dict(locator) if isinstance(locator, dict) else None,
|
|
196
|
+
checksum=str(value.get("checksum", "")),
|
|
197
|
+
)
|
agent_core/errors.py
ADDED
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
"""Agent Core 公共错误定义。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
|
|
6
|
+
class AgentError(Exception):
|
|
7
|
+
"""Agent 基础错误。"""
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class ConfigurationError(AgentError):
|
|
11
|
+
"""配置错误。"""
|
|
12
|
+
|
|
13
|
+
|
|
14
|
+
class ModelInvocationError(AgentError):
|
|
15
|
+
"""模型调用错误。"""
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class ModelRateLimitError(ModelInvocationError):
|
|
19
|
+
"""模型服务限流,适合按退避策略重试。"""
|
|
20
|
+
|
|
21
|
+
|
|
22
|
+
class ModelTimeoutError(ModelInvocationError):
|
|
23
|
+
"""模型调用超时,适合有限次数重试。"""
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class ModelUnavailableError(ModelInvocationError):
|
|
27
|
+
"""模型服务暂时不可用,适合按退避策略重试。"""
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
class ModelOutputValidationError(AgentError):
|
|
31
|
+
"""模型输出验证错误。"""
|
|
32
|
+
|
|
33
|
+
|
|
34
|
+
class ToolExecutionError(AgentError):
|
|
35
|
+
"""工具执行错误。"""
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
class StateMachineError(AgentError):
|
|
39
|
+
"""状态机错误。"""
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
class CheckpointError(AgentError):
|
|
43
|
+
"""Checkpoint 持久化错误。"""
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
class MiddlewareError(AgentError):
|
|
47
|
+
"""中间件错误。"""
|
|
48
|
+
|
|
49
|
+
|
|
50
|
+
class DocumentError(AgentError):
|
|
51
|
+
"""文档加载或解析基础错误。"""
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
class DocumentParsingError(DocumentError):
|
|
55
|
+
"""文档内容或解析服务响应无法转换成 Core Document。"""
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
class DocumentServiceError(DocumentError):
|
|
59
|
+
"""外部文档解析服务调用失败。"""
|
|
60
|
+
|
|
61
|
+
|
|
62
|
+
class MemoryConflictError(AgentError):
|
|
63
|
+
"""长期记忆发生乐观并发冲突。"""
|
|
64
|
+
|
|
65
|
+
def __init__(self, memory_id: str, expected_version: int, actual_version: int) -> None:
|
|
66
|
+
super().__init__(f"记忆 {memory_id} 版本冲突:期望 {expected_version},实际 {actual_version}")
|
|
67
|
+
self.memory_id = memory_id
|
|
68
|
+
self.expected_version = expected_version
|
|
69
|
+
self.actual_version = actual_version
|
|
70
|
+
|
|
71
|
+
|
|
72
|
+
class SerializationError(AgentError):
|
|
73
|
+
"""序列化或反序列化失败。"""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class UnknownSerializedTypeError(SerializationError):
|
|
77
|
+
"""序列化注册表中不存在指定类型。"""
|
|
78
|
+
|
|
79
|
+
|
|
80
|
+
class UnsupportedSchemaVersionError(SerializationError):
|
|
81
|
+
"""对象版本高于当前版本,或缺少必要迁移函数。"""
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
"""输入输出护栏公共导出。"""
|
|
2
|
+
|
|
3
|
+
from agent_core.guardrails.core import (
|
|
4
|
+
BlockedKeywordsGuard,
|
|
5
|
+
Guard,
|
|
6
|
+
GuardrailsMiddleware,
|
|
7
|
+
LengthGuard,
|
|
8
|
+
OutputFormatGuard,
|
|
9
|
+
PIIRedactionGuard,
|
|
10
|
+
)
|
|
11
|
+
|
|
12
|
+
__all__ = [
|
|
13
|
+
"BlockedKeywordsGuard",
|
|
14
|
+
"Guard",
|
|
15
|
+
"GuardrailsMiddleware",
|
|
16
|
+
"LengthGuard",
|
|
17
|
+
"OutputFormatGuard",
|
|
18
|
+
"PIIRedactionGuard",
|
|
19
|
+
]
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
"""与业务无关的输入、输出安全校验中间件。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import re
|
|
7
|
+
from abc import ABC, abstractmethod
|
|
8
|
+
from collections.abc import Sequence
|
|
9
|
+
from typing import ClassVar
|
|
10
|
+
|
|
11
|
+
from agent_core.middleware.base import (
|
|
12
|
+
Middleware,
|
|
13
|
+
MiddlewareAction,
|
|
14
|
+
MiddlewareContext,
|
|
15
|
+
MiddlewareResult,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
logger = logging.getLogger(__name__)
|
|
19
|
+
|
|
20
|
+
|
|
21
|
+
class Guard(ABC):
|
|
22
|
+
"""单个文本校验规则。"""
|
|
23
|
+
|
|
24
|
+
@abstractmethod
|
|
25
|
+
def check(self, text: str) -> tuple[bool, str]:
|
|
26
|
+
"""返回 ``(是否通过, 原因)``。"""
|
|
27
|
+
|
|
28
|
+
@property
|
|
29
|
+
def name(self) -> str:
|
|
30
|
+
return self.__class__.__name__
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
class BlockedKeywordsGuard(Guard):
|
|
34
|
+
"""屏蔽指定关键词,默认大小写不敏感。"""
|
|
35
|
+
|
|
36
|
+
def __init__(self, keywords: Sequence[str] = (), case_sensitive: bool = False) -> None:
|
|
37
|
+
flags = 0 if case_sensitive else re.IGNORECASE
|
|
38
|
+
self._keywords = list(keywords)
|
|
39
|
+
self._patterns = [re.compile(re.escape(keyword), flags) for keyword in self._keywords]
|
|
40
|
+
|
|
41
|
+
def check(self, text: str) -> tuple[bool, str]:
|
|
42
|
+
for pattern, keyword in zip(self._patterns, self._keywords, strict=True):
|
|
43
|
+
if pattern.search(text):
|
|
44
|
+
return False, f"输入包含违禁词:{keyword}"
|
|
45
|
+
return True, ""
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
class LengthGuard(Guard):
|
|
49
|
+
"""限制输入或输出字符数。"""
|
|
50
|
+
|
|
51
|
+
def __init__(
|
|
52
|
+
self,
|
|
53
|
+
max_input_chars: int | None = 5000,
|
|
54
|
+
max_output_chars: int | None = None,
|
|
55
|
+
) -> None:
|
|
56
|
+
self._max_input = max_input_chars
|
|
57
|
+
self._max_output = max_output_chars
|
|
58
|
+
|
|
59
|
+
def check(self, text: str) -> tuple[bool, str]:
|
|
60
|
+
limit = self._max_input or self._max_output
|
|
61
|
+
if limit and len(text) > limit:
|
|
62
|
+
return False, f"文本长度 {len(text)} 超出限制 {limit}"
|
|
63
|
+
return True, ""
|
|
64
|
+
|
|
65
|
+
def check_input(self, text: str) -> tuple[bool, str]:
|
|
66
|
+
if self._max_input and len(text) > self._max_input:
|
|
67
|
+
return False, f"输入长度 {len(text)} 超出限制 {self._max_input}"
|
|
68
|
+
return True, ""
|
|
69
|
+
|
|
70
|
+
def check_output(self, text: str) -> tuple[bool, str]:
|
|
71
|
+
if self._max_output and len(text) > self._max_output:
|
|
72
|
+
return False, f"输出长度 {len(text)} 超出限制 {self._max_output}"
|
|
73
|
+
return True, ""
|
|
74
|
+
|
|
75
|
+
|
|
76
|
+
class PIIRedactionGuard(Guard):
|
|
77
|
+
"""检测并脱敏常见手机号、身份证、邮箱和银行卡号。"""
|
|
78
|
+
|
|
79
|
+
_PATTERNS: ClassVar[list[tuple[str, re.Pattern[str], str]]] = [
|
|
80
|
+
("手机号", re.compile(r"\b1[3-9]\d{9}\b"), "***手机号***"),
|
|
81
|
+
("身份证", re.compile(r"\b\d{17}[\dXx]\b"), "***身份证***"),
|
|
82
|
+
("邮箱", re.compile(r"\b[\w.+-]+@[\w-]+\.[a-z]{2,}\b", re.I), "***邮箱***"),
|
|
83
|
+
("银行卡", re.compile(r"\b\d{16,19}\b"), "***银行卡***"),
|
|
84
|
+
]
|
|
85
|
+
|
|
86
|
+
def __init__(self, block_on_detection: bool = False) -> None:
|
|
87
|
+
self._block = block_on_detection
|
|
88
|
+
|
|
89
|
+
def check(self, text: str) -> tuple[bool, str]:
|
|
90
|
+
detected = [name for name, pattern, _ in self._PATTERNS if pattern.search(text)]
|
|
91
|
+
if not detected:
|
|
92
|
+
return True, ""
|
|
93
|
+
if self._block:
|
|
94
|
+
return False, f"输入/输出包含 PII:{', '.join(detected)}"
|
|
95
|
+
logger.warning("[PII] 检测到 PII 类型:%s(已脱敏)", detected)
|
|
96
|
+
return True, f"PII detected: {detected}"
|
|
97
|
+
|
|
98
|
+
def redact(self, text: str) -> str:
|
|
99
|
+
for _, pattern, placeholder in self._PATTERNS:
|
|
100
|
+
text = pattern.sub(placeholder, text)
|
|
101
|
+
return text
|
|
102
|
+
|
|
103
|
+
|
|
104
|
+
class OutputFormatGuard(Guard):
|
|
105
|
+
"""拦截常见脚本、SQL 注入和 JavaScript 协议内容。"""
|
|
106
|
+
|
|
107
|
+
_INJECTION_PATTERNS: ClassVar[list[re.Pattern[str]]] = [
|
|
108
|
+
re.compile(r"(DROP\s+TABLE|DELETE\s+FROM|INSERT\s+INTO)", re.I),
|
|
109
|
+
re.compile(r"(<script[\s>])", re.I),
|
|
110
|
+
re.compile(r"(javascript\s*:)", re.I),
|
|
111
|
+
]
|
|
112
|
+
|
|
113
|
+
def check(self, text: str) -> tuple[bool, str]:
|
|
114
|
+
if any(pattern.search(text) for pattern in self._INJECTION_PATTERNS):
|
|
115
|
+
return False, "输出包含潜在注入内容"
|
|
116
|
+
return True, ""
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
class GuardrailsMiddleware(Middleware):
|
|
120
|
+
"""在模型调用前检查用户输入、调用后检查模型输出。"""
|
|
121
|
+
|
|
122
|
+
def __init__(
|
|
123
|
+
self,
|
|
124
|
+
input_guards: Sequence[Guard] = (),
|
|
125
|
+
output_guards: Sequence[Guard] = (),
|
|
126
|
+
pii_redact: bool = False,
|
|
127
|
+
) -> None:
|
|
128
|
+
self._input_guards = list(input_guards)
|
|
129
|
+
self._output_guards = list(output_guards)
|
|
130
|
+
self._pii_redact = pii_redact
|
|
131
|
+
self._pii_guard = PIIRedactionGuard() if pii_redact else None
|
|
132
|
+
|
|
133
|
+
async def before_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
134
|
+
user_text = next(
|
|
135
|
+
(message.content for message in reversed(ctx.messages) if message.role == "user"),
|
|
136
|
+
"",
|
|
137
|
+
)
|
|
138
|
+
if not user_text:
|
|
139
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
140
|
+
for guard in self._input_guards:
|
|
141
|
+
passed, reason = (
|
|
142
|
+
guard.check_input(user_text) if isinstance(guard, LengthGuard) else guard.check(user_text)
|
|
143
|
+
)
|
|
144
|
+
if not passed:
|
|
145
|
+
logger.warning("[Guard] 输入被拦截 [%s]: %s", guard.name, reason)
|
|
146
|
+
return MiddlewareResult(
|
|
147
|
+
action=MiddlewareAction.STOP,
|
|
148
|
+
data={"guard": guard.name, "reason": reason},
|
|
149
|
+
error=reason,
|
|
150
|
+
)
|
|
151
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
152
|
+
|
|
153
|
+
async def after_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
154
|
+
if ctx.llm_response is None:
|
|
155
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
156
|
+
output_text = ctx.llm_response.content
|
|
157
|
+
if self._pii_redact and self._pii_guard and output_text:
|
|
158
|
+
redacted = self._pii_guard.redact(output_text)
|
|
159
|
+
if redacted != output_text:
|
|
160
|
+
logger.info("[Guard] 输出 PII 已脱敏")
|
|
161
|
+
ctx.llm_response.content = redacted
|
|
162
|
+
output_text = redacted
|
|
163
|
+
for guard in self._output_guards:
|
|
164
|
+
passed, reason = (
|
|
165
|
+
guard.check_output(output_text)
|
|
166
|
+
if isinstance(guard, LengthGuard)
|
|
167
|
+
else guard.check(output_text)
|
|
168
|
+
)
|
|
169
|
+
if not passed:
|
|
170
|
+
logger.warning("[Guard] 输出被拦截 [%s]: %s", guard.name, reason)
|
|
171
|
+
return MiddlewareResult(
|
|
172
|
+
action=MiddlewareAction.STOP,
|
|
173
|
+
data={"guard": guard.name, "reason": reason},
|
|
174
|
+
error=reason,
|
|
175
|
+
)
|
|
176
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
177
|
+
|
|
178
|
+
|
|
179
|
+
__all__ = [
|
|
180
|
+
"BlockedKeywordsGuard",
|
|
181
|
+
"Guard",
|
|
182
|
+
"GuardrailsMiddleware",
|
|
183
|
+
"LengthGuard",
|
|
184
|
+
"OutputFormatGuard",
|
|
185
|
+
"PIIRedactionGuard",
|
|
186
|
+
]
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
"""人机协同审批公共导出。"""
|
|
2
|
+
|
|
3
|
+
from agent_core.hitl.core import (
|
|
4
|
+
ApprovalQueue,
|
|
5
|
+
ApprovalRequest,
|
|
6
|
+
ApprovalStatus,
|
|
7
|
+
HumanInTheLoopMiddleware,
|
|
8
|
+
)
|
|
9
|
+
|
|
10
|
+
__all__ = [
|
|
11
|
+
"ApprovalQueue",
|
|
12
|
+
"ApprovalRequest",
|
|
13
|
+
"ApprovalStatus",
|
|
14
|
+
"HumanInTheLoopMiddleware",
|
|
15
|
+
]
|