feat(plugin): 插件本体与分身可各自设置独立日志等级

全局日志等级是一个开关,调试单个插件时只能整体下调。一旦下调,被调试插件那几行
日志会淹没在其他几十个插件与宿主自身的 DEBUG 输出里,找不出来;不下调又什么都
看不见。插件实例数量只会继续增长,这个矛盾不会自己消失。

因此把等级下放到实例:plugininstance 行加 log_level / log_expires_at 两列,本体与
分身共用同一张表、同一套语义。等级落在实例自己那一行而不是另起一张按实例 ID 索引
的表——它和业务参数一样是用户按实例设置的东西,同一个生命周期,实例被删时应当随行
一起消失。清除覆盖后若本体行只剩一对身份列,整行按 carries_only_identity 回收,与
清空业务参数同一规则。

覆盖带失效时间,因为它是调试设置不是长期配置:开了 DEBUG 忘记关,下次谁也想不起
某个插件为什么一直在刷日志。过期判定在读取时惰性执行并就地清理,不另起后台线程去
扫一张最多几十条的表。expires_at 在写入侧先归一到 UTC 再分发给缓存与落盘两处:客户端
提交的时间可能不带时区,两处各按进程时区折算一次,就会与读取口返回的 UTC 值差出一个
时区,表现为「提交的失效时刻和读回来的对不上」。

运行期用 ContextVar 在宿主自己控制的四类调用点绑定当前实例——构造与 init_plugin、
事件处理器、定时服务回调、插件声明的 HTTP 端点——而不是沿用 logger() 已有的栈帧内省。
内省认的是栈顶的模块与类,插件通过宿主公共方法转发调用时栈顶是宿主而不是发起调用的
插件,会把日志记到错误的来源上;分身更甚:分身共享源码,类的 __qualname__ 保持源类名
不变,内省根本分不出是哪一个分身在打日志。内省结果继续只用于文件路由,不参与等级判定。

等级过滤必须排在全局等级判定之前。官方现状是先按全局等级短路、之后才做内省,先短路
就会把实例调低的等级挡在门外,后面再怎么识别来源都看不到这条日志。

回调在注册时被捕获、稍后才由调度器或 FastAPI 调用,那时已经不在任何绑定作用域内,
故这两处改用 wrap_for_plugin_instance 把绑定包进回调自身;同一实例重复包装直接返回
原对象,避免插件缓存声明时包装链随重载次数无限增长。

进程内覆盖表按实例 ID 常驻,删分身时必须一并清掉:只删库里那一行的话,同一进程内用
相同后缀重建的分身会继承上一个分身的等级,界面显示「跟随全局」而实际仍按旧等级输出。
进程重启后反向从库预热缓存。

迁移链在 F1 之后追加:c4e1a7b9d2f6(3.0.35)-> 487f7e681955(3.0.36 加两列)。

顺带补了 agent 策略里缺失的 media.classification.policy.get 的 template 字段说明:
少了它,api_mcp_schema.json 与技能文档的两个生成脚本都跑不起来。
This commit is contained in:
Aqr-K
2026-09-12 19:05:05 -04:00
parent 4dbb8b8204
commit e47d7defe7
42 changed files with 2550 additions and 53 deletions

View File

@@ -142,6 +142,52 @@ class PluginInstanceOper(DbOper):
return self._execute_sync_write(stage)
def set_log_level(
self,
*,
instance_id: str,
log_level: Optional[str],
log_expires_at: Optional[str],
) -> bool:
"""写入或清除该实例的日志等级覆盖,没有实例行时按本体建出。
只设过等级、从没存过参数的插件同样要有一行,否则覆盖无处落盘;反过来,
清除覆盖后若该行是本体且各列皆空,整行一并回收,与清空业务参数同一规则,
不让只剩一对身份列的空行堆积。
:param instance_id: 实例 ID
:param log_level: 目标等级名,None 表示清除覆盖
:param log_expires_at: 覆盖失效时间的 ISO-8601 文本,None 表示不过期
:return: 是否发生了写入;清除一个本来就没有覆盖的实例返回 False
"""
def stage(session: Session) -> bool:
"""在同一事务内写入等级覆盖,并回收清空后只剩身份的本体行。"""
record = PluginInstance.get_by_instance_id(session, instance_id)
if record is None:
if log_level is None and log_expires_at is None:
return False
now = _now()
session.add(
PluginInstance(
instance_id=instance_id,
source_plugin_id=instance_id,
log_level=log_level,
log_expires_at=log_expires_at,
created_at=now,
updated_at=now,
)
)
return True
record.log_level = log_level
record.log_expires_at = log_expires_at
record.updated_at = _now()
if record.is_host and record.carries_only_identity:
session.delete(record)
return True
return bool(self._execute_sync_write(stage))
def clear_config_data(self, instance_id: str) -> bool:
"""清空某实例的业务参数,保留其身份与展示信息。