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,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"]