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,200 @@
|
|
|
1
|
+
"""提供商无关的结构化输出约束、解析和校验。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import json
|
|
6
|
+
import re
|
|
7
|
+
from collections.abc import Callable
|
|
8
|
+
from dataclasses import dataclass
|
|
9
|
+
from typing import Any, cast
|
|
10
|
+
|
|
11
|
+
from agent_core.errors import ModelOutputValidationError
|
|
12
|
+
from agent_core.model.core import ModelAdapter
|
|
13
|
+
from agent_core.protocol.messages import Message, system_message, user_message
|
|
14
|
+
|
|
15
|
+
_JSON_FENCE = re.compile(r"^```(?:json)?\s*(.*?)\s*```$", re.DOTALL | re.IGNORECASE)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
@dataclass(frozen=True)
|
|
19
|
+
class StructuredOutputSpec[T]:
|
|
20
|
+
"""一次结构化输出调用的 Schema 和解析规则。"""
|
|
21
|
+
|
|
22
|
+
name: str
|
|
23
|
+
schema: dict[str, Any]
|
|
24
|
+
decoder: Callable[[Any], T] | None = None
|
|
25
|
+
max_retries: int = 1
|
|
26
|
+
instruction: str | None = None
|
|
27
|
+
|
|
28
|
+
def __post_init__(self) -> None:
|
|
29
|
+
if not self.name.strip():
|
|
30
|
+
raise ValueError("结构化输出名称不能为空")
|
|
31
|
+
if self.max_retries < 0:
|
|
32
|
+
raise ValueError("max_retries 不能小于 0")
|
|
33
|
+
if not isinstance(self.schema, dict):
|
|
34
|
+
raise TypeError("schema 必须是 JSON Schema 字典")
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
@dataclass(frozen=True)
|
|
38
|
+
class StructuredOutputResult[T]:
|
|
39
|
+
"""结构化输出结果及其原始模型消息。"""
|
|
40
|
+
|
|
41
|
+
value: T
|
|
42
|
+
raw_message: Message
|
|
43
|
+
attempts: int
|
|
44
|
+
|
|
45
|
+
|
|
46
|
+
def _extract_json(content: str) -> Any:
|
|
47
|
+
text = content.strip()
|
|
48
|
+
fenced = _JSON_FENCE.match(text)
|
|
49
|
+
if fenced:
|
|
50
|
+
text = fenced.group(1).strip()
|
|
51
|
+
try:
|
|
52
|
+
return json.loads(text)
|
|
53
|
+
except json.JSONDecodeError as exc:
|
|
54
|
+
raise ModelOutputValidationError(
|
|
55
|
+
f"模型输出不是有效 JSON:第 {exc.lineno} 行第 {exc.colno} 列,{exc.msg}"
|
|
56
|
+
) from exc
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
def _matches_type(value: Any, expected: str) -> bool:
|
|
60
|
+
if expected == "object":
|
|
61
|
+
return isinstance(value, dict)
|
|
62
|
+
if expected == "array":
|
|
63
|
+
return isinstance(value, list)
|
|
64
|
+
if expected == "string":
|
|
65
|
+
return isinstance(value, str)
|
|
66
|
+
if expected == "integer":
|
|
67
|
+
return isinstance(value, int) and not isinstance(value, bool)
|
|
68
|
+
if expected == "number":
|
|
69
|
+
return isinstance(value, int | float) and not isinstance(value, bool)
|
|
70
|
+
if expected == "boolean":
|
|
71
|
+
return isinstance(value, bool)
|
|
72
|
+
if expected == "null":
|
|
73
|
+
return value is None
|
|
74
|
+
raise ModelOutputValidationError(f"暂不支持的 JSON Schema type:{expected}")
|
|
75
|
+
|
|
76
|
+
|
|
77
|
+
def validate_json_schema(value: Any, schema: dict[str, Any], *, path: str = "$") -> None:
|
|
78
|
+
"""校验 Core 支持的 JSON Schema 子集。
|
|
79
|
+
|
|
80
|
+
支持 type、required、properties、additionalProperties、items、enum、
|
|
81
|
+
minLength、maxLength、minimum 和 maximum。复杂组合规则可由 decoder 做二次校验。
|
|
82
|
+
"""
|
|
83
|
+
|
|
84
|
+
expected = schema.get("type")
|
|
85
|
+
if isinstance(expected, list):
|
|
86
|
+
if not any(_matches_type(value, item) for item in expected):
|
|
87
|
+
raise ModelOutputValidationError(f"{path} 类型不匹配,期望 {expected}")
|
|
88
|
+
elif isinstance(expected, str) and not _matches_type(value, expected):
|
|
89
|
+
raise ModelOutputValidationError(f"{path} 类型不匹配,期望 {expected}")
|
|
90
|
+
|
|
91
|
+
if "enum" in schema and value not in schema["enum"]:
|
|
92
|
+
raise ModelOutputValidationError(f"{path} 不在允许值 {schema['enum']} 中")
|
|
93
|
+
|
|
94
|
+
if isinstance(value, dict):
|
|
95
|
+
required = schema.get("required", [])
|
|
96
|
+
missing = [name for name in required if name not in value]
|
|
97
|
+
if missing:
|
|
98
|
+
raise ModelOutputValidationError(f"{path} 缺少必填字段:{', '.join(missing)}")
|
|
99
|
+
properties = schema.get("properties", {})
|
|
100
|
+
for name, item in value.items():
|
|
101
|
+
child_schema = properties.get(name)
|
|
102
|
+
if child_schema is not None:
|
|
103
|
+
validate_json_schema(item, child_schema, path=f"{path}.{name}")
|
|
104
|
+
elif schema.get("additionalProperties") is False:
|
|
105
|
+
raise ModelOutputValidationError(f"{path} 包含未声明字段:{name}")
|
|
106
|
+
|
|
107
|
+
if isinstance(value, list) and isinstance(schema.get("items"), dict):
|
|
108
|
+
for index, item in enumerate(value):
|
|
109
|
+
validate_json_schema(item, schema["items"], path=f"{path}[{index}]")
|
|
110
|
+
|
|
111
|
+
if isinstance(value, str):
|
|
112
|
+
if "minLength" in schema and len(value) < int(schema["minLength"]):
|
|
113
|
+
raise ModelOutputValidationError(f"{path} 长度小于 {schema['minLength']}")
|
|
114
|
+
if "maxLength" in schema and len(value) > int(schema["maxLength"]):
|
|
115
|
+
raise ModelOutputValidationError(f"{path} 长度大于 {schema['maxLength']}")
|
|
116
|
+
|
|
117
|
+
if isinstance(value, int | float) and not isinstance(value, bool):
|
|
118
|
+
if "minimum" in schema and value < schema["minimum"]:
|
|
119
|
+
raise ModelOutputValidationError(f"{path} 小于最小值 {schema['minimum']}")
|
|
120
|
+
if "maximum" in schema and value > schema["maximum"]:
|
|
121
|
+
raise ModelOutputValidationError(f"{path} 大于最大值 {schema['maximum']}")
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def parse_structured_output[T](message: Message, spec: StructuredOutputSpec[T]) -> T:
|
|
125
|
+
"""解析并校验一个模型消息。"""
|
|
126
|
+
|
|
127
|
+
if message.tool_calls:
|
|
128
|
+
raise ModelOutputValidationError("结构化输出阶段不允许返回工具调用")
|
|
129
|
+
value = _extract_json(message.content)
|
|
130
|
+
validate_json_schema(value, spec.schema)
|
|
131
|
+
if spec.decoder is None:
|
|
132
|
+
return cast(T, value)
|
|
133
|
+
try:
|
|
134
|
+
return spec.decoder(value)
|
|
135
|
+
except ModelOutputValidationError:
|
|
136
|
+
raise
|
|
137
|
+
except Exception as exc:
|
|
138
|
+
raise ModelOutputValidationError(f"结构化输出 decoder 校验失败:{exc}") from exc
|
|
139
|
+
|
|
140
|
+
|
|
141
|
+
def _instruction(spec: StructuredOutputSpec[Any]) -> str:
|
|
142
|
+
if spec.instruction:
|
|
143
|
+
return spec.instruction
|
|
144
|
+
schema = json.dumps(spec.schema, ensure_ascii=False, separators=(",", ":"))
|
|
145
|
+
return (
|
|
146
|
+
f"请只输出符合 JSON Schema 的 JSON 对象,不要使用 Markdown 代码块。"
|
|
147
|
+
f"输出名称:{spec.name}。JSON Schema:{schema}"
|
|
148
|
+
)
|
|
149
|
+
|
|
150
|
+
|
|
151
|
+
async def chat_structured[T](
|
|
152
|
+
model: ModelAdapter,
|
|
153
|
+
messages: list[Message],
|
|
154
|
+
spec: StructuredOutputSpec[T],
|
|
155
|
+
*,
|
|
156
|
+
tools: list[dict[str, Any]] | None = None,
|
|
157
|
+
temperature: float | None = None,
|
|
158
|
+
max_tokens: int | None = None,
|
|
159
|
+
) -> StructuredOutputResult[T]:
|
|
160
|
+
"""调用模型并在解析失败时携带错误信息进行有限重试。"""
|
|
161
|
+
|
|
162
|
+
# 合并为一条 system 消息,避免 Anthropic 等单 system Provider 丢失应用原提示词。
|
|
163
|
+
system_parts = [message.content for message in messages if message.role == "system"]
|
|
164
|
+
system_parts.append(_instruction(spec))
|
|
165
|
+
working = [
|
|
166
|
+
system_message("\n\n".join(part for part in system_parts if part)),
|
|
167
|
+
*(message for message in messages if message.role != "system"),
|
|
168
|
+
]
|
|
169
|
+
last_error: ModelOutputValidationError | None = None
|
|
170
|
+
for attempt in range(1, spec.max_retries + 2):
|
|
171
|
+
kwargs: dict[str, Any] = {"tools": tools}
|
|
172
|
+
if temperature is not None:
|
|
173
|
+
kwargs["temperature"] = temperature
|
|
174
|
+
if max_tokens is not None:
|
|
175
|
+
kwargs["max_tokens"] = max_tokens
|
|
176
|
+
raw = await model.chat(working, **kwargs)
|
|
177
|
+
try:
|
|
178
|
+
value = parse_structured_output(raw, spec)
|
|
179
|
+
return StructuredOutputResult(value=value, raw_message=raw, attempts=attempt)
|
|
180
|
+
except ModelOutputValidationError as exc:
|
|
181
|
+
last_error = exc
|
|
182
|
+
if attempt > spec.max_retries:
|
|
183
|
+
break
|
|
184
|
+
working.extend(
|
|
185
|
+
[
|
|
186
|
+
raw,
|
|
187
|
+
user_message(f"上一次输出校验失败:{exc}。请重新输出完整且合法的 JSON。"),
|
|
188
|
+
]
|
|
189
|
+
)
|
|
190
|
+
assert last_error is not None
|
|
191
|
+
raise last_error
|
|
192
|
+
|
|
193
|
+
|
|
194
|
+
__all__ = [
|
|
195
|
+
"StructuredOutputResult",
|
|
196
|
+
"StructuredOutputSpec",
|
|
197
|
+
"chat_structured",
|
|
198
|
+
"parse_structured_output",
|
|
199
|
+
"validate_json_schema",
|
|
200
|
+
]
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
"""可观测性公共导出。"""
|
|
2
|
+
|
|
3
|
+
from agent_core.observability.core import (
|
|
4
|
+
AgentStats,
|
|
5
|
+
CallRecord,
|
|
6
|
+
ObservabilityMiddleware,
|
|
7
|
+
ThreadStats,
|
|
8
|
+
ToolRecord,
|
|
9
|
+
agent_stats,
|
|
10
|
+
setup_tracing,
|
|
11
|
+
)
|
|
12
|
+
|
|
13
|
+
__all__ = [
|
|
14
|
+
"AgentStats",
|
|
15
|
+
"CallRecord",
|
|
16
|
+
"ObservabilityMiddleware",
|
|
17
|
+
"ThreadStats",
|
|
18
|
+
"ToolRecord",
|
|
19
|
+
"agent_stats",
|
|
20
|
+
"setup_tracing",
|
|
21
|
+
]
|
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
"""零依赖可观测性中间件和进程内统计。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import logging
|
|
6
|
+
import time
|
|
7
|
+
from contextvars import ContextVar
|
|
8
|
+
from dataclasses import dataclass, field
|
|
9
|
+
from typing import Any
|
|
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
|
+
@dataclass
|
|
22
|
+
class CallRecord:
|
|
23
|
+
"""单次模型调用记录。"""
|
|
24
|
+
|
|
25
|
+
timestamp: float
|
|
26
|
+
iteration: int
|
|
27
|
+
latency_ms: float
|
|
28
|
+
input_messages: int
|
|
29
|
+
tool_calls: int
|
|
30
|
+
finish_reason: str = "unknown"
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
@dataclass
|
|
34
|
+
class ToolRecord:
|
|
35
|
+
"""单次工具调用记录。"""
|
|
36
|
+
|
|
37
|
+
timestamp: float
|
|
38
|
+
tool_name: str
|
|
39
|
+
latency_ms: float
|
|
40
|
+
success: bool = True
|
|
41
|
+
|
|
42
|
+
|
|
43
|
+
@dataclass
|
|
44
|
+
class ThreadStats:
|
|
45
|
+
"""单个会话的累计统计。"""
|
|
46
|
+
|
|
47
|
+
thread_id: str
|
|
48
|
+
total_llm_calls: int = 0
|
|
49
|
+
total_tool_calls: int = 0
|
|
50
|
+
total_latency_ms: float = 0.0
|
|
51
|
+
llm_records: list[CallRecord] = field(default_factory=list)
|
|
52
|
+
tool_records: list[ToolRecord] = field(default_factory=list)
|
|
53
|
+
|
|
54
|
+
@property
|
|
55
|
+
def avg_latency_ms(self) -> float:
|
|
56
|
+
return self.total_latency_ms / len(self.llm_records) if self.llm_records else 0.0
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AgentStats:
|
|
60
|
+
"""进程级统计注册表;生产环境可通过事件 sink 转发到外部系统。"""
|
|
61
|
+
|
|
62
|
+
def __init__(self, *, max_threads: int = 1000, max_records_per_thread: int = 1000) -> None:
|
|
63
|
+
if max_threads <= 0 or max_records_per_thread <= 0:
|
|
64
|
+
raise ValueError("统计容量必须大于 0")
|
|
65
|
+
self._stats: dict[str, ThreadStats] = {}
|
|
66
|
+
self._max_threads = max_threads
|
|
67
|
+
self._max_records_per_thread = max_records_per_thread
|
|
68
|
+
|
|
69
|
+
def get_stats(self, thread_id: str) -> ThreadStats:
|
|
70
|
+
if thread_id not in self._stats:
|
|
71
|
+
if len(self._stats) >= self._max_threads:
|
|
72
|
+
oldest_thread = next(iter(self._stats))
|
|
73
|
+
del self._stats[oldest_thread]
|
|
74
|
+
self._stats[thread_id] = ThreadStats(thread_id=thread_id)
|
|
75
|
+
else:
|
|
76
|
+
# dict 保持插入顺序,用重新插入实现轻量 LRU。
|
|
77
|
+
self._stats[thread_id] = self._stats.pop(thread_id)
|
|
78
|
+
return self._stats[thread_id]
|
|
79
|
+
|
|
80
|
+
def record_llm_call(
|
|
81
|
+
self,
|
|
82
|
+
thread_id: str,
|
|
83
|
+
iteration: int,
|
|
84
|
+
latency_ms: float,
|
|
85
|
+
input_messages: int,
|
|
86
|
+
tool_calls: int,
|
|
87
|
+
finish_reason: str = "unknown",
|
|
88
|
+
) -> None:
|
|
89
|
+
stats = self.get_stats(thread_id)
|
|
90
|
+
stats.total_llm_calls += 1
|
|
91
|
+
stats.total_latency_ms += latency_ms
|
|
92
|
+
stats.llm_records.append(
|
|
93
|
+
CallRecord(
|
|
94
|
+
timestamp=time.time(),
|
|
95
|
+
iteration=iteration,
|
|
96
|
+
latency_ms=latency_ms,
|
|
97
|
+
input_messages=input_messages,
|
|
98
|
+
tool_calls=tool_calls,
|
|
99
|
+
finish_reason=finish_reason,
|
|
100
|
+
)
|
|
101
|
+
)
|
|
102
|
+
if len(stats.llm_records) > self._max_records_per_thread:
|
|
103
|
+
del stats.llm_records[: len(stats.llm_records) - self._max_records_per_thread]
|
|
104
|
+
|
|
105
|
+
def record_tool_call(
|
|
106
|
+
self,
|
|
107
|
+
thread_id: str,
|
|
108
|
+
tool_name: str,
|
|
109
|
+
latency_ms: float,
|
|
110
|
+
success: bool = True,
|
|
111
|
+
) -> None:
|
|
112
|
+
stats = self.get_stats(thread_id)
|
|
113
|
+
stats.total_tool_calls += 1
|
|
114
|
+
stats.tool_records.append(
|
|
115
|
+
ToolRecord(
|
|
116
|
+
timestamp=time.time(),
|
|
117
|
+
tool_name=tool_name,
|
|
118
|
+
latency_ms=latency_ms,
|
|
119
|
+
success=success,
|
|
120
|
+
)
|
|
121
|
+
)
|
|
122
|
+
if len(stats.tool_records) > self._max_records_per_thread:
|
|
123
|
+
del stats.tool_records[: len(stats.tool_records) - self._max_records_per_thread]
|
|
124
|
+
|
|
125
|
+
def all_threads(self) -> list[str]:
|
|
126
|
+
return list(self._stats)
|
|
127
|
+
|
|
128
|
+
def summary(self) -> dict[str, Any]:
|
|
129
|
+
return {
|
|
130
|
+
thread_id: {
|
|
131
|
+
"llm_calls": stats.total_llm_calls,
|
|
132
|
+
"tool_calls": stats.total_tool_calls,
|
|
133
|
+
"avg_latency_ms": round(stats.avg_latency_ms, 1),
|
|
134
|
+
"total_latency_ms": round(stats.total_latency_ms, 1),
|
|
135
|
+
}
|
|
136
|
+
for thread_id, stats in self._stats.items()
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
|
|
140
|
+
agent_stats = AgentStats()
|
|
141
|
+
|
|
142
|
+
|
|
143
|
+
class ObservabilityMiddleware(Middleware):
|
|
144
|
+
"""记录模型和工具调用耗时,并可选地创建 OpenTelemetry span。"""
|
|
145
|
+
|
|
146
|
+
def __init__(
|
|
147
|
+
self,
|
|
148
|
+
thread_id: str = "default",
|
|
149
|
+
log_level: int = logging.INFO,
|
|
150
|
+
enable_otel: bool = False,
|
|
151
|
+
) -> None:
|
|
152
|
+
self._thread_id = thread_id
|
|
153
|
+
self._log_level = log_level
|
|
154
|
+
# 同一个 Agent 可被多个会话并发复用,计时状态必须按异步任务隔离。
|
|
155
|
+
self._model_state: ContextVar[tuple[float, Any | None]] = ContextVar(
|
|
156
|
+
f"agent_core_observability_model_{id(self)}",
|
|
157
|
+
default=(0.0, None),
|
|
158
|
+
)
|
|
159
|
+
self._tool_start: ContextVar[float] = ContextVar(
|
|
160
|
+
f"agent_core_observability_tool_{id(self)}",
|
|
161
|
+
default=0.0,
|
|
162
|
+
)
|
|
163
|
+
self._tracer = _get_tracer() if enable_otel else None
|
|
164
|
+
|
|
165
|
+
def _context_thread_id(self, ctx: MiddlewareContext) -> str:
|
|
166
|
+
return str(ctx.metadata.get("thread_id") or self._thread_id)
|
|
167
|
+
|
|
168
|
+
async def before_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
169
|
+
call_start = time.perf_counter()
|
|
170
|
+
span = None
|
|
171
|
+
if self._tracer:
|
|
172
|
+
span = self._tracer.start_span(f"llm.call.iter{ctx.iteration}")
|
|
173
|
+
span.set_attribute("iteration", ctx.iteration)
|
|
174
|
+
span.set_attribute("message_count", len(ctx.messages))
|
|
175
|
+
self._model_state.set((call_start, span))
|
|
176
|
+
thread_id = self._context_thread_id(ctx)
|
|
177
|
+
logger.log(
|
|
178
|
+
self._log_level,
|
|
179
|
+
"[Obs] LLM call start: thread=%s iter=%d msgs=%d",
|
|
180
|
+
thread_id,
|
|
181
|
+
ctx.iteration,
|
|
182
|
+
len(ctx.messages),
|
|
183
|
+
)
|
|
184
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
185
|
+
|
|
186
|
+
async def after_model(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
187
|
+
call_start, span = self._model_state.get()
|
|
188
|
+
latency_ms = (time.perf_counter() - call_start) * 1000 if call_start else 0.0
|
|
189
|
+
response = ctx.llm_response
|
|
190
|
+
tool_calls = len(response.tool_calls) if response else 0
|
|
191
|
+
thread_id = self._context_thread_id(ctx)
|
|
192
|
+
agent_stats.record_llm_call(
|
|
193
|
+
thread_id=thread_id,
|
|
194
|
+
iteration=ctx.iteration,
|
|
195
|
+
latency_ms=latency_ms,
|
|
196
|
+
input_messages=len(ctx.messages),
|
|
197
|
+
tool_calls=tool_calls,
|
|
198
|
+
)
|
|
199
|
+
logger.log(
|
|
200
|
+
self._log_level,
|
|
201
|
+
"[Obs] LLM call done: thread=%s iter=%d latency=%.0fms content=%d tool_calls=%d",
|
|
202
|
+
thread_id,
|
|
203
|
+
ctx.iteration,
|
|
204
|
+
latency_ms,
|
|
205
|
+
len(response.content) if response else 0,
|
|
206
|
+
tool_calls,
|
|
207
|
+
)
|
|
208
|
+
if span:
|
|
209
|
+
span.set_attribute("latency_ms", latency_ms)
|
|
210
|
+
span.set_attribute("tool_calls", tool_calls)
|
|
211
|
+
span.end()
|
|
212
|
+
self._model_state.set((0.0, None))
|
|
213
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
214
|
+
|
|
215
|
+
async def handle_exception(self, exc: Exception, ctx: MiddlewareContext) -> bool:
|
|
216
|
+
"""关闭失败模型调用的 span;观测中间件本身不决定是否重试。"""
|
|
217
|
+
del ctx
|
|
218
|
+
call_start, span = self._model_state.get()
|
|
219
|
+
if span is not None:
|
|
220
|
+
record_exception = getattr(span, "record_exception", None)
|
|
221
|
+
if record_exception is not None:
|
|
222
|
+
record_exception(exc)
|
|
223
|
+
if call_start:
|
|
224
|
+
span.set_attribute("latency_ms", (time.perf_counter() - call_start) * 1000)
|
|
225
|
+
span.end()
|
|
226
|
+
self._model_state.set((0.0, None))
|
|
227
|
+
return False
|
|
228
|
+
|
|
229
|
+
async def before_tool(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
230
|
+
self._tool_start.set(time.perf_counter())
|
|
231
|
+
logger.log(self._log_level, "[Obs] Tool call start: %s args=%s", ctx.tool_name, ctx.tool_args)
|
|
232
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
233
|
+
|
|
234
|
+
async def after_tool(self, ctx: MiddlewareContext) -> MiddlewareResult:
|
|
235
|
+
tool_start = self._tool_start.get()
|
|
236
|
+
latency_ms = (time.perf_counter() - tool_start) * 1000 if tool_start else 0.0
|
|
237
|
+
agent_stats.record_tool_call(
|
|
238
|
+
thread_id=self._context_thread_id(ctx),
|
|
239
|
+
tool_name=ctx.tool_name or "unknown",
|
|
240
|
+
latency_ms=latency_ms,
|
|
241
|
+
success=bool(ctx.metadata.get("tool_success", True)),
|
|
242
|
+
)
|
|
243
|
+
logger.log(
|
|
244
|
+
self._log_level,
|
|
245
|
+
"[Obs] Tool call done: %s latency=%.0fms result_len=%d",
|
|
246
|
+
ctx.tool_name,
|
|
247
|
+
latency_ms,
|
|
248
|
+
len(ctx.tool_result) if ctx.tool_result else 0,
|
|
249
|
+
)
|
|
250
|
+
self._tool_start.set(0.0)
|
|
251
|
+
return MiddlewareResult(action=MiddlewareAction.CONTINUE)
|
|
252
|
+
|
|
253
|
+
|
|
254
|
+
def _get_tracer() -> Any:
|
|
255
|
+
"""按需获取 OTel tracer,未安装依赖时返回 None。"""
|
|
256
|
+
try:
|
|
257
|
+
from opentelemetry import trace
|
|
258
|
+
|
|
259
|
+
return trace.get_tracer("zero-agent-core")
|
|
260
|
+
except ImportError:
|
|
261
|
+
logger.warning("opentelemetry-sdk 未安装,OTel 追踪已禁用")
|
|
262
|
+
return None
|
|
263
|
+
|
|
264
|
+
|
|
265
|
+
def setup_tracing(
|
|
266
|
+
service_name: str = "handwritten-agent",
|
|
267
|
+
otlp_endpoint: str | None = None,
|
|
268
|
+
console_export: bool = False,
|
|
269
|
+
) -> bool:
|
|
270
|
+
"""配置可选的 OpenTelemetry 导出器。"""
|
|
271
|
+
try:
|
|
272
|
+
from opentelemetry import trace
|
|
273
|
+
from opentelemetry.sdk.resources import Resource
|
|
274
|
+
from opentelemetry.sdk.trace import TracerProvider
|
|
275
|
+
from opentelemetry.sdk.trace.export import (
|
|
276
|
+
BatchSpanProcessor,
|
|
277
|
+
SimpleSpanProcessor,
|
|
278
|
+
)
|
|
279
|
+
except ImportError:
|
|
280
|
+
logger.warning("opentelemetry-sdk 未安装,跳过 tracing 配置")
|
|
281
|
+
return False
|
|
282
|
+
|
|
283
|
+
provider = TracerProvider(resource=Resource.create({"service.name": service_name}))
|
|
284
|
+
if console_export:
|
|
285
|
+
from opentelemetry.sdk.trace.export import ConsoleSpanExporter
|
|
286
|
+
|
|
287
|
+
provider.add_span_processor(SimpleSpanProcessor(ConsoleSpanExporter()))
|
|
288
|
+
if otlp_endpoint:
|
|
289
|
+
try:
|
|
290
|
+
from opentelemetry.exporter.otlp.proto.grpc.trace_exporter import (
|
|
291
|
+
OTLPSpanExporter,
|
|
292
|
+
)
|
|
293
|
+
|
|
294
|
+
provider.add_span_processor(
|
|
295
|
+
BatchSpanProcessor(OTLPSpanExporter(endpoint=otlp_endpoint, insecure=True))
|
|
296
|
+
)
|
|
297
|
+
except ImportError:
|
|
298
|
+
logger.warning("OTLP 导出依赖未安装,跳过远端 tracing")
|
|
299
|
+
trace.set_tracer_provider(provider)
|
|
300
|
+
return True
|
|
301
|
+
|
|
302
|
+
|
|
303
|
+
__all__ = [
|
|
304
|
+
"AgentStats",
|
|
305
|
+
"CallRecord",
|
|
306
|
+
"ObservabilityMiddleware",
|
|
307
|
+
"ThreadStats",
|
|
308
|
+
"ToolRecord",
|
|
309
|
+
"agent_stats",
|
|
310
|
+
"setup_tracing",
|
|
311
|
+
]
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
"""Agent Core 的可替换外部端口。"""
|
|
2
|
+
|
|
3
|
+
from agent_core.ports.memory import MemoryRecord, MemorySearchResult, MemoryStore
|
|
4
|
+
from agent_core.ports.model_selection import (
|
|
5
|
+
ModelSelection,
|
|
6
|
+
ModelSelectionScope,
|
|
7
|
+
ModelSelectionStore,
|
|
8
|
+
)
|
|
9
|
+
from agent_core.ports.storage import (
|
|
10
|
+
ApprovalStore,
|
|
11
|
+
ContextStore,
|
|
12
|
+
EventSink,
|
|
13
|
+
RunStore,
|
|
14
|
+
SessionStore,
|
|
15
|
+
WorkflowStore,
|
|
16
|
+
)
|
|
17
|
+
|
|
18
|
+
__all__ = [
|
|
19
|
+
"ApprovalStore",
|
|
20
|
+
"ContextStore",
|
|
21
|
+
"EventSink",
|
|
22
|
+
"MemoryRecord",
|
|
23
|
+
"MemorySearchResult",
|
|
24
|
+
"MemoryStore",
|
|
25
|
+
"ModelSelection",
|
|
26
|
+
"ModelSelectionScope",
|
|
27
|
+
"ModelSelectionStore",
|
|
28
|
+
"RunStore",
|
|
29
|
+
"SessionStore",
|
|
30
|
+
"WorkflowStore",
|
|
31
|
+
]
|
|
@@ -0,0 +1,130 @@
|
|
|
1
|
+
"""跨会话长期记忆的值对象和存储端口。"""
|
|
2
|
+
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import uuid
|
|
6
|
+
from dataclasses import dataclass, field
|
|
7
|
+
from datetime import UTC, datetime
|
|
8
|
+
from typing import Any, Protocol, runtime_checkable
|
|
9
|
+
|
|
10
|
+
from agent_core.access import AccessContext
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def memory_now() -> str:
|
|
14
|
+
"""返回可排序的 UTC ISO 时间。"""
|
|
15
|
+
|
|
16
|
+
return datetime.now(UTC).isoformat()
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
@dataclass
|
|
20
|
+
class MemoryRecord:
|
|
21
|
+
"""一条可跨会话检索的长期记忆。"""
|
|
22
|
+
|
|
23
|
+
namespace: str
|
|
24
|
+
content: str
|
|
25
|
+
memory_id: str = field(default_factory=lambda: str(uuid.uuid4()))
|
|
26
|
+
key: str | None = None
|
|
27
|
+
user_id: str | None = None
|
|
28
|
+
tenant_id: str | None = None
|
|
29
|
+
source: str | None = None
|
|
30
|
+
metadata: dict[str, Any] = field(default_factory=dict)
|
|
31
|
+
expires_at: str | None = None
|
|
32
|
+
version: int = 0
|
|
33
|
+
created_at: str = field(default_factory=memory_now)
|
|
34
|
+
updated_at: str = field(default_factory=memory_now)
|
|
35
|
+
|
|
36
|
+
def __post_init__(self) -> None:
|
|
37
|
+
if not self.namespace.strip():
|
|
38
|
+
raise ValueError("记忆 namespace 不能为空")
|
|
39
|
+
if not self.memory_id.strip():
|
|
40
|
+
raise ValueError("memory_id 不能为空")
|
|
41
|
+
|
|
42
|
+
def is_expired(self, *, now: datetime | None = None) -> bool:
|
|
43
|
+
if self.expires_at is None:
|
|
44
|
+
return False
|
|
45
|
+
expires = datetime.fromisoformat(self.expires_at.replace("Z", "+00:00"))
|
|
46
|
+
if expires.tzinfo is None:
|
|
47
|
+
expires = expires.replace(tzinfo=UTC)
|
|
48
|
+
return expires <= (now or datetime.now(UTC))
|
|
49
|
+
|
|
50
|
+
def to_dict(self) -> dict[str, Any]:
|
|
51
|
+
return {
|
|
52
|
+
"memory_id": self.memory_id,
|
|
53
|
+
"namespace": self.namespace,
|
|
54
|
+
"key": self.key,
|
|
55
|
+
"content": self.content,
|
|
56
|
+
"user_id": self.user_id,
|
|
57
|
+
"tenant_id": self.tenant_id,
|
|
58
|
+
"source": self.source,
|
|
59
|
+
"metadata": self.metadata,
|
|
60
|
+
"expires_at": self.expires_at,
|
|
61
|
+
"version": self.version,
|
|
62
|
+
"created_at": self.created_at,
|
|
63
|
+
"updated_at": self.updated_at,
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
@classmethod
|
|
67
|
+
def from_dict(cls, value: dict[str, Any]) -> MemoryRecord:
|
|
68
|
+
return cls(
|
|
69
|
+
memory_id=str(value["memory_id"]),
|
|
70
|
+
namespace=str(value["namespace"]),
|
|
71
|
+
key=value.get("key"),
|
|
72
|
+
content=str(value.get("content", "")),
|
|
73
|
+
user_id=value.get("user_id"),
|
|
74
|
+
tenant_id=value.get("tenant_id"),
|
|
75
|
+
source=value.get("source"),
|
|
76
|
+
metadata=dict(value.get("metadata") or {}),
|
|
77
|
+
expires_at=value.get("expires_at"),
|
|
78
|
+
version=int(value.get("version", 0)),
|
|
79
|
+
created_at=str(value.get("created_at") or memory_now()),
|
|
80
|
+
updated_at=str(value.get("updated_at") or memory_now()),
|
|
81
|
+
)
|
|
82
|
+
|
|
83
|
+
|
|
84
|
+
@dataclass(frozen=True)
|
|
85
|
+
class MemorySearchResult:
|
|
86
|
+
"""长期记忆检索结果。"""
|
|
87
|
+
|
|
88
|
+
record: MemoryRecord
|
|
89
|
+
score: float
|
|
90
|
+
|
|
91
|
+
|
|
92
|
+
@runtime_checkable
|
|
93
|
+
class MemoryStore(Protocol):
|
|
94
|
+
"""跨会话记忆存储协议,语义向量适配器也实现该端口。"""
|
|
95
|
+
|
|
96
|
+
async def put(
|
|
97
|
+
self,
|
|
98
|
+
record: MemoryRecord,
|
|
99
|
+
*,
|
|
100
|
+
expected_version: int | None = None,
|
|
101
|
+
access: AccessContext | None = None,
|
|
102
|
+
) -> MemoryRecord: ...
|
|
103
|
+
|
|
104
|
+
async def get(
|
|
105
|
+
self,
|
|
106
|
+
memory_id: str,
|
|
107
|
+
*,
|
|
108
|
+
access: AccessContext | None = None,
|
|
109
|
+
) -> MemoryRecord | None: ...
|
|
110
|
+
|
|
111
|
+
async def delete(
|
|
112
|
+
self,
|
|
113
|
+
memory_id: str,
|
|
114
|
+
*,
|
|
115
|
+
expected_version: int | None = None,
|
|
116
|
+
access: AccessContext | None = None,
|
|
117
|
+
) -> bool: ...
|
|
118
|
+
|
|
119
|
+
async def search(
|
|
120
|
+
self,
|
|
121
|
+
namespace: str,
|
|
122
|
+
*,
|
|
123
|
+
query: str = "",
|
|
124
|
+
metadata: dict[str, Any] | None = None,
|
|
125
|
+
limit: int = 20,
|
|
126
|
+
access: AccessContext | None = None,
|
|
127
|
+
) -> list[MemorySearchResult]: ...
|
|
128
|
+
|
|
129
|
+
|
|
130
|
+
__all__ = ["MemoryRecord", "MemorySearchResult", "MemoryStore", "memory_now"]
|