pasm-framework 0.3.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.
- pasm_framework/__init__.py +509 -0
- pasm_framework/__main__.py +282 -0
- pasm_framework/adapter.py +80 -0
- pasm_framework/application.py +426 -0
- pasm_framework/config.py +319 -0
- pasm_framework/demo.py +85 -0
- pasm_framework/discovery.py +91 -0
- pasm_framework/errors.py +26 -0
- pasm_framework/plugins/__init__.py +42 -0
- pasm_framework/plugins/builtins/__init__.py +30 -0
- pasm_framework/plugins/builtins/knowledge_base.py +319 -0
- pasm_framework/plugins/builtins/llm_responder.py +443 -0
- pasm_framework/plugins/builtins/observability.py +64 -0
- pasm_framework/plugins/builtins/safety.py +89 -0
- pasm_framework/plugins/builtins/sessions.py +82 -0
- pasm_framework/plugins/builtins/warmth.py +97 -0
- pasm_framework/plugins/builtins/web_gateway.py +457 -0
- pasm_framework/plugins/core.py +420 -0
- pasm_framework/plugins/registry.py +80 -0
- pasm_framework/py.typed +0 -0
- pasm_framework/scaffold.py +292 -0
- pasm_framework/service.py +154 -0
- pasm_framework/simple.py +174 -0
- pasm_framework/skill.py +77 -0
- pasm_framework-0.3.0.dist-info/METADATA +281 -0
- pasm_framework-0.3.0.dist-info/RECORD +30 -0
- pasm_framework-0.3.0.dist-info/WHEEL +5 -0
- pasm_framework-0.3.0.dist-info/entry_points.txt +2 -0
- pasm_framework-0.3.0.dist-info/licenses/LICENSE +21 -0
- pasm_framework-0.3.0.dist-info/top_level.txt +1 -0
|
@@ -0,0 +1,443 @@
|
|
|
1
|
+
"""llm_responder —— 可选 LLM 接入插件(零强制依赖,懒接入)。
|
|
2
|
+
|
|
3
|
+
让产品智能体"真正会说话":在不改 ``BaseApplication`` 的前提下,
|
|
4
|
+
把自然语言回复交给外部 LLM(OpenAI 兼容 / DeepSeek / Ollama 本地)。
|
|
5
|
+
|
|
6
|
+
设计要点
|
|
7
|
+
--------
|
|
8
|
+
· **零强制依赖**:用标准库 ``urllib`` 发 HTTP,装了 ``openai`` 也能用,
|
|
9
|
+
两者都没有时本插件安全退场(``msg.reply`` 留空 → 回落 chat 模板)。
|
|
10
|
+
· **默认关闭**:需要密钥 / 网络,故 ``_DEFAULT_ENABLED`` 里 ``False``;
|
|
11
|
+
用户通过后端配置显式开启。
|
|
12
|
+
· **永不崩**:任何异常都静默失败、保留模板兜底,不让线上服务挂掉。
|
|
13
|
+
· **真流式**(v0.3.0):``Message.stream_sink`` 非空时按上游 SSE 增量外推,
|
|
14
|
+
首字延迟从"整段生成完"降到"首 token"。
|
|
15
|
+
· **多轮工具调用**(v0.3.0):应用的能力(``Capability``)自动暴露成
|
|
16
|
+
function tools,支持 ``tool_calls → 本地执行 → 回填 → 再请求`` 的环;
|
|
17
|
+
上游不支持 ``tools`` 时自动降级为纯对话。
|
|
18
|
+
|
|
19
|
+
后端配置
|
|
20
|
+
--------
|
|
21
|
+
· ``provider`` ``openai`` / ``deepseek`` / ``ollama`` / 其它(视为 OpenAI 兼容)
|
|
22
|
+
· ``base_url`` 服务地址(缺省按 provider 取官方地址)
|
|
23
|
+
· ``api_key`` 密钥(也可用环境变量 ``PASM_LLM_API_KEY`` / ``OPENAI_API_KEY`` /
|
|
24
|
+
``DEEPSEEK_API_KEY``);``ollama`` 与 OpenAI 兼容本地服务可免
|
|
25
|
+
· ``model`` / ``temperature`` / ``max_tokens`` / ``timeout`` / ``system_prompt``
|
|
26
|
+
· ``tools`` bool,是否把应用能力暴露为 tools(默认 ``True``)
|
|
27
|
+
· ``max_tool_rounds`` int,工具调用最大轮数(默认 ``3``;``0`` = 关闭工具)
|
|
28
|
+
· ``stream`` bool,是否向上游请求流式(默认 ``True``)
|
|
29
|
+
"""
|
|
30
|
+
from __future__ import annotations
|
|
31
|
+
|
|
32
|
+
import json
|
|
33
|
+
import os
|
|
34
|
+
import re
|
|
35
|
+
import urllib.request
|
|
36
|
+
import urllib.error
|
|
37
|
+
from typing import Any, Callable, Dict, Iterator, List, Optional, Tuple
|
|
38
|
+
|
|
39
|
+
from ..core import BasePlugin, Message, PluginContext
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
_DEFAULT_BASE = {
|
|
43
|
+
"openai": "https://api.openai.com/v1",
|
|
44
|
+
"deepseek": "https://api.deepseek.com/v1",
|
|
45
|
+
"ollama": "http://localhost:11434",
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
#: OpenAI 规定 tool 名必须匹配 ^[a-zA-Z0-9_-]{1,64}$
|
|
49
|
+
_TOOL_UNSAFE = re.compile(r"[^A-Za-z0-9_-]")
|
|
50
|
+
|
|
51
|
+
|
|
52
|
+
def _tool_name(raw: str, index: int) -> str:
|
|
53
|
+
"""把能力名转成合法的 tool 名。
|
|
54
|
+
|
|
55
|
+
中文能力名(如"广告设计")不符合 OpenAI 的命名约束,故回退为
|
|
56
|
+
``cap_<序号>``;原始中文名会写进 tool 的 ``description``,
|
|
57
|
+
模型依然能正确选择要调用哪个能力。
|
|
58
|
+
"""
|
|
59
|
+
s = _TOOL_UNSAFE.sub("", raw or "").strip("-")
|
|
60
|
+
if not s:
|
|
61
|
+
s = "cap_%d" % index
|
|
62
|
+
return s[:64]
|
|
63
|
+
|
|
64
|
+
|
|
65
|
+
def _iter_frames(resp: Any, provider: str) -> Iterator[Dict[str, Any]]:
|
|
66
|
+
"""逐帧解析上游流。
|
|
67
|
+
|
|
68
|
+
· OpenAI 兼容:SSE(``data: {...}``,以 ``data: [DONE]`` 结束)
|
|
69
|
+
· Ollama:NDJSON(每行一个 JSON 对象)
|
|
70
|
+
"""
|
|
71
|
+
while True:
|
|
72
|
+
line = resp.readline()
|
|
73
|
+
if not line:
|
|
74
|
+
break
|
|
75
|
+
line = line.strip()
|
|
76
|
+
if not line:
|
|
77
|
+
continue
|
|
78
|
+
if provider == "ollama":
|
|
79
|
+
try:
|
|
80
|
+
yield json.loads(line.decode("utf-8"))
|
|
81
|
+
except Exception:
|
|
82
|
+
continue
|
|
83
|
+
continue
|
|
84
|
+
if line.startswith(b":"): # SSE 注释/心跳
|
|
85
|
+
continue
|
|
86
|
+
if not line.startswith(b"data:"):
|
|
87
|
+
continue
|
|
88
|
+
payload = line[5:].strip()
|
|
89
|
+
if payload == b"[DONE]":
|
|
90
|
+
break
|
|
91
|
+
try:
|
|
92
|
+
yield json.loads(payload.decode("utf-8"))
|
|
93
|
+
except Exception:
|
|
94
|
+
continue
|
|
95
|
+
|
|
96
|
+
|
|
97
|
+
class LLMResponderPlugin(BasePlugin):
|
|
98
|
+
"""把自然语言回复交给外部 LLM(支持真流式与多轮工具调用)。"""
|
|
99
|
+
|
|
100
|
+
name = "llm_responder"
|
|
101
|
+
version = "0.3.0"
|
|
102
|
+
|
|
103
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
|
104
|
+
super().__init__(config)
|
|
105
|
+
self._provider: str = str(self.config.get("provider", "openai")).lower()
|
|
106
|
+
self._base_url: str = str(self.config.get("base_url")
|
|
107
|
+
or _DEFAULT_BASE.get(self._provider, ""))
|
|
108
|
+
self._model: str = str(self.config.get("model", "gpt-4o-mini"))
|
|
109
|
+
self._api_key: Optional[str] = (
|
|
110
|
+
self.config.get("api_key")
|
|
111
|
+
or os.environ.get("PASM_LLM_API_KEY")
|
|
112
|
+
or os.environ.get("OPENAI_API_KEY")
|
|
113
|
+
or os.environ.get("DEEPSEEK_API_KEY")
|
|
114
|
+
)
|
|
115
|
+
self._temperature: float = float(self.config.get("temperature", 0.7))
|
|
116
|
+
self._max_tokens: int = int(self.config.get("max_tokens", 800))
|
|
117
|
+
self._timeout: int = int(self.config.get("timeout", 30))
|
|
118
|
+
self._tools_on: bool = bool(self.config.get("tools", True))
|
|
119
|
+
self._max_tool_rounds: int = max(0, int(self.config.get("max_tool_rounds", 3)))
|
|
120
|
+
self._stream_on: bool = bool(self.config.get("stream", True))
|
|
121
|
+
self._system_prompt: str = str(self.config.get(
|
|
122
|
+
"system_prompt",
|
|
123
|
+
"你是{persona_name},一位{persona_role}。请用自然、专业、有温度的中文回答用户。"
|
|
124
|
+
"仅依据给定资料作答,资料没有的就如实说不知道,不要编造。"))
|
|
125
|
+
self._last_error: Optional[str] = None
|
|
126
|
+
self._tools_rejected = False # 上游不支持 tools 时置位,避免反复试错
|
|
127
|
+
self._stats: Dict[str, int] = {
|
|
128
|
+
"requests": 0, "streamed_chars": 0, "tool_calls": 0, "errors": 0,
|
|
129
|
+
}
|
|
130
|
+
|
|
131
|
+
# ---- 请求构造 ----
|
|
132
|
+
def _system(self, ctx: PluginContext) -> str:
|
|
133
|
+
p = ctx.persona()
|
|
134
|
+
name = p.get("name", "智能助手")
|
|
135
|
+
role = p.get("role", "AI 助手")
|
|
136
|
+
try:
|
|
137
|
+
return self._system_prompt.format(persona_name=name, persona_role=role)
|
|
138
|
+
except Exception: # 用户自定模板里的大括号写错也不该崩
|
|
139
|
+
return self._system_prompt
|
|
140
|
+
|
|
141
|
+
def _messages(self, ctx: PluginContext) -> List[Dict[str, Any]]:
|
|
142
|
+
msgs: List[Dict[str, Any]] = [{"role": "system", "content": self._system(ctx)}]
|
|
143
|
+
# 注入检索到的资料(情景 + 领域 + 知识库)。
|
|
144
|
+
facts = ctx.message.facts or []
|
|
145
|
+
if facts:
|
|
146
|
+
knowledge = "\n".join(
|
|
147
|
+
"- %s:%s" % (f.get("title", ""), f.get("brief", ""))
|
|
148
|
+
for f in facts[:8] if f.get("brief")
|
|
149
|
+
)
|
|
150
|
+
if knowledge:
|
|
151
|
+
msgs.append({
|
|
152
|
+
"role": "system",
|
|
153
|
+
"content": "参考资料(请优先据此回答):\n%s" % knowledge,
|
|
154
|
+
})
|
|
155
|
+
# 会话历史(sessions 插件注入)。
|
|
156
|
+
hist = ctx.store.get("session_history") or []
|
|
157
|
+
for m in hist[-10:]:
|
|
158
|
+
role = "assistant" if m.get("role") == "assistant" else "user"
|
|
159
|
+
msgs.append({"role": role, "content": str(m.get("text", ""))})
|
|
160
|
+
# 当前用户问题。
|
|
161
|
+
msgs.append({"role": "user", "content": ctx.message.text})
|
|
162
|
+
return msgs
|
|
163
|
+
|
|
164
|
+
# ---- 工具(把应用能力暴露成 function tools)----------------
|
|
165
|
+
def _tool_specs(self, ctx: PluginContext) -> Tuple[List[Dict[str, Any]], Dict[str, str]]:
|
|
166
|
+
"""返回 (OpenAI tools 列表, tool名 → 能力名 映射)。"""
|
|
167
|
+
app = ctx.app
|
|
168
|
+
discover = getattr(app, "capabilities", None)
|
|
169
|
+
if discover is None or not self._tools_on or self._tools_rejected:
|
|
170
|
+
return [], {}
|
|
171
|
+
specs: List[Dict[str, Any]] = []
|
|
172
|
+
mapping: Dict[str, str] = {}
|
|
173
|
+
try:
|
|
174
|
+
names = list(discover.names())
|
|
175
|
+
except Exception:
|
|
176
|
+
return [], {}
|
|
177
|
+
for i, cap_name in enumerate(names, 1):
|
|
178
|
+
try:
|
|
179
|
+
cap = discover.get(cap_name)
|
|
180
|
+
except Exception:
|
|
181
|
+
continue
|
|
182
|
+
if cap is None:
|
|
183
|
+
continue
|
|
184
|
+
tname = _tool_name(cap_name, i)
|
|
185
|
+
if tname in mapping:
|
|
186
|
+
tname = "%s_%d" % (tname, i)
|
|
187
|
+
mapping[tname] = cap_name
|
|
188
|
+
desc = getattr(cap, "description", "") or ""
|
|
189
|
+
specs.append({
|
|
190
|
+
"type": "function",
|
|
191
|
+
"function": {
|
|
192
|
+
"name": tname,
|
|
193
|
+
"description": ("应用能力「%s」%s" % (cap_name, (":" + desc) if desc else ""))[:512],
|
|
194
|
+
"parameters": {
|
|
195
|
+
"type": "object",
|
|
196
|
+
"properties": {
|
|
197
|
+
"text": {
|
|
198
|
+
"type": "string",
|
|
199
|
+
"description": "用户想要执行这件事的原话(尽量保留原始措辞)",
|
|
200
|
+
},
|
|
201
|
+
},
|
|
202
|
+
"required": ["text"],
|
|
203
|
+
},
|
|
204
|
+
},
|
|
205
|
+
})
|
|
206
|
+
return specs, mapping
|
|
207
|
+
|
|
208
|
+
def _run_tool(self, ctx: PluginContext, mapping: Dict[str, str],
|
|
209
|
+
call: Dict[str, Any]) -> str:
|
|
210
|
+
"""执行一次工具调用(落到应用能力上)。失败也返回文本,让模型继续。"""
|
|
211
|
+
cap_name = mapping.get(call.get("name", ""))
|
|
212
|
+
if not cap_name:
|
|
213
|
+
return "错误:未知工具 %s" % call.get("name")
|
|
214
|
+
try:
|
|
215
|
+
cap = ctx.app.capabilities.get(cap_name)
|
|
216
|
+
except Exception:
|
|
217
|
+
cap = None
|
|
218
|
+
if cap is None:
|
|
219
|
+
return "错误:能力 %s 不存在" % cap_name
|
|
220
|
+
args = call.get("args") or {}
|
|
221
|
+
text = str(args.get("text") or ctx.message.text)
|
|
222
|
+
try:
|
|
223
|
+
out = cap.run(ctx.app, text)
|
|
224
|
+
except Exception as ex: # noqa: BLE001
|
|
225
|
+
return "错误:能力 %s 执行失败(%s)" % (cap_name, ex)
|
|
226
|
+
self._stats["tool_calls"] += 1
|
|
227
|
+
return str(out or "")
|
|
228
|
+
|
|
229
|
+
# ---- HTTP ----
|
|
230
|
+
def _endpoint(self) -> Tuple[Optional[str], Dict[str, str]]:
|
|
231
|
+
base = self._base_url.rstrip("/")
|
|
232
|
+
if not base:
|
|
233
|
+
return None, {}
|
|
234
|
+
headers = {"Content-Type": "application/json"}
|
|
235
|
+
if self._provider == "ollama":
|
|
236
|
+
url = base if base.endswith("/api/chat") else base + "/api/chat"
|
|
237
|
+
return url, headers
|
|
238
|
+
# openai / deepseek / 其它 OpenAI 兼容服务
|
|
239
|
+
if self._provider in ("openai", "deepseek") and not self._api_key:
|
|
240
|
+
return None, {}
|
|
241
|
+
url = base if base.endswith("/chat/completions") else base + "/chat/completions"
|
|
242
|
+
if self._api_key:
|
|
243
|
+
headers["Authorization"] = "Bearer %s" % self._api_key
|
|
244
|
+
return url, headers
|
|
245
|
+
|
|
246
|
+
def _payload(self, msgs: List[Dict[str, Any]], *,
|
|
247
|
+
tools: List[Dict[str, Any]], stream: bool) -> Dict[str, Any]:
|
|
248
|
+
if self._provider == "ollama":
|
|
249
|
+
p: Dict[str, Any] = {
|
|
250
|
+
"model": self._model, "messages": msgs, "stream": stream,
|
|
251
|
+
"options": {"temperature": self._temperature},
|
|
252
|
+
}
|
|
253
|
+
if tools:
|
|
254
|
+
p["tools"] = tools
|
|
255
|
+
return p
|
|
256
|
+
p = {
|
|
257
|
+
"model": self._model, "messages": msgs,
|
|
258
|
+
"temperature": self._temperature, "max_tokens": self._max_tokens,
|
|
259
|
+
}
|
|
260
|
+
if stream:
|
|
261
|
+
p["stream"] = True
|
|
262
|
+
if tools:
|
|
263
|
+
p["tools"] = tools
|
|
264
|
+
p["tool_choice"] = "auto"
|
|
265
|
+
return p
|
|
266
|
+
|
|
267
|
+
def _consume_stream(self, resp: Any,
|
|
268
|
+
sink: Optional[Callable[[str], None]]) -> Dict[str, Any]:
|
|
269
|
+
"""读流式响应:内容增量实时外推,工具调用增量按 index 拼装。"""
|
|
270
|
+
parts: List[str] = []
|
|
271
|
+
slots: Dict[int, Dict[str, str]] = {}
|
|
272
|
+
for obj in _iter_frames(resp, self._provider):
|
|
273
|
+
piece = ""
|
|
274
|
+
if self._provider == "ollama":
|
|
275
|
+
piece = str((obj.get("message") or {}).get("content") or "")
|
|
276
|
+
else:
|
|
277
|
+
choices = obj.get("choices") or []
|
|
278
|
+
if not choices:
|
|
279
|
+
continue
|
|
280
|
+
delta = choices[0].get("delta") or choices[0].get("message") or {}
|
|
281
|
+
piece = str(delta.get("content") or "")
|
|
282
|
+
for tc in (delta.get("tool_calls") or []):
|
|
283
|
+
idx = int(tc.get("index", 0) or 0)
|
|
284
|
+
slot = slots.setdefault(idx, {"id": "", "name": "", "arguments": ""})
|
|
285
|
+
if tc.get("id"):
|
|
286
|
+
slot["id"] = str(tc["id"])
|
|
287
|
+
fn = tc.get("function") or {}
|
|
288
|
+
if fn.get("name"):
|
|
289
|
+
slot["name"] = str(fn["name"])
|
|
290
|
+
if fn.get("arguments"):
|
|
291
|
+
slot["arguments"] += str(fn["arguments"])
|
|
292
|
+
if piece:
|
|
293
|
+
parts.append(piece)
|
|
294
|
+
self._stats["streamed_chars"] += len(piece)
|
|
295
|
+
if sink is not None:
|
|
296
|
+
try:
|
|
297
|
+
sink(piece)
|
|
298
|
+
except Exception: # 下游断开不该让生成崩掉
|
|
299
|
+
pass
|
|
300
|
+
return {"content": "".join(parts), "calls": self._finalize_calls(slots)}
|
|
301
|
+
|
|
302
|
+
@staticmethod
|
|
303
|
+
def _finalize_calls(slots: Dict[int, Dict[str, str]]) -> List[Dict[str, Any]]:
|
|
304
|
+
out: List[Dict[str, Any]] = []
|
|
305
|
+
for idx in sorted(slots):
|
|
306
|
+
s = slots[idx]
|
|
307
|
+
try:
|
|
308
|
+
args = json.loads(s["arguments"] or "{}")
|
|
309
|
+
except Exception:
|
|
310
|
+
args = {}
|
|
311
|
+
if not isinstance(args, dict):
|
|
312
|
+
args = {"text": str(args)}
|
|
313
|
+
out.append({"id": s["id"] or ("call_%d" % idx),
|
|
314
|
+
"name": s["name"], "args": args})
|
|
315
|
+
return out
|
|
316
|
+
|
|
317
|
+
def _parse_full(self, body: str) -> Dict[str, Any]:
|
|
318
|
+
try:
|
|
319
|
+
obj = json.loads(body)
|
|
320
|
+
except Exception:
|
|
321
|
+
return {"content": "", "calls": []}
|
|
322
|
+
if self._provider == "ollama":
|
|
323
|
+
m = obj.get("message") or {}
|
|
324
|
+
calls = []
|
|
325
|
+
for c in (m.get("tool_calls") or []):
|
|
326
|
+
fn = c.get("function") or {}
|
|
327
|
+
args = fn.get("arguments")
|
|
328
|
+
if isinstance(args, str):
|
|
329
|
+
try:
|
|
330
|
+
args = json.loads(args)
|
|
331
|
+
except Exception:
|
|
332
|
+
args = {"text": args}
|
|
333
|
+
calls.append({"id": c.get("id") or "call_0",
|
|
334
|
+
"name": fn.get("name") or "", "args": args or {}})
|
|
335
|
+
return {"content": str(m.get("content") or ""), "calls": calls}
|
|
336
|
+
choices = obj.get("choices") or []
|
|
337
|
+
if not choices:
|
|
338
|
+
return {"content": "", "calls": []}
|
|
339
|
+
m = choices[0].get("message") or {}
|
|
340
|
+
calls = []
|
|
341
|
+
for c in (m.get("tool_calls") or []):
|
|
342
|
+
fn = c.get("function") or {}
|
|
343
|
+
args = fn.get("arguments")
|
|
344
|
+
if isinstance(args, str):
|
|
345
|
+
try:
|
|
346
|
+
args = json.loads(args)
|
|
347
|
+
except Exception:
|
|
348
|
+
args = {"text": args}
|
|
349
|
+
calls.append({"id": c.get("id") or "call_0",
|
|
350
|
+
"name": fn.get("name") or "", "args": args or {}})
|
|
351
|
+
return {"content": str(m.get("content") or ""), "calls": calls}
|
|
352
|
+
|
|
353
|
+
def _round(self, payload: Dict[str, Any], url: str, headers: Dict[str, str],
|
|
354
|
+
*, stream: bool, sink: Optional[Callable[[str], None]]) -> Dict[str, Any]:
|
|
355
|
+
"""发一次请求,返回 ``{"ok", "content", "calls", "bad_request"}``。"""
|
|
356
|
+
data = json.dumps(payload).encode("utf-8")
|
|
357
|
+
req = urllib.request.Request(url, data=data, headers=headers, method="POST")
|
|
358
|
+
self._stats["requests"] += 1
|
|
359
|
+
try:
|
|
360
|
+
with urllib.request.urlopen(req, timeout=self._timeout) as resp:
|
|
361
|
+
if stream:
|
|
362
|
+
got = self._consume_stream(resp, sink)
|
|
363
|
+
else:
|
|
364
|
+
got = self._parse_full(resp.read().decode("utf-8"))
|
|
365
|
+
return {"ok": True, "bad_request": False, **got}
|
|
366
|
+
except urllib.error.HTTPError as ex:
|
|
367
|
+
detail = ex.read().decode("utf-8", "ignore")[:300]
|
|
368
|
+
self._last_error = "HTTP %s: %s" % (ex.code, detail)
|
|
369
|
+
self._stats["errors"] += 1
|
|
370
|
+
# 400 多半是上游不认识 tools/stream → 交给上层降级重试。
|
|
371
|
+
return {"ok": False, "bad_request": ex.code == 400,
|
|
372
|
+
"content": "", "calls": []}
|
|
373
|
+
except Exception as ex: # noqa: BLE001
|
|
374
|
+
self._last_error = str(ex)
|
|
375
|
+
self._stats["errors"] += 1
|
|
376
|
+
return {"ok": False, "bad_request": False, "content": "", "calls": []}
|
|
377
|
+
|
|
378
|
+
# ---- Hook ----
|
|
379
|
+
def on_reply(self, ctx: PluginContext) -> None:
|
|
380
|
+
msg: Message = ctx.message
|
|
381
|
+
# 若已有回复(能力路由 or 其它插件生成),默认不再覆盖。
|
|
382
|
+
if msg.reply and self.config.get("skip_if_reply", True):
|
|
383
|
+
return
|
|
384
|
+
url, headers = self._endpoint()
|
|
385
|
+
if url is None:
|
|
386
|
+
return
|
|
387
|
+
|
|
388
|
+
sink = msg.stream_sink
|
|
389
|
+
msgs = self._messages(ctx)
|
|
390
|
+
specs, mapping = self._tool_specs(ctx)
|
|
391
|
+
rounds = self._max_tool_rounds if specs else 0
|
|
392
|
+
want_stream = bool(sink) and self._stream_on
|
|
393
|
+
|
|
394
|
+
content = ""
|
|
395
|
+
for i in range(rounds + 1):
|
|
396
|
+
use_tools = specs if i < rounds else []
|
|
397
|
+
payload = self._payload(msgs, tools=use_tools, stream=want_stream)
|
|
398
|
+
got = self._round(payload, url, headers, stream=want_stream, sink=sink)
|
|
399
|
+
|
|
400
|
+
if not got["ok"]:
|
|
401
|
+
if got["bad_request"] and (use_tools or want_stream):
|
|
402
|
+
# 降级:上游不支持 tools / stream → 换纯对话再试一次。
|
|
403
|
+
if use_tools:
|
|
404
|
+
self._tools_rejected = True
|
|
405
|
+
specs = []
|
|
406
|
+
if want_stream:
|
|
407
|
+
self._stream_on = False
|
|
408
|
+
want_stream = False
|
|
409
|
+
payload = self._payload(msgs, tools=[], stream=want_stream)
|
|
410
|
+
got = self._round(payload, url, headers,
|
|
411
|
+
stream=want_stream, sink=sink)
|
|
412
|
+
if not got["ok"]:
|
|
413
|
+
return
|
|
414
|
+
|
|
415
|
+
calls = got["calls"]
|
|
416
|
+
if calls and use_tools:
|
|
417
|
+
# 回填 assistant(tool_calls) + tool(结果),进入下一轮。
|
|
418
|
+
msgs.append({
|
|
419
|
+
"role": "assistant",
|
|
420
|
+
"content": got["content"] or "",
|
|
421
|
+
"tool_calls": [
|
|
422
|
+
{"id": c["id"], "type": "function",
|
|
423
|
+
"function": {"name": c["name"],
|
|
424
|
+
"arguments": json.dumps(c["args"], ensure_ascii=False)}}
|
|
425
|
+
for c in calls
|
|
426
|
+
],
|
|
427
|
+
})
|
|
428
|
+
for c in calls:
|
|
429
|
+
msgs.append({"role": "tool", "tool_call_id": c["id"],
|
|
430
|
+
"content": self._run_tool(ctx, mapping, c)})
|
|
431
|
+
continue
|
|
432
|
+
|
|
433
|
+
content = got["content"] or ""
|
|
434
|
+
break
|
|
435
|
+
|
|
436
|
+
if content:
|
|
437
|
+
msg.set_reply(content.strip())
|
|
438
|
+
|
|
439
|
+
# ---- 可观测 ----
|
|
440
|
+
def stats(self) -> Dict[str, Any]:
|
|
441
|
+
return dict(self._stats, provider=self._provider, model=self._model,
|
|
442
|
+
tools=self._tools_on and not self._tools_rejected,
|
|
443
|
+
stream=self._stream_on, last_error=self._last_error)
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
"""observability —— 可观测插件(零依赖):指标 + 健康。
|
|
2
|
+
|
|
3
|
+
给线上服务最基本的"看得见"能力:消息量、回复量、错误数、延迟分布、
|
|
4
|
+
插件异常。``health()`` / ``metrics()`` 供 web_gateway 的 ``/healthz`` 与
|
|
5
|
+
``app_summary()`` 调用。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from ..core import BasePlugin, Message, PluginContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class ObservabilityPlugin(BasePlugin):
|
|
16
|
+
"""指标采集。"""
|
|
17
|
+
|
|
18
|
+
name = "observability"
|
|
19
|
+
version = "0.1.0"
|
|
20
|
+
|
|
21
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
|
22
|
+
super().__init__(config)
|
|
23
|
+
self._start = time.time()
|
|
24
|
+
self._messages_in = 0
|
|
25
|
+
self._replies = 0
|
|
26
|
+
self._errors = 0
|
|
27
|
+
self._latencies: List[float] = []
|
|
28
|
+
self._plugin_errors = 0
|
|
29
|
+
|
|
30
|
+
def on_message_in(self, ctx: PluginContext) -> None:
|
|
31
|
+
self._messages_in += 1
|
|
32
|
+
|
|
33
|
+
def on_learn(self, ctx: PluginContext) -> None:
|
|
34
|
+
msg: Message = ctx.message
|
|
35
|
+
if msg.reply:
|
|
36
|
+
self._replies += 1
|
|
37
|
+
if msg.error:
|
|
38
|
+
self._errors += 1
|
|
39
|
+
lat = time.time() - msg.created_at
|
|
40
|
+
self._latencies.append(lat)
|
|
41
|
+
if len(self._latencies) > 1000:
|
|
42
|
+
self._latencies = self._latencies[-1000:]
|
|
43
|
+
errs = ctx.store.get("_plugin_errors") or []
|
|
44
|
+
if errs:
|
|
45
|
+
self._plugin_errors += len(errs)
|
|
46
|
+
|
|
47
|
+
def metrics(self) -> Dict[str, Any]:
|
|
48
|
+
n = len(self._latencies)
|
|
49
|
+
avg = (sum(self._latencies) / n) if n else 0.0
|
|
50
|
+
p95 = sorted(self._latencies)[int(n * 0.95) - 1] if n else 0.0
|
|
51
|
+
return {
|
|
52
|
+
"uptime_s": round(time.time() - self._start, 1),
|
|
53
|
+
"messages_in": self._messages_in,
|
|
54
|
+
"replies": self._replies,
|
|
55
|
+
"errors": self._errors,
|
|
56
|
+
"plugin_errors": self._plugin_errors,
|
|
57
|
+
"avg_latency_s": round(avg, 3),
|
|
58
|
+
"p95_latency_s": round(p95, 3),
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
def health(self) -> Dict[str, Any]:
|
|
62
|
+
m = self.metrics()
|
|
63
|
+
ok = m["errors"] <= m["messages_in"] # 并非零错误才算健康
|
|
64
|
+
return {"ok": bool(ok), "status": "healthy" if ok else "degraded", **m}
|
|
@@ -0,0 +1,89 @@
|
|
|
1
|
+
"""safety —— 护栏插件(零依赖):prompt 注入拦截 + PII 脱敏。
|
|
2
|
+
|
|
3
|
+
对外暴露的 AI 应用(尤其智能客服)必须有基本护栏,否则:
|
|
4
|
+
· 用户可能用"忽略以上指令"类 prompt 注入劫持行为;
|
|
5
|
+
· 助手回复可能泄露系统提示词 / 密钥 / 用户隐私。
|
|
6
|
+
|
|
7
|
+
默认 ``enabled=True``、``mode="warn"``(只标记不拦,避免误伤正常用户);
|
|
8
|
+
需强拦截时把 mode 设为 ``"block"``。
|
|
9
|
+
"""
|
|
10
|
+
from __future__ import annotations
|
|
11
|
+
|
|
12
|
+
import re
|
|
13
|
+
from typing import Any, Dict, List, Optional
|
|
14
|
+
|
|
15
|
+
from ..core import BasePlugin, Message, PluginContext
|
|
16
|
+
|
|
17
|
+
# 常见越权 / 注入短语(大小写不敏感子串匹配,零依赖)。
|
|
18
|
+
_DEFAULT_INJECTION = [
|
|
19
|
+
"忽略", "ignore ", "ignore the", "忽视以上", "忽略以上", "忽略前面",
|
|
20
|
+
"你现在是", "pretend you are", "system prompt", "系统提示", "泄露你的提示",
|
|
21
|
+
"repeat your instructions", "输出你的设定", "忘记你的角色",
|
|
22
|
+
"disregard", "override", "jailbreak", "越狱", "DAN",
|
|
23
|
+
]
|
|
24
|
+
|
|
25
|
+
# PII / 密钥脱敏正则。
|
|
26
|
+
_EMAIL = re.compile(r"[a-zA-Z0-9_.+-]+@[a-zA-Z0-9-]+\.[a-zA-Z0-9-.]+")
|
|
27
|
+
_PHONE = re.compile(r"(?<!\d)(1[3-9]\d{9})(?!\d)")
|
|
28
|
+
_APIKEY = re.compile(r"(sk-[A-Za-z0-9]{8,}|AKIA[0-9A-Z]{16}|ghp_[A-Za-z0-9]{20,})")
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class SafetyPlugin(BasePlugin):
|
|
32
|
+
"""护栏:注入拦截 + 脱敏。"""
|
|
33
|
+
|
|
34
|
+
name = "safety"
|
|
35
|
+
version = "0.1.0"
|
|
36
|
+
|
|
37
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
|
38
|
+
super().__init__(config)
|
|
39
|
+
self._mode: str = str(self.config.get("mode", "warn")).lower()
|
|
40
|
+
self._patterns: List[str] = list(self.config.get("block_patterns")
|
|
41
|
+
or _DEFAULT_INJECTION)
|
|
42
|
+
self._redact_pii: bool = bool(self.config.get("redact_pii", True))
|
|
43
|
+
|
|
44
|
+
def _scan_injection(self, text: str) -> Optional[str]:
|
|
45
|
+
low = (text or "").lower()
|
|
46
|
+
for p in self._patterns:
|
|
47
|
+
if p.lower() in low:
|
|
48
|
+
return p
|
|
49
|
+
return None
|
|
50
|
+
|
|
51
|
+
@staticmethod
|
|
52
|
+
def _redact(text: str) -> str:
|
|
53
|
+
if not text:
|
|
54
|
+
return text
|
|
55
|
+
t = _EMAIL.sub("[邮箱已脱敏]", text)
|
|
56
|
+
t = _PHONE.sub("[手机号已脱敏]", t)
|
|
57
|
+
t = _APIKEY.sub("[密钥已脱敏]", t)
|
|
58
|
+
return t
|
|
59
|
+
|
|
60
|
+
def on_message_in(self, ctx: PluginContext) -> None:
|
|
61
|
+
msg: Message = ctx.message
|
|
62
|
+
hit = self._scan_injection(msg.text)
|
|
63
|
+
if hit:
|
|
64
|
+
msg.meta["injection_flag"] = hit
|
|
65
|
+
if self._mode == "block":
|
|
66
|
+
msg.stop = True
|
|
67
|
+
msg.error = "检测到疑似越权指令(%s),已拦截。" % hit
|
|
68
|
+
msg.reply = "抱歉,我无法执行该指令。如果你有产品相关问题,我很乐意帮忙。"
|
|
69
|
+
return
|
|
70
|
+
# 入站 PII 脱敏(仅脱敏日志/存储副本,不影响原意太多):
|
|
71
|
+
# 这里仅标记,真正脱敏在出站回复时做,避免误伤用户表达。
|
|
72
|
+
if self._redact_pii:
|
|
73
|
+
msg.meta["pii_present"] = bool(
|
|
74
|
+
_EMAIL.search(msg.text) or _PHONE.search(msg.text)
|
|
75
|
+
)
|
|
76
|
+
|
|
77
|
+
def on_reply_final(self, ctx: PluginContext) -> None:
|
|
78
|
+
"""出站脱敏。挂在 **收尾阶段**(而非 ``on_reply``)至关重要:
|
|
79
|
+
|
|
80
|
+
``on_reply`` 阶段模板兜底回复尚未生成,挂在那里会导致
|
|
81
|
+
"未接 LLM 的离线模式"回复完全绕过护栏(v0.2.0 缺陷)。
|
|
82
|
+
收尾阶段由 ``BaseApplication.handle`` 在唯一出口调用,
|
|
83
|
+
LLM 回复与模板回复都会被脱敏。
|
|
84
|
+
"""
|
|
85
|
+
if not self._redact_pii:
|
|
86
|
+
return
|
|
87
|
+
msg: Message = ctx.message
|
|
88
|
+
if msg.reply:
|
|
89
|
+
msg.reply = self._redact(msg.reply)
|
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""sessions —— 会话 / 租户隔离插件(零依赖)。
|
|
2
|
+
|
|
3
|
+
解决的问题:v0.1.0 的 ``BaseApplication`` 是"一个 agent_id 全局共享状态",
|
|
4
|
+
多用户 / 多会话无法隔离。本插件把每次 ``handle`` 绑定到一个 ``session_id``,
|
|
5
|
+
维护每会话的历史与元信息,供 llm_responder / 客服场景使用。
|
|
6
|
+
"""
|
|
7
|
+
from __future__ import annotations
|
|
8
|
+
|
|
9
|
+
import time
|
|
10
|
+
from typing import Any, Dict, List, Optional
|
|
11
|
+
|
|
12
|
+
from ..core import BasePlugin, Message, PluginContext
|
|
13
|
+
|
|
14
|
+
|
|
15
|
+
class Session:
|
|
16
|
+
"""单个会话的轻量状态。"""
|
|
17
|
+
|
|
18
|
+
def __init__(self, session_id: str, user_id: Optional[str] = None) -> None:
|
|
19
|
+
self.session_id = session_id
|
|
20
|
+
self.user_id = user_id
|
|
21
|
+
self.created_at = time.time()
|
|
22
|
+
self.last_active = time.time()
|
|
23
|
+
self.messages: List[Dict[str, Any]] = [] # {"role","text","ts"}
|
|
24
|
+
self.meta: Dict[str, Any] = {}
|
|
25
|
+
|
|
26
|
+
def append(self, role: str, text: str) -> None:
|
|
27
|
+
self.messages.append({"role": role, "text": text, "ts": time.time()})
|
|
28
|
+
self.last_active = time.time()
|
|
29
|
+
|
|
30
|
+
def trim(self, max_len: int) -> None:
|
|
31
|
+
if max_len and len(self.messages) > max_len:
|
|
32
|
+
self.messages = self.messages[-max_len:]
|
|
33
|
+
|
|
34
|
+
def history(self, max_len: int = 0) -> List[Dict[str, Any]]:
|
|
35
|
+
if max_len:
|
|
36
|
+
return self.messages[-max_len:]
|
|
37
|
+
return list(self.messages)
|
|
38
|
+
|
|
39
|
+
|
|
40
|
+
class SessionsPlugin(BasePlugin):
|
|
41
|
+
"""会话隔离:每个 session_id 一份历史 + 元信息。"""
|
|
42
|
+
|
|
43
|
+
name = "sessions"
|
|
44
|
+
version = "0.1.0"
|
|
45
|
+
|
|
46
|
+
def __init__(self, config: Optional[Dict[str, Any]] = None) -> None:
|
|
47
|
+
super().__init__(config)
|
|
48
|
+
self._max_history: int = int(self.config.get("max_history", 20))
|
|
49
|
+
self._sessions: Dict[str, Session] = {}
|
|
50
|
+
|
|
51
|
+
def _get(self, session_id: str, user_id: Optional[str]) -> Session:
|
|
52
|
+
s = self._sessions.get(session_id)
|
|
53
|
+
if s is None:
|
|
54
|
+
s = Session(session_id, user_id=user_id)
|
|
55
|
+
self._sessions[session_id] = s
|
|
56
|
+
elif user_id and s.user_id is None:
|
|
57
|
+
s.user_id = user_id
|
|
58
|
+
return s
|
|
59
|
+
|
|
60
|
+
def on_message_in(self, ctx: PluginContext) -> None:
|
|
61
|
+
msg: Message = ctx.message
|
|
62
|
+
s = self._get(msg.session_id, msg.user_id)
|
|
63
|
+
# 把会话状态交给同一次调用里的其它插件(如 llm_responder)。
|
|
64
|
+
ctx.store["session"] = s
|
|
65
|
+
ctx.store["session_history"] = s.history(self._max_history)
|
|
66
|
+
s.meta.update(msg.meta or {})
|
|
67
|
+
|
|
68
|
+
def on_learn(self, ctx: PluginContext) -> None:
|
|
69
|
+
msg: Message = ctx.message
|
|
70
|
+
s = self._sessions.get(msg.session_id)
|
|
71
|
+
if s is None:
|
|
72
|
+
return
|
|
73
|
+
s.append("user", msg.text)
|
|
74
|
+
if msg.reply:
|
|
75
|
+
s.append("assistant", msg.reply)
|
|
76
|
+
s.trim(self._max_history)
|
|
77
|
+
|
|
78
|
+
def get_session(self, session_id: str) -> Optional[Session]:
|
|
79
|
+
return self._sessions.get(session_id)
|
|
80
|
+
|
|
81
|
+
def reset(self, session_id: str) -> None:
|
|
82
|
+
self._sessions.pop(session_id, None)
|