zhkj_llm_function 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.
- zhkj_llm_function/__init__.py +78 -0
- zhkj_llm_function/agent.py +285 -0
- zhkj_llm_function/compat.py +63 -0
- zhkj_llm_function/errors.py +29 -0
- zhkj_llm_function/protocol.py +109 -0
- zhkj_llm_function/result.py +82 -0
- zhkj_llm_function/runtime.py +412 -0
- zhkj_llm_function/schema.py +139 -0
- zhkj_llm_function/session.py +95 -0
- zhkj_llm_function/tools.py +130 -0
- zhkj_llm_function-0.2.0.dist-info/METADATA +383 -0
- zhkj_llm_function-0.2.0.dist-info/RECORD +14 -0
- zhkj_llm_function-0.2.0.dist-info/WHEEL +5 -0
- zhkj_llm_function-0.2.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,412 @@
|
|
|
1
|
+
"""运行时:基于 tinyagent 执行 agent,产出框架统一返回结构。
|
|
2
|
+
|
|
3
|
+
引擎:tinyagent-py(ReAct 循环 + 工具调用 + 上下文压缩 + token 统计)。
|
|
4
|
+
框架层职责:
|
|
5
|
+
- @agent_def 声明的入参/出参校验
|
|
6
|
+
- 外部工具注册(参数 schema 由函数签名自动反射)
|
|
7
|
+
- 业务指令 docstring -> system prompt
|
|
8
|
+
- <|tool_call|> 标记协议兜底(tinyagent 原生工具调用之外的兼容路径)
|
|
9
|
+
- 会话 ttl 管控与统一返回结构封装
|
|
10
|
+
|
|
11
|
+
框架返回结构(插件不用管):
|
|
12
|
+
|
|
13
|
+
{
|
|
14
|
+
"ok": bool,
|
|
15
|
+
"data": 输出模型实例,
|
|
16
|
+
"finish_reason": "stop|reset|compact|not_registered|input_schema_invalid|output_schema_invalid|tool_error|error",
|
|
17
|
+
"meta": {
|
|
18
|
+
"agent_name": str,
|
|
19
|
+
"token_original": int,
|
|
20
|
+
"token_compacted": int
|
|
21
|
+
}
|
|
22
|
+
}
|
|
23
|
+
"""
|
|
24
|
+
|
|
25
|
+
import time
|
|
26
|
+
from datetime import datetime
|
|
27
|
+
from typing import Any, Dict, List, Optional
|
|
28
|
+
|
|
29
|
+
from .agent import AgentSpec
|
|
30
|
+
from .compat import messages_for_summary
|
|
31
|
+
from .errors import (
|
|
32
|
+
AgentNotRegisteredError,
|
|
33
|
+
FrameworkError,
|
|
34
|
+
InputSchemaError,
|
|
35
|
+
OutputSchemaError,
|
|
36
|
+
ToolExecutionError,
|
|
37
|
+
ToolNotFoundError,
|
|
38
|
+
)
|
|
39
|
+
from .protocol import extract_json_payload, parse_tool_call
|
|
40
|
+
from .schema import json_dumps, model_dump, model_validate
|
|
41
|
+
from .session import SessionStore
|
|
42
|
+
from .tools import ToolSpec, execute_tool, find_tool, normalize_tools, register_tools
|
|
43
|
+
|
|
44
|
+
FINISH_STOP = "stop"
|
|
45
|
+
FINISH_RESET = "reset"
|
|
46
|
+
FINISH_COMPACT = "compact"
|
|
47
|
+
FINISH_NOT_REGISTERED = "not_registered"
|
|
48
|
+
FINISH_INPUT_INVALID = "input_schema_invalid"
|
|
49
|
+
FINISH_OUTPUT_INVALID = "output_schema_invalid"
|
|
50
|
+
FINISH_TOOL_ERROR = "tool_error"
|
|
51
|
+
FINISH_ERROR = "error"
|
|
52
|
+
|
|
53
|
+
DEFAULT_COMPACT_CONFIG: Dict[str, Any] = {
|
|
54
|
+
"max_prompt_tokens": 8000,
|
|
55
|
+
"enabled": True,
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
|
|
59
|
+
class AgentRuntime(object):
|
|
60
|
+
"""插件实例的运行时。"""
|
|
61
|
+
|
|
62
|
+
def __init__(
|
|
63
|
+
self,
|
|
64
|
+
group: Any,
|
|
65
|
+
model: str = "gpt-5",
|
|
66
|
+
api_key: Optional[str] = None,
|
|
67
|
+
model_kwargs: Optional[Dict[str, Any]] = None,
|
|
68
|
+
temperature: float = 0.0,
|
|
69
|
+
session_store: Optional[SessionStore] = None,
|
|
70
|
+
global_compact_config: Optional[Dict[str, Any]] = None,
|
|
71
|
+
max_tool_iterations: int = 8,
|
|
72
|
+
max_turns: int = 10,
|
|
73
|
+
enable_todo_write: bool = False,
|
|
74
|
+
storage: Any = None,
|
|
75
|
+
logger: Any = None,
|
|
76
|
+
) -> None:
|
|
77
|
+
self.group = group
|
|
78
|
+
self.model = model
|
|
79
|
+
self.api_key = api_key
|
|
80
|
+
self.model_kwargs = dict(model_kwargs or {})
|
|
81
|
+
self.temperature = temperature
|
|
82
|
+
self.session_store = session_store or SessionStore()
|
|
83
|
+
self.global_compact_config = global_compact_config
|
|
84
|
+
self.max_tool_iterations = max_tool_iterations
|
|
85
|
+
self.max_turns = max_turns
|
|
86
|
+
self.enable_todo_write = enable_todo_write
|
|
87
|
+
self.storage = storage
|
|
88
|
+
self.logger = logger
|
|
89
|
+
|
|
90
|
+
# ------------------------------------------------------------------ 公共入口
|
|
91
|
+
async def run(
|
|
92
|
+
self,
|
|
93
|
+
agent_name: str,
|
|
94
|
+
payload: Any,
|
|
95
|
+
tools: Optional[List[Any]] = None,
|
|
96
|
+
session_id: Optional[str] = None,
|
|
97
|
+
) -> Dict[str, Any]:
|
|
98
|
+
"""执行一次 agent 调用,返回框架统一封装结构。"""
|
|
99
|
+
meta = {
|
|
100
|
+
"agent_name": agent_name,
|
|
101
|
+
"token_original": 0,
|
|
102
|
+
"token_compacted": 0,
|
|
103
|
+
"session_id": session_id or "",
|
|
104
|
+
"session_reset": False,
|
|
105
|
+
"iterations": 0,
|
|
106
|
+
"tool_calls": 0,
|
|
107
|
+
"compactions": 0,
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
try:
|
|
111
|
+
spec = self._resolve_spec(agent_name)
|
|
112
|
+
except AgentNotRegisteredError as exc:
|
|
113
|
+
return self._envelope(False, None, FINISH_NOT_REGISTERED, meta, str(exc))
|
|
114
|
+
|
|
115
|
+
meta["agent_name"] = spec.name
|
|
116
|
+
|
|
117
|
+
try:
|
|
118
|
+
tool_specs = normalize_tools(tools)
|
|
119
|
+
except FrameworkError as exc:
|
|
120
|
+
return self._envelope(False, None, FINISH_TOOL_ERROR, meta, str(exc))
|
|
121
|
+
|
|
122
|
+
try:
|
|
123
|
+
payload_data = self._validate_payload(spec, payload)
|
|
124
|
+
except InputSchemaError as exc:
|
|
125
|
+
return self._envelope(False, None, FINISH_INPUT_INVALID, meta, str(exc))
|
|
126
|
+
|
|
127
|
+
session, session_reset = self._resolve_session(spec, session_id)
|
|
128
|
+
meta["session_id"] = session.session_id
|
|
129
|
+
meta["session_reset"] = session_reset
|
|
130
|
+
|
|
131
|
+
agent = session.agent
|
|
132
|
+
if agent is None:
|
|
133
|
+
agent = self._create_agent(spec)
|
|
134
|
+
session.agent = agent
|
|
135
|
+
try:
|
|
136
|
+
register_tools(agent, tool_specs)
|
|
137
|
+
except Exception as exc: # noqa: BLE001
|
|
138
|
+
return self._envelope(False, None, FINISH_TOOL_ERROR, meta, str(exc))
|
|
139
|
+
|
|
140
|
+
user_input = self._build_user_input(payload_data)
|
|
141
|
+
final_text = ""
|
|
142
|
+
|
|
143
|
+
try:
|
|
144
|
+
for iteration in range(self.max_tool_iterations):
|
|
145
|
+
meta["iterations"] = iteration + 1
|
|
146
|
+
|
|
147
|
+
if iteration == 0:
|
|
148
|
+
final_text = await agent.run(user_input, max_turns=self.max_turns)
|
|
149
|
+
else:
|
|
150
|
+
final_text = await agent.resume(max_turns=self.max_turns)
|
|
151
|
+
|
|
152
|
+
# <|tool_call|> 标记协议兜底:兼容不走原生工具调用的模型
|
|
153
|
+
marker_call = parse_tool_call(final_text)
|
|
154
|
+
if marker_call is None:
|
|
155
|
+
break
|
|
156
|
+
|
|
157
|
+
meta["tool_calls"] += 1
|
|
158
|
+
try:
|
|
159
|
+
tool = find_tool(tool_specs, marker_call.name)
|
|
160
|
+
tool_result = await execute_tool(tool, marker_call.arguments)
|
|
161
|
+
except (ToolNotFoundError, ToolExecutionError) as exc:
|
|
162
|
+
return self._envelope(False, None, FINISH_TOOL_ERROR, meta, str(exc))
|
|
163
|
+
|
|
164
|
+
agent.messages.append(
|
|
165
|
+
{
|
|
166
|
+
"role": "user",
|
|
167
|
+
"content": "工具 {0} 执行结果:{1}".format(marker_call.name, tool_result),
|
|
168
|
+
"created_at": int(time.time()),
|
|
169
|
+
}
|
|
170
|
+
)
|
|
171
|
+
else:
|
|
172
|
+
return self._envelope(
|
|
173
|
+
False,
|
|
174
|
+
None,
|
|
175
|
+
FINISH_COMPACT,
|
|
176
|
+
meta,
|
|
177
|
+
"工具调用轮次超过上限 {0}".format(self.max_tool_iterations),
|
|
178
|
+
)
|
|
179
|
+
except FrameworkError as exc:
|
|
180
|
+
return self._envelope(False, None, FINISH_ERROR, meta, str(exc))
|
|
181
|
+
except Exception as exc: # noqa: BLE001
|
|
182
|
+
return self._envelope(
|
|
183
|
+
False, None, FINISH_ERROR, meta, "{0}: {1}".format(type(exc).__name__, exc)
|
|
184
|
+
)
|
|
185
|
+
|
|
186
|
+
# ---- token 统计与上下文压缩 ----
|
|
187
|
+
native_tool_calls = len(
|
|
188
|
+
[m for m in getattr(agent, "messages", []) or [] if m.get("role") == "tool"]
|
|
189
|
+
)
|
|
190
|
+
meta["tool_calls"] = meta["tool_calls"] + native_tool_calls
|
|
191
|
+
token_original = self._count_tokens(agent)
|
|
192
|
+
meta["token_original"] = token_original
|
|
193
|
+
meta["token_compacted"] = token_original
|
|
194
|
+
|
|
195
|
+
compact_config = self._compact_config(spec)
|
|
196
|
+
if compact_config.get("enabled", True) and token_original > int(
|
|
197
|
+
compact_config.get("max_prompt_tokens", DEFAULT_COMPACT_CONFIG["max_prompt_tokens"])
|
|
198
|
+
):
|
|
199
|
+
try:
|
|
200
|
+
compacted = await self._compact_agent(agent)
|
|
201
|
+
if compacted is False:
|
|
202
|
+
meta["compact_error"] = "压缩未生效(引擎返回 False)"
|
|
203
|
+
else:
|
|
204
|
+
meta["compactions"] = 1
|
|
205
|
+
meta["token_compacted"] = self._count_tokens(agent)
|
|
206
|
+
except Exception as exc: # noqa: BLE001 - 压缩失败不阻塞主流程,但需可观测
|
|
207
|
+
meta["compact_error"] = "{0}: {1}".format(type(exc).__name__, exc)
|
|
208
|
+
|
|
209
|
+
# ---- 最终输出校验 ----
|
|
210
|
+
payload_obj = extract_json_payload(final_text)
|
|
211
|
+
if payload_obj is None:
|
|
212
|
+
return self._envelope(
|
|
213
|
+
False,
|
|
214
|
+
None,
|
|
215
|
+
FINISH_OUTPUT_INVALID,
|
|
216
|
+
meta,
|
|
217
|
+
"模型输出无法解析为 JSON: {0}".format((final_text or "")[:200]),
|
|
218
|
+
)
|
|
219
|
+
|
|
220
|
+
if isinstance(payload_obj, list):
|
|
221
|
+
payload_obj = {"items": payload_obj}
|
|
222
|
+
|
|
223
|
+
try:
|
|
224
|
+
output = model_validate(spec.output_model, payload_obj)
|
|
225
|
+
except Exception as exc: # noqa: BLE001 - pydantic 校验失败
|
|
226
|
+
return self._envelope(
|
|
227
|
+
False,
|
|
228
|
+
None,
|
|
229
|
+
FINISH_OUTPUT_INVALID,
|
|
230
|
+
meta,
|
|
231
|
+
"模型输出不符合 {0}: {1}".format(spec.output_model.__name__, exc),
|
|
232
|
+
)
|
|
233
|
+
|
|
234
|
+
finish_reason = FINISH_RESET if session_reset else FINISH_STOP
|
|
235
|
+
return self._envelope(True, output, finish_reason, meta, "")
|
|
236
|
+
|
|
237
|
+
# ------------------------------------------------------------------ 内部方法
|
|
238
|
+
def _resolve_spec(self, agent_name: str) -> AgentSpec:
|
|
239
|
+
"""解析 agent 声明。"""
|
|
240
|
+
getter = getattr(self.group, "get_agent_spec", None)
|
|
241
|
+
if getter is None:
|
|
242
|
+
raise AgentNotRegisteredError("当前对象不是 BaseAgentGroup")
|
|
243
|
+
return getter(agent_name)
|
|
244
|
+
|
|
245
|
+
def _validate_payload(self, spec: AgentSpec, payload: Any) -> Dict[str, Any]:
|
|
246
|
+
"""校验入参并转为 dict。"""
|
|
247
|
+
if payload is None:
|
|
248
|
+
raise InputSchemaError("payload 不能为空")
|
|
249
|
+
if isinstance(payload, spec.input_model):
|
|
250
|
+
return model_dump(payload)
|
|
251
|
+
if isinstance(payload, dict):
|
|
252
|
+
try:
|
|
253
|
+
return model_dump(model_validate(spec.input_model, payload))
|
|
254
|
+
except Exception as exc: # noqa: BLE001
|
|
255
|
+
raise InputSchemaError(
|
|
256
|
+
"入参不符合 {0}: {1}".format(spec.input_model.__name__, exc)
|
|
257
|
+
)
|
|
258
|
+
raise InputSchemaError(
|
|
259
|
+
"payload 必须是 {0} 实例或 dict,当前为 {1}".format(
|
|
260
|
+
spec.input_model.__name__, type(payload).__name__
|
|
261
|
+
)
|
|
262
|
+
)
|
|
263
|
+
|
|
264
|
+
def _compact_config(self, spec: AgentSpec) -> Dict[str, Any]:
|
|
265
|
+
"""合并压缩配置:agent 声明优先,其次全局配置,最后默认值。"""
|
|
266
|
+
config = dict(DEFAULT_COMPACT_CONFIG)
|
|
267
|
+
if self.global_compact_config:
|
|
268
|
+
config.update({k: v for k, v in self.global_compact_config.items() if v is not None})
|
|
269
|
+
if spec.compact_config:
|
|
270
|
+
config.update({k: v for k, v in spec.compact_config.items() if v is not None})
|
|
271
|
+
return config
|
|
272
|
+
|
|
273
|
+
def _resolve_session(self, spec: AgentSpec, session_id: Optional[str]):
|
|
274
|
+
"""获取或创建会话(会话内复用同一个 tinyagent 实例,ttl 到期即重置)。"""
|
|
275
|
+
if not session_id:
|
|
276
|
+
session = self.session_store.create(
|
|
277
|
+
spec.name, spec.ttl, agent=self._create_agent(spec)
|
|
278
|
+
)
|
|
279
|
+
return session, False
|
|
280
|
+
|
|
281
|
+
existing = self.session_store.peek(session_id)
|
|
282
|
+
if existing is None:
|
|
283
|
+
# 全新会话
|
|
284
|
+
session = self.session_store.create(
|
|
285
|
+
spec.name, spec.ttl, session_id=session_id, agent=self._create_agent(spec)
|
|
286
|
+
)
|
|
287
|
+
return session, False
|
|
288
|
+
|
|
289
|
+
if existing.expired:
|
|
290
|
+
# 会话过期 -> 重置
|
|
291
|
+
session = self.session_store.create(
|
|
292
|
+
spec.name, spec.ttl, session_id=session_id, agent=self._create_agent(spec)
|
|
293
|
+
)
|
|
294
|
+
return session, True
|
|
295
|
+
|
|
296
|
+
session = existing
|
|
297
|
+
if session.agent_name != spec.name:
|
|
298
|
+
session = self.session_store.reset(
|
|
299
|
+
session_id, spec.name, spec.ttl, agent=self._create_agent(spec)
|
|
300
|
+
)
|
|
301
|
+
return session, True
|
|
302
|
+
|
|
303
|
+
session.touch(spec.ttl)
|
|
304
|
+
return session, False
|
|
305
|
+
|
|
306
|
+
async def _compact_agent(self, agent: Any) -> bool:
|
|
307
|
+
"""执行压缩。
|
|
308
|
+
|
|
309
|
+
引擎在生成摘要时用 dict 方式读取 tool_calls,而运行期消息里是对象,
|
|
310
|
+
这里临时换成可读形式;压缩失败时恢复原消息。
|
|
311
|
+
"""
|
|
312
|
+
original = getattr(agent, "messages", []) or []
|
|
313
|
+
try:
|
|
314
|
+
agent.messages = messages_for_summary(list(original))
|
|
315
|
+
except Exception: # pragma: no cover - 转换失败时按原样压缩
|
|
316
|
+
agent.messages = original
|
|
317
|
+
|
|
318
|
+
try:
|
|
319
|
+
return await agent.compact()
|
|
320
|
+
except Exception:
|
|
321
|
+
if getattr(agent, "messages", None) is not original:
|
|
322
|
+
agent.messages = original
|
|
323
|
+
raise
|
|
324
|
+
|
|
325
|
+
def _create_agent(self, spec: AgentSpec) -> Any:
|
|
326
|
+
"""按 agent 声明创建 tinyagent 实例。"""
|
|
327
|
+
from tinyagent import TinyAgent
|
|
328
|
+
|
|
329
|
+
return TinyAgent(
|
|
330
|
+
model=self.model,
|
|
331
|
+
api_key=self.api_key,
|
|
332
|
+
system_prompt=self._build_system_prompt(spec),
|
|
333
|
+
temperature=self.temperature,
|
|
334
|
+
model_kwargs=dict(self.model_kwargs),
|
|
335
|
+
enable_todo_write=self.enable_todo_write,
|
|
336
|
+
parallel_tool_calls=False,
|
|
337
|
+
enable_custom_instruction=False,
|
|
338
|
+
storage=self.storage,
|
|
339
|
+
logger=self.logger,
|
|
340
|
+
)
|
|
341
|
+
|
|
342
|
+
def _build_system_prompt(self, spec: AgentSpec) -> str:
|
|
343
|
+
"""组装 system prompt:业务指令 + 输出约束 + 工具调用协议。"""
|
|
344
|
+
instruction = spec.business_instruction or (
|
|
345
|
+
"你是 {0} 助手,请根据用户输入完成任务。".format(spec.name)
|
|
346
|
+
)
|
|
347
|
+
blocks = [
|
|
348
|
+
instruction,
|
|
349
|
+
"\n【输出要求】\n最终仅输出符合以下 JSON Schema 的 JSON 对象,"
|
|
350
|
+
"不要附带任何解释、说明或 Markdown 标记:\n" + json_dumps(spec.output_schema),
|
|
351
|
+
"\n【工具调用】\n需要外部信息时必须调用工具,禁止凭空编造;"
|
|
352
|
+
"一次只能调用一个工具。可使用原生函数调用,或使用以下标记协议:\n"
|
|
353
|
+
"<|tool_call|>\n"
|
|
354
|
+
'{"name": "工具名称", "arguments": {}}\n'
|
|
355
|
+
"<|end_tool_call|>",
|
|
356
|
+
]
|
|
357
|
+
return "\n".join(blocks)
|
|
358
|
+
|
|
359
|
+
def _build_user_input(self, payload_data: Dict[str, Any]) -> str:
|
|
360
|
+
"""把入参转成用户消息(含当前日期,便于模型推导“明天”等相对日期)。"""
|
|
361
|
+
now = datetime.now()
|
|
362
|
+
return (
|
|
363
|
+
"当前日期时间:{0}({1})\n"
|
|
364
|
+
"如需相对日期(如今天/明天/后天),以此为基准换算。\n"
|
|
365
|
+
"用户输入:\n{2}"
|
|
366
|
+
).format(
|
|
367
|
+
now.strftime("%Y-%m-%d %H:%M:%S"),
|
|
368
|
+
now.strftime("%A"),
|
|
369
|
+
json_dumps(payload_data),
|
|
370
|
+
)
|
|
371
|
+
|
|
372
|
+
@staticmethod
|
|
373
|
+
def _count_tokens(agent: Any) -> int:
|
|
374
|
+
"""统计当前会话的 token 数。"""
|
|
375
|
+
try:
|
|
376
|
+
total = 0
|
|
377
|
+
for message in getattr(agent, "messages", []) or []:
|
|
378
|
+
content = message.get("content") or ""
|
|
379
|
+
total += agent.count_tokens(content)
|
|
380
|
+
return total
|
|
381
|
+
except Exception: # pragma: no cover
|
|
382
|
+
return 0
|
|
383
|
+
|
|
384
|
+
@staticmethod
|
|
385
|
+
def _envelope(
|
|
386
|
+
ok: bool,
|
|
387
|
+
data: Any,
|
|
388
|
+
finish_reason: str,
|
|
389
|
+
meta: Dict[str, Any],
|
|
390
|
+
error: str,
|
|
391
|
+
) -> Dict[str, Any]:
|
|
392
|
+
"""构造框架统一返回结构。"""
|
|
393
|
+
envelope: Dict[str, Any] = {
|
|
394
|
+
"ok": ok,
|
|
395
|
+
"data": data,
|
|
396
|
+
"finish_reason": finish_reason,
|
|
397
|
+
"meta": {
|
|
398
|
+
"agent_name": meta.get("agent_name", ""),
|
|
399
|
+
"token_original": meta.get("token_original", 0),
|
|
400
|
+
"token_compacted": meta.get("token_compacted", 0),
|
|
401
|
+
"session_id": meta.get("session_id", ""),
|
|
402
|
+
"session_reset": meta.get("session_reset", False),
|
|
403
|
+
"iterations": meta.get("iterations", 0),
|
|
404
|
+
"tool_calls": meta.get("tool_calls", 0),
|
|
405
|
+
"compactions": meta.get("compactions", 0),
|
|
406
|
+
},
|
|
407
|
+
}
|
|
408
|
+
if meta.get("compact_error"):
|
|
409
|
+
envelope["meta"]["compact_error"] = meta["compact_error"]
|
|
410
|
+
if error:
|
|
411
|
+
envelope["error"] = error
|
|
412
|
+
return envelope
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
"""Pydantic v1/v2 兼容层与 JSON Schema 反射工具。
|
|
2
|
+
|
|
3
|
+
框架不手写工具 parameters:工具函数的签名由 `inspect` + `typing.get_type_hints`
|
|
4
|
+
反射生成 JSON Schema,agent 的入参/出参由 Pydantic 模型反射生成。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import inspect
|
|
8
|
+
import json
|
|
9
|
+
import typing
|
|
10
|
+
from typing import Any, Callable, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from pydantic import BaseModel
|
|
13
|
+
|
|
14
|
+
_PYDANTIC_V2 = hasattr(BaseModel, "model_validate")
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def model_validate(model_cls: type, data: Any) -> BaseModel:
|
|
18
|
+
"""按当前 pydantic 版本校验数据。"""
|
|
19
|
+
if _PYDANTIC_V2:
|
|
20
|
+
return model_cls.model_validate(data)
|
|
21
|
+
return model_cls.parse_obj(data) # pragma: no cover - pydantic v1 分支
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
def model_dump(model: BaseModel) -> Dict[str, Any]:
|
|
25
|
+
"""按当前 pydantic 版本导出字典。"""
|
|
26
|
+
if _PYDANTIC_V2:
|
|
27
|
+
return model.model_dump()
|
|
28
|
+
return model.dict() # pragma: no cover - pydantic v1 分支
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
def model_json(model: BaseModel) -> str:
|
|
32
|
+
"""序列化为 JSON 字符串。"""
|
|
33
|
+
if _PYDANTIC_V2:
|
|
34
|
+
return model.model_dump_json()
|
|
35
|
+
return model.json() # pragma: no cover - pydantic v1 分支
|
|
36
|
+
|
|
37
|
+
|
|
38
|
+
def model_schema(model_cls: type) -> Dict[str, Any]:
|
|
39
|
+
"""返回模型的 JSON Schema。"""
|
|
40
|
+
if _PYDANTIC_V2:
|
|
41
|
+
return model_cls.model_json_schema()
|
|
42
|
+
return model_cls.schema() # pragma: no cover - pydantic v1 分支
|
|
43
|
+
|
|
44
|
+
|
|
45
|
+
_JSON_TYPES = {
|
|
46
|
+
str: "string",
|
|
47
|
+
int: "integer",
|
|
48
|
+
float: "number",
|
|
49
|
+
bool: "boolean",
|
|
50
|
+
list: "array",
|
|
51
|
+
tuple: "array",
|
|
52
|
+
set: "array",
|
|
53
|
+
dict: "object",
|
|
54
|
+
type(None): "null",
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
|
|
58
|
+
def _type_to_schema(annotation: Any) -> Dict[str, Any]:
|
|
59
|
+
"""把 Python 类型注解转换成 JSON Schema 片段。"""
|
|
60
|
+
if annotation is inspect.Parameter.empty or annotation is Any:
|
|
61
|
+
return {"type": "string"}
|
|
62
|
+
|
|
63
|
+
origin = typing.get_origin(annotation)
|
|
64
|
+
args = typing.get_args(annotation)
|
|
65
|
+
|
|
66
|
+
if origin is typing.Union:
|
|
67
|
+
non_none = [a for a in args if a is not type(None)] # noqa: E721
|
|
68
|
+
if len(non_none) == 1:
|
|
69
|
+
return _type_to_schema(non_none[0])
|
|
70
|
+
return {"anyOf": [_type_to_schema(a) for a in non_none]}
|
|
71
|
+
|
|
72
|
+
if origin in (list, set, tuple):
|
|
73
|
+
item = _type_to_schema(args[0]) if args else {"type": "string"}
|
|
74
|
+
return {"type": "array", "items": item}
|
|
75
|
+
|
|
76
|
+
if origin is dict:
|
|
77
|
+
return {"type": "object"}
|
|
78
|
+
|
|
79
|
+
if isinstance(annotation, type) and issubclass(annotation, BaseModel):
|
|
80
|
+
return model_schema(annotation)
|
|
81
|
+
|
|
82
|
+
if annotation in _JSON_TYPES:
|
|
83
|
+
return {"type": _JSON_TYPES[annotation]}
|
|
84
|
+
|
|
85
|
+
return {"type": "string"}
|
|
86
|
+
|
|
87
|
+
|
|
88
|
+
def tool_parameters_schema(func: Callable[..., Any]) -> Dict[str, Any]:
|
|
89
|
+
"""从工具函数签名反射生成 JSON Schema(框架自动完成,无需手写)。"""
|
|
90
|
+
signature = inspect.signature(func)
|
|
91
|
+
try:
|
|
92
|
+
hints = typing.get_type_hints(func)
|
|
93
|
+
except Exception: # pragma: no cover - 注解无法解析时退化为空类型
|
|
94
|
+
hints = {}
|
|
95
|
+
|
|
96
|
+
properties: Dict[str, Any] = {}
|
|
97
|
+
required: List[str] = []
|
|
98
|
+
|
|
99
|
+
for name, param in signature.parameters.items():
|
|
100
|
+
if name in ("self", "cls"):
|
|
101
|
+
continue
|
|
102
|
+
if param.kind in (inspect.Parameter.VAR_POSITIONAL, inspect.Parameter.VAR_KEYWORD):
|
|
103
|
+
continue
|
|
104
|
+
|
|
105
|
+
annotation = hints.get(name, param.annotation)
|
|
106
|
+
properties[name] = _type_to_schema(annotation)
|
|
107
|
+
if param.default is inspect.Parameter.empty:
|
|
108
|
+
required.append(name)
|
|
109
|
+
|
|
110
|
+
return {"type": "object", "properties": properties, "required": required}
|
|
111
|
+
|
|
112
|
+
|
|
113
|
+
def describe_docstring(func: Callable[..., Any]) -> str:
|
|
114
|
+
"""取函数的业务指令 docstring。"""
|
|
115
|
+
doc = inspect.getdoc(func)
|
|
116
|
+
return (doc or "").strip()
|
|
117
|
+
|
|
118
|
+
|
|
119
|
+
def json_dumps(data: Any) -> str:
|
|
120
|
+
"""稳定的 JSON 序列化。"""
|
|
121
|
+
return json.dumps(data, ensure_ascii=False, default=str)
|
|
122
|
+
|
|
123
|
+
|
|
124
|
+
def resolve_model_hints(func: Callable[..., Any]) -> Dict[str, Any]:
|
|
125
|
+
"""解析函数注解,失败时返回空字典。"""
|
|
126
|
+
try:
|
|
127
|
+
return typing.get_type_hints(func, include_extras=True)
|
|
128
|
+
except TypeError: # pragma: no cover - Python 3.8 无 include_extras
|
|
129
|
+
try:
|
|
130
|
+
return typing.get_type_hints(func)
|
|
131
|
+
except Exception:
|
|
132
|
+
return {}
|
|
133
|
+
except Exception: # pragma: no cover
|
|
134
|
+
return {}
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
def optional_str(value: Optional[str]) -> str:
|
|
138
|
+
"""None 安全地转成字符串。"""
|
|
139
|
+
return "" if value is None else value
|
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
"""会话存储:按 agent 的 ttl 管理会话有效期,会话内复用同一个引擎实例。
|
|
2
|
+
|
|
3
|
+
会话状态只存在于内存中:进程重启即失效。
|
|
4
|
+
需要跨重启保留历史时,请由业务侧自行保存/恢复(如把 session_id 映射到外部存储)。
|
|
5
|
+
"""
|
|
6
|
+
|
|
7
|
+
import time
|
|
8
|
+
import uuid
|
|
9
|
+
from typing import Any, Dict, List, Optional
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class Session(object):
|
|
13
|
+
"""一次会话的完整状态。"""
|
|
14
|
+
|
|
15
|
+
__slots__ = ("session_id", "agent_name", "messages", "created_at", "expires_at", "meta", "agent")
|
|
16
|
+
|
|
17
|
+
def __init__(
|
|
18
|
+
self,
|
|
19
|
+
session_id: str,
|
|
20
|
+
agent_name: str,
|
|
21
|
+
messages: List[Dict[str, Any]],
|
|
22
|
+
ttl: int,
|
|
23
|
+
meta: Optional[Dict[str, Any]] = None,
|
|
24
|
+
agent: Any = None,
|
|
25
|
+
) -> None:
|
|
26
|
+
self.session_id = session_id
|
|
27
|
+
self.agent_name = agent_name
|
|
28
|
+
self.messages = messages
|
|
29
|
+
self.created_at = time.time()
|
|
30
|
+
self.expires_at = self.created_at + ttl
|
|
31
|
+
self.meta: Dict[str, Any] = meta or {}
|
|
32
|
+
self.agent = agent
|
|
33
|
+
|
|
34
|
+
@property
|
|
35
|
+
def expired(self) -> bool:
|
|
36
|
+
"""会话是否已过期。"""
|
|
37
|
+
return time.time() >= self.expires_at
|
|
38
|
+
|
|
39
|
+
def touch(self, ttl: int) -> None:
|
|
40
|
+
"""续期。"""
|
|
41
|
+
self.expires_at = time.time() + ttl
|
|
42
|
+
|
|
43
|
+
|
|
44
|
+
class SessionStore(object):
|
|
45
|
+
"""内存会话存储。"""
|
|
46
|
+
|
|
47
|
+
def __init__(self) -> None:
|
|
48
|
+
self._sessions: Dict[str, Session] = {}
|
|
49
|
+
|
|
50
|
+
def get(self, session_id: str) -> Optional[Session]:
|
|
51
|
+
"""获取未过期会话;已过期返回 None(保留记录以便识别“需重置”)。"""
|
|
52
|
+
session = self._sessions.get(session_id)
|
|
53
|
+
if session is None or session.expired:
|
|
54
|
+
return None
|
|
55
|
+
return session
|
|
56
|
+
|
|
57
|
+
def peek(self, session_id: str) -> Optional[Session]:
|
|
58
|
+
"""获取会话(不过期检查),用于识别“已过期需重置”的会话。"""
|
|
59
|
+
return self._sessions.get(session_id)
|
|
60
|
+
|
|
61
|
+
def create(
|
|
62
|
+
self,
|
|
63
|
+
agent_name: str,
|
|
64
|
+
ttl: int,
|
|
65
|
+
session_id: Optional[str] = None,
|
|
66
|
+
messages: Optional[List[Dict[str, Any]]] = None,
|
|
67
|
+
agent: Any = None,
|
|
68
|
+
) -> Session:
|
|
69
|
+
"""新建会话。"""
|
|
70
|
+
sid = session_id or uuid.uuid4().hex
|
|
71
|
+
session = Session(sid, agent_name, list(messages or []), ttl, agent=agent)
|
|
72
|
+
self._sessions[sid] = session
|
|
73
|
+
return session
|
|
74
|
+
|
|
75
|
+
def list_session_ids(self) -> List[str]:
|
|
76
|
+
"""当前进程内已知的 session_id 列表。"""
|
|
77
|
+
return sorted(self._sessions.keys())
|
|
78
|
+
|
|
79
|
+
def reset(
|
|
80
|
+
self,
|
|
81
|
+
session_id: str,
|
|
82
|
+
agent_name: str,
|
|
83
|
+
ttl: int,
|
|
84
|
+
agent: Any = None,
|
|
85
|
+
) -> Session:
|
|
86
|
+
"""重置(清空消息并重建引擎实例)会话。"""
|
|
87
|
+
return self.create(agent_name, ttl, session_id=session_id, agent=agent)
|
|
88
|
+
|
|
89
|
+
def drop(self, session_id: str) -> None:
|
|
90
|
+
"""删除会话。"""
|
|
91
|
+
self._sessions.pop(session_id, None)
|
|
92
|
+
|
|
93
|
+
def clear(self) -> None:
|
|
94
|
+
"""清空全部会话。"""
|
|
95
|
+
self._sessions.clear()
|