Files
HomeAgent/internal/lua/vm.go
JianFeeeee 41926e770d fix(lua): 适配器永久落后 —— 修死代码 + 改为显式 opt-in 同步
## 缺陷:适配器会永久卡在旧版

writeBundledAdapters 用清单里的 prevHash 判断「盘上有没有被人动过」,
被动的那个就保留。但清单只存 hash ——「内置旧版本的 hash」与
「用户改过的 hash」在清单里长得一模一样。于是:

    记了盘上那份当新基线 ⇒ 下次 onDisk == prevHash
      ⇒ 被误判成「没跟上新版本」⇒ 覆盖掉用户改动
    而保留分支里的 cur[name] = onDisk 又会

## 被循环末尾无条件 `cur[name] = sum` 冲掉

⇒ 判「用户改过」后清单仍记内置 hash ⇒ 下次再判一遍「用户改过」
⇒ **永久**停在旧版。日志只有一句「保留用户版本」,
看起来像用户自己的选择,完全看不出是卡住。

本机实测 8 个适配器中招(openai/anthropic/github/groq/mistral/ollama/
kimicode/server),落后内置版 3000~6500 字节,缺的是用量归一化
(cache_read / reasoning_tokens)、流式 tool_calls 等真实修复。
症状最终表现为「模型不听话」「工具调不动」——指不到适配器身上。

## 为什么不能靠「自动覆盖」解决

「旧发行版残留」与「用户手工改过」在文件内容上无法区分(都只是
hash 不等于内置版)。擅自改用户盘上的文件,比落后一个版本更糟。

所以改成**显式 opt-in**:

    core.lua.adapter_sync = off(默认)  不碰文件,但告警必须可操作
    core.lua.adapter_sync = auto         差异即备份 .bak-<hash8> 后覆盖

零值 = off:忘记注入配置时宁可不动用户文件,也不能默默全量覆盖。

## 告警要可操作,不是「保留用户版本」一句话

教训(2026-10-26 + 2026-11-11 两次):这类静默落后最难查,因为症状
指向模型而不是适配器。现在会给出:数量、名单、影响、三条处理路径
(跟随内核 / 长期定制改 adapter_path / 删掉旧文件)。

长期定制的正道是 `core.llm.sources.<name>.adapter_path`(每源独立可配),
不需要改内置位置的同名文件 —— 这一点也写进告警里。

## 判据 7 条,均经变异验证

    TestAdapterSyncDefaultsToOff            零值/未注入必须是 off
      (变异:return "off" → "auto" ⇒ 红)
    TestOffModeLeavesDiskUntouched          off 不碰文件、不产生备份
      (变异:case mode=="auto" → case mode!="" ⇒ 红)
    TestAutoModeUpdatesWithBackup           auto 更新且备份可回滚
    TestAutoModeNoRedundantBackup           一致时不重复堆 .bak
    TestAdapterStateStableAcrossRestarts    两种模式启停 3 次不漂移
    TestManifestCoversAllBundledAdapters    清单不漏项
    TestFirstInstallHappensInBothModes      off 只「不动已存在文件」,≠不安装

    go build / vet / gofmt / go test -race  全绿

## 生产现状(已按用户确认保留更新)

    适配器 10/10 与内置一致,8 个 .bak-<hash8> 可回滚
    服务 active,NRestarts=0
2026-11-11 21:39:03 +08:00

866 lines
24 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

package lua
import (
"crypto/hmac"
"crypto/sha256"
"embed"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"sort"
"strconv"
"strings"
"sync"
"time"
lua "github.com/yuin/gopher-lua"
)
//go:embed adapters/*.lua
var bundledAdapters embed.FS
// adapterGlobal 是每个 worker state 中保存适配器表的保留全局名。
const adapterGlobal = "__ha_adapter"
type APIAdapter struct {
Name string `json:"name"`
Version string `json:"version"`
}
// worker 封装一个独立的 gopher-lua 解释器。LState 非并发安全,
// 同一时刻只能被一个 goroutine 使用(从池中取出)。
type worker struct {
L *lua.LState
}
func (w *worker) close() {
if w.L != nil {
w.L.Close()
w.L = nil
}
}
// staticInfo 缓存适配器脚本在加载时提取出的不可变字段,
// Endpoint/Headers 等读取不需要占用池 worker。
type staticInfo struct {
name string
version string
endpoint string
headers map[string]string
}
// adapterPool 管理单个适配器的 worker 池。空闲 worker 存放在 idle 切片,
// 按 target 惰性创建;同一 worker 同一时刻只被一个 goroutine 使用。
type adapterPool struct {
name string
script string
static staticInfo
mu sync.Mutex
cond *sync.Cond
idle []*worker
created int
target int
closed bool
}
func newAdapterPool(name, script string, static staticInfo) *adapterPool {
p := &adapterPool{name: name, script: script, static: static, target: 1}
p.cond = sync.NewCond(&p.mu)
return p
}
func (p *adapterPool) setTarget(n int) {
p.mu.Lock()
p.target = n
p.cond.Broadcast()
p.mu.Unlock()
}
// boot 创建全新 gopher-lua 状态:注册共享全局、执行适配器脚本、
// 把返回表存到保留全局。compile Lua 的成本较高,尽量复用池。
func (p *adapterPool) boot() (*worker, error) {
L := lua.NewState()
setupGlobals(L)
if err := L.DoString(p.script); err != nil {
L.Close()
return nil, fmt.Errorf("compile adapter %s: %w", p.name, err)
}
tbl, ok := L.Get(-1).(*lua.LTable)
L.Pop(1)
if !ok {
L.Close()
return nil, fmt.Errorf("adapter %s must return a table", p.name)
}
L.SetGlobal(adapterGlobal, tbl)
return &worker{L: L}, nil
}
func (p *adapterPool) acquire() (*worker, error) {
p.mu.Lock()
for {
if p.closed {
p.mu.Unlock()
return nil, fmt.Errorf("adapter %s pool closed", p.name)
}
if n := len(p.idle); n > 0 {
w := p.idle[n-1]
p.idle = p.idle[:n-1]
p.mu.Unlock()
return w, nil
}
if p.created < p.target {
p.created++
break
}
p.cond.Wait()
}
p.mu.Unlock()
w, err := p.boot()
if err != nil {
p.mu.Lock()
p.created--
p.cond.Signal()
p.mu.Unlock()
return nil, err
}
return w, nil
}
func (p *adapterPool) release(w *worker) {
p.mu.Lock()
if p.closed {
p.mu.Unlock()
w.close()
return
}
p.idle = append(p.idle, w)
p.cond.Signal()
p.mu.Unlock()
}
func (p *adapterPool) shutdown() {
p.mu.Lock()
p.closed = true
idle := p.idle
p.idle = nil
p.cond.Broadcast()
p.mu.Unlock()
for _, w := range idle {
w.close()
}
}
// VM 聚合各适配器的 worker 池。所有导出方法并发安全。
type VM struct {
mu sync.RWMutex
dir string
pools map[string]*adapterPool
// adapterSync 决定盘上适配器与内置版不一致时怎么处理。
//
// "" / "off"(默认):不覆盖,只告警。
// "auto":备份为 <name>.lua.bak-<hash8> 后覆盖。
//
// ★ 为何默认 off:adapters/ 在**用户的数据目录**里,内核擅自改它比
// “落后一个版本”更糟;且“旧发行版残留”与“用户手工改过”在文件
// 内容上长得一模一样,无法自动区分。要长期定制就配 adapter_path。
adapterSync string
}
func NewVM(dir string) *VM {
return &VM{dir: dir, pools: map[string]*adapterPool{}}
}
// SetAdapterSync 设置适配器同步策略("off" / "auto")。由模块层从配置注入。
//
// 未经注入时保持零值 = off(保守)。这条选择很重要:忘记注入时
// 行为必须是“不动用户文件”,而不是“默默全量覆盖”。
func (v *VM) SetAdapterSync(mode string) { v.adapterSync = strings.ToLower(strings.TrimSpace(mode)) }
func (v *VM) AdapterSyncMode() string {
if v.adapterSync == "" {
return "off"
}
return v.adapterSync
}
func (v *VM) AdapterDir() string { return v.dir }
func (v *VM) Start() error {
if err := os.MkdirAll(v.dir, 0755); err != nil {
return fmt.Errorf("mkdir adapter dir: %w", err)
}
if err := v.writeBundledAdapters(); err != nil {
return fmt.Errorf("write bundled adapters: %w", err)
}
entries, err := os.ReadDir(v.dir)
if err != nil {
if os.IsNotExist(err) {
return nil
}
return err
}
for _, entry := range entries {
if filepath.Ext(entry.Name()) != ".lua" {
continue
}
path := filepath.Join(v.dir, entry.Name())
if err := v.LoadAdapter(path); err != nil {
fmt.Printf("[lua] preload %s: %v\n", entry.Name(), err)
}
}
return nil
}
func (v *VM) Stop() {
v.mu.Lock()
pools := v.pools
v.pools = map[string]*adapterPool{}
v.mu.Unlock()
for _, p := range pools {
p.shutdown()
}
}
// ConfigureConcurrency sets each adapter's worker target to the sum of every
// source's max concurrent calls that share the adapter. Clamped >= 1.
func (v *VM) ConfigureConcurrency(adapterConcurrency map[string]int) {
v.mu.RLock()
defer v.mu.RUnlock()
for name, n := range adapterConcurrency {
if p, ok := v.pools[name]; ok {
if n < 1 {
n = 1
}
p.setTarget(n)
}
}
}
func (v *VM) LoadAdapter(path string) error {
data, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read adapter: %w", err)
}
name := strings.TrimSuffix(filepath.Base(path), filepath.Ext(path))
return v.LoadAdapterSource(name, string(data))
}
func (v *VM) LoadAdapterSource(name, code string) error {
static, err := inspectScript(name, code)
if err != nil {
return err
}
target := 1
v.mu.RLock()
if p, ok := v.pools[static.name]; ok {
target = p.target
}
v.mu.RUnlock()
v.mu.Lock()
if p, ok := v.pools[static.name]; ok {
p.shutdown()
}
p := newAdapterPool(static.name, code, *static)
p.setTarget(target)
v.pools[static.name] = p
v.mu.Unlock()
// 立即 boot 一个 worker,把编译成本放到加载阶段而不是首个请求。
w, err := p.boot()
if err != nil {
return err
}
p.release(w)
return nil
}
func (v *VM) RemoveAdapter(name string) {
v.mu.Lock()
p := v.pools[name]
delete(v.pools, name)
v.mu.Unlock()
if p != nil {
p.shutdown()
}
}
func (v *VM) ListAdapters() []APIAdapter {
v.mu.RLock()
defer v.mu.RUnlock()
list := make([]APIAdapter, 0, len(v.pools))
for name, p := range v.pools {
list = append(list, APIAdapter{Name: name, Version: p.static.version})
}
sort.Slice(list, func(i, j int) bool { return list[i].Name < list[j].Name })
return list
}
func (v *VM) pool(name string) *adapterPool {
v.mu.RLock()
defer v.mu.RUnlock()
return v.pools[name]
}
// callMethod 在池 worker 上执行 adapter 的 fn(raw) 并返回字符串结果。
func callMethod(p *adapterPool, fn, raw string) (string, error) {
w, err := p.acquire()
if err != nil {
return "", err
}
defer p.release(w)
L := w.L
adapter := L.GetGlobal(adapterGlobal)
tbl, ok := adapter.(*lua.LTable)
if !ok {
return "", fmt.Errorf("adapter %s has no table", p.name)
}
f := tbl.RawGetString(fn)
if _, ok := f.(*lua.LFunction); !ok {
return "", fmt.Errorf("adapter %s missing %s", p.name, fn)
}
L.Push(f)
L.Push(lua.LString(raw))
if err := L.PCall(1, 1, nil); err != nil {
return "", fmt.Errorf("%s: %w", fn, err)
}
out := L.Get(-1)
L.Pop(1)
return out.String(), nil
}
func (v *VM) CallTransformRequest(name, rawJSON string) (string, error) {
p := v.pool(name)
if p == nil {
return "", fmt.Errorf("adapter %s not loaded", name)
}
return callMethod(p, "transform_request", rawJSON)
}
func (v *VM) CallTransformResponse(name, rawJSON string) (string, error) {
p := v.pool(name)
if p == nil {
return rawJSON, nil
}
out, err := callMethod(p, "transform_response", rawJSON)
if err != nil {
return "", err
}
return out, nil
}
// TransformError 执行可选的 adapter.transform_error(status, body) 钩子。
// 返回 reason: 适配器提取的错误原因;ok=false 表示适配器未定义此钩子。
func (v *VM) TransformError(name string, status int, body string) (reason string, ok bool, err error) {
p := v.pool(name)
if p == nil {
return "", false, nil
}
w, err := p.acquire()
if err != nil {
return "", false, err
}
defer p.release(w)
L := w.L
adapter := L.GetGlobal(adapterGlobal)
tbl, ok2 := adapter.(*lua.LTable)
if !ok2 {
return "", false, nil
}
f := tbl.RawGetString("transform_error")
if _, ok := f.(*lua.LFunction); !ok {
return "", false, nil
}
L.Push(f)
L.Push(lua.LNumber(status))
L.Push(lua.LString(body))
if err := L.PCall(2, 1, nil); err != nil {
return "", false, fmt.Errorf("transform_error: %w", err)
}
res := L.Get(-1)
L.Pop(1)
if res.Type() != lua.LTString {
return "", false, nil
}
return res.String(), true, nil
}
func (v *VM) CallTransformStreamChunk(name, rawLine string) (string, error) {
p := v.pool(name)
if p == nil {
return rawLine, nil
}
out, err := callMethod(p, "transform_stream_chunk", rawLine)
if err != nil {
return rawLine, nil
}
if out == "" {
return "", nil
}
return out, nil
}
func (v *VM) GetAdapterEndpoint(name string) string {
p := v.pool(name)
if p == nil {
return ""
}
p.mu.Lock()
defer p.mu.Unlock()
return p.static.endpoint
}
func (v *VM) GetAdapterHeaders(name string) map[string]string {
p := v.pool(name)
if p == nil {
return nil
}
p.mu.Lock()
defer p.mu.Unlock()
hdr := make(map[string]string, len(p.static.headers))
for k, h := range p.static.headers {
hdr[k] = h
}
return hdr
}
// BuildHeaders executes adapter.build_headers(meta); falls back to the static
// adapter.headers table when no dynamic hook is defined.
func (v *VM) BuildHeaders(name string, meta map[string]interface{}) (map[string]string, error) {
p := v.pool(name)
if p == nil {
return nil, fmt.Errorf("adapter %s not loaded", name)
}
w, err := p.acquire()
if err != nil {
return nil, err
}
defer p.release(w)
L := w.L
adapter := L.GetGlobal(adapterGlobal)
tbl, ok := adapter.(*lua.LTable)
if !ok {
return nil, fmt.Errorf("adapter %s has no table", name)
}
f := tbl.RawGetString("build_headers")
if _, ok := f.(*lua.LFunction); !ok {
// no dynamic hook -> static headers
p.mu.Lock()
hdr := make(map[string]string, len(p.static.headers))
for k, h := range p.static.headers {
hdr[k] = h
}
p.mu.Unlock()
return hdr, nil
}
L.Push(f)
L.Push(goValueToLua(L, meta))
if err := L.PCall(1, 1, nil); err != nil {
return nil, fmt.Errorf("build_headers: %w", err)
}
res := L.Get(-1)
L.Pop(1)
tbl2, ok := res.(*lua.LTable)
if !ok {
return nil, fmt.Errorf("build_headers returned non-table")
}
headers := make(map[string]string)
tbl2.ForEach(func(key, val lua.LValue) {
headers[key.String()] = val.String()
})
return headers, nil
}
// ---- static inspection (compile-once at load time) ----
func inspectScript(name, code string) (*staticInfo, error) {
L := lua.NewState()
setupGlobals(L)
if err := L.DoString(code); err != nil {
L.Close()
return nil, fmt.Errorf("compile adapter: %w", err)
}
tbl, ok := L.Get(-1).(*lua.LTable)
L.Pop(1)
if !ok {
L.Close()
return nil, fmt.Errorf("adapter script must return a table")
}
info := &staticInfo{name: name, headers: map[string]string{}}
if n := readString(tbl, "name"); n != "" {
info.name = n
}
info.version = readString(tbl, "version")
info.endpoint = readString(tbl, "endpoint")
if ht, ok := tbl.RawGetString("headers").(*lua.LTable); ok {
ht.ForEach(func(k, val lua.LValue) {
info.headers[k.String()] = val.String()
})
}
L.Close()
return info, nil
}
func readString(tbl *lua.LTable, field string) string {
v := tbl.RawGetString(field)
switch x := v.(type) {
case lua.LString:
return string(x)
case lua.LNumber:
return strconv.FormatFloat(float64(x), 'f', -1, 64)
default:
return ""
}
}
// ---- shared globals installed into every worker state ----
func setupGlobals(L *lua.LState) {
L.SetGlobal("log", L.NewFunction(func(L *lua.LState) int {
level := L.ToString(1)
msg := L.ToString(2)
fmt.Printf("[lua/%s] %s\n", level, msg)
return 0
}))
jsonTable := L.NewTable()
L.SetGlobal("json", jsonTable)
L.SetField(jsonTable, "encode", L.NewFunction(func(L *lua.LState) int {
val := L.CheckAny(1)
goVal := luaValueToGo(val)
b, err := json.Marshal(goVal)
if err != nil {
L.Push(lua.LString("null"))
return 1
}
L.Push(lua.LString(string(b)))
return 1
}))
L.SetField(jsonTable, "decode", L.NewFunction(func(L *lua.LState) int {
str := L.CheckString(1)
var val interface{}
if err := json.Unmarshal([]byte(str), &val); err != nil {
L.Push(lua.LNil)
return 1
}
L.Push(goValueToLua(L, val))
return 1
}))
L.SetGlobal("http_get", L.NewFunction(func(L *lua.LState) int {
url := L.ToString(1)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Get(url)
if err != nil {
errJSON, _ := json.Marshal(map[string]interface{}{"url": url, "error": err.Error()})
L.Push(lua.LString(string(errJSON)))
return 1
}
defer resp.Body.Close()
body, _ := io.ReadAll(resp.Body)
result, _ := json.Marshal(map[string]interface{}{"url": url, "status": resp.StatusCode, "body": string(body)})
L.Push(lua.LString(string(result)))
return 1
}))
L.SetGlobal("http_post", L.NewFunction(func(L *lua.LState) int {
url := L.ToString(1)
body := L.ToString(2)
client := &http.Client{Timeout: 30 * time.Second}
resp, err := client.Post(url, "application/json", strings.NewReader(body))
if err != nil {
errJSON, _ := json.Marshal(map[string]interface{}{"url": url, "error": err.Error()})
L.Push(lua.LString(string(errJSON)))
return 1
}
defer resp.Body.Close()
respBody, _ := io.ReadAll(resp.Body)
result, _ := json.Marshal(map[string]interface{}{"url": url, "body": string(respBody), "status": resp.StatusCode})
L.Push(lua.LString(string(result)))
return 1
}))
// 签名辅助(kimicode 等上游签名型 adapter 需要)
L.SetGlobal("hmac_sha256_hex", L.NewFunction(func(L *lua.LState) int {
key := L.ToString(1)
data := L.ToString(2)
m := hmac.New(sha256.New, []byte(key))
m.Write([]byte(data))
L.Push(lua.LString(hex.EncodeToString(m.Sum(nil))))
return 1
}))
L.SetGlobal("sha256_hex", L.NewFunction(func(L *lua.LState) int {
h := sha256.Sum256([]byte(L.ToString(1)))
L.Push(lua.LString(hex.EncodeToString(h[:])))
return 1
}))
L.SetGlobal("base64_encode", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(base64.StdEncoding.EncodeToString([]byte(L.ToString(1)))))
return 1
}))
L.SetGlobal("tohex", L.NewFunction(func(L *lua.LState) int {
L.Push(lua.LString(hex.EncodeToString([]byte(L.ToString(1)))))
return 1
}))
}
// bundledAdapterNames 是内核内置的适配器清单。
//
// 单独提出来:writeBundledAdapters 与体检判据共用,避免两处各写一份而漏掉
// 某个(漏掉的后果是该适配器永远不会被更新)。
var bundledAdapterNames = []string{
"openai", "anthropic", "deepseek", "gemini",
"github", "groq", "mistral", "ollama", "kimicode",
"server",
}
// writeBundledAdapters 把内嵌适配器落到 DataDir/adapters。
//
// ★ 原本是 `if 文件已存在 { continue }` —— 后果是「修了适配器 → 升级二进制
//
// → 已部署实例上的文件不更新」。这正是仓库 openai.lua 缺 stream_index
// 透传、而生产早有(2026-08-26 15:46 手工补上,比入库早 32 分钟)却长期
// 没人发现的机制性原因。
//
// 现在的判据(按内容,不按存在):
//
// 文件不存在 ⇒ 写
// 有历史清单且盘上 == 上次内嵌 ⇒ 用新的覆盖(只是没跟上新版本)
// 有历史清单但盘上 != 上次内嵌 ⇒ 不动(用户改过,静默覆盖等于丢修改)
// 无历史清单(首跑/从旧版本升级) ⇒ 不动,只补缺失的文件
//
// "上次内嵌的版本"记在 DataDir/adapters/.bundled(`<name>\t<sha256>`)。
//
// ⚠️ 代价:升级到本版本的**那一次**,已部署实例上的适配器不会更新
//
// (没有历史清单可比)。从第二次升级起自动生效。要立刻生效就删掉
// DataDir/adapters 让内核重新解包。
//
// adapterVersion 从适配器源码里取 `adapter.version = "x"`(取不到返回 "?")。
//
// 用途只有一个:让漂移告警能说清"盘上是哪一版、内置是哪一版",
// 而不是只让运维看两个 sha。判据不依赖它。
func adapterVersion(src string) string {
const key = "adapter.version"
i := strings.Index(src, key)
if i < 0 {
return "?"
}
rest := src[i+len(key):]
if j := strings.Index(rest, "\""); j < 0 {
return "?"
} else {
rest = rest[j+1:]
}
if k := strings.Index(rest, "\""); k >= 0 {
return rest[:k]
}
return "?"
}
func (v *VM) writeBundledAdapters() error {
cur := map[string]string{}
updated := 0
mode := v.AdapterSyncMode()
// drifted 收集「盘上与内置不一致」的适配器,用于末尾一条汇总告警。
// 逐条打日志在 off 模式下会刷屏(本机一次 8 条),而人真正需要的是
// 「有 N 个落后了 + 怎么处理」这一句。
type driftInfo struct {
name string
disk string
bundled string
}
var drifted []driftInfo
for _, name := range bundledAdapterNames {
data, err := bundledAdapters.ReadFile("adapters/" + name + ".lua")
if err != nil {
continue
}
sum := sha256Hex(data)
dstPath := filepath.Join(v.dir, name+".lua")
// ── 盘上已有:比对 hash ──
//
// ## 这里为什么不再用「清单里的 prevHash」判断
//
// 旧实现拿上次清单做基线,认为「onDisk == prevHash ⇒ 没人动过 ⇒ 可覆盖」。
// 但清单只存 hash,而「内置旧版本的 hash」与「用户改过的 hash」
// 在清单里长得一模一样 —— 于是:
//
// 记了盘上那份当新基线 ⇒ 下次 onDisk == prevHash
// ⇒ 被误判成「没跟上新版本」⇒ 覆盖掉用户改动
// 保留分支又因为末尾无条件 cur[name]=sum 把基线改回内置 hash
// ⇒ **永久**停在旧版,日志只说「保留用户版本」,看不出是卡住
//
// 本机实测 8 个适配器中招(落后 3000~6500 字节,缺用量归一化、
// 流式 tool_calls 等修复)。既然没法从文件本身区分这两种来路,
// 就**不猜**:默认一律不动(off),要动就由用户显式开 auto。
if old, rerr := os.ReadFile(dstPath); rerr == nil {
onDisk := sha256Hex(old)
switch {
case onDisk == sum:
// 与内置一致,无需动作
case mode == "auto":
// 显式允许:备份后覆盖。
// 备份失败就不覆盖 —— 宁可保留旧版,也不能无声丢用户的改动。
bak := dstPath + ".bak-" + onDisk[:8]
if werr := os.WriteFile(bak, old, 0644); werr != nil {
fmt.Printf("[lua] WARNING: adapter %s 与内置版不一致,但备份到 %s 失败(%v);"+
"本次不覆盖,继续用盘上旧版。\n", name+".lua", filepath.Base(bak), werr)
cur[name] = onDisk
continue
}
if werr := os.WriteFile(dstPath, data, 0644); werr != nil {
return fmt.Errorf("write %s: %w", name, werr)
}
updated++
fmt.Printf("[lua] adapter %s 与内置版不一致(v%s → v%s),已备份为 %s 并更新\n",
name+".lua", adapterVersion(string(old)), adapterVersion(string(data)), filepath.Base(bak))
default:
// off(默认):不碰用户文件,但记下来后面汇总告警。
drifted = append(drifted, driftInfo{name: name + ".lua", disk: onDisk, bundled: sum})
cur[name] = onDisk
}
} else {
// 盘上没有:直接落盘(首次安装,不属于「改动用户文件」)
if werr := os.WriteFile(dstPath, data, 0644); werr != nil {
return fmt.Errorf("write %s: %w", name, werr)
}
updated++
fmt.Printf("[lua] installed bundled adapter: %s\n", name+".lua")
}
if _, set := cur[name]; !set {
cur[name] = sum
}
}
// ★ off 模式的告警必须**可操作**。
//
// 教训(2026-10-26 + 2026-11-11):这里曾经只打一句「保留用户版本」,
// 结果 8 个适配器落后内置版几个月都没人发现,而症状是「模型不听话」
// 「工具调不动」这种完全指不到适配器的地方。
// 所以告警要给出:数量、落后在哪个版本、以及一条能直接跑的命令。
if len(drifted) > 0 {
names := make([]string, 0, len(drifted))
for _, d := range drifted {
names = append(names, d.name)
}
fmt.Printf("[lua] WARNING: %d 个适配器与内置版不一致,当前保留盘上版本(core.lua.adapter_sync=off):\n", len(drifted))
fmt.Printf("[lua] %s\n", strings.Join(names, ", "))
fmt.Printf("[lua] 影响:盘上版本可能缺少内核已修的问题(用量统计、流式 tool_calls 等),\n" +
"[lua] 症状常表现为「模型不听话」或「工具调不动」,容易误判成模型问题。\n")
fmt.Printf("[lua] 处理:① 想跟随内核 ⇒ 设 core.lua.adapter_sync=auto 后重启(会自动备份旧版);\n"+
"[lua] ② 想长期定制 ⇒ 把改过的文件改名,并配 core.llm.sources.<name>.adapter_path 指过去;\n"+
"[lua] ③ 只是旧发行版残留 ⇒ 删掉 %s 下对应文件后重启即可。\n", v.dir)
}
if err := v.writeBundledManifest(cur); err != nil {
fmt.Printf("[lua] 写内嵌清单失败(仅影响状态快照): %v\n", err)
}
if updated > 0 {
fmt.Printf("[lua] updated %d bundled adapter(s)\n", updated)
}
return nil
}
func (v *VM) manifestPath() string { return filepath.Join(v.dir, ".bundled") }
// 注:原先还有一个 readBundledManifest(读上次清单做 prevHash 比对)。
// 2026-11-11 重设计后不再需要它:现在的判断只看「盘上 hash 与内置 hash 是否相等」,
// 不依赖历史基线(那套基线无法区分「内置旧版」与「用户改过」,
// 正是导致 8 个适配器永久卡住的根源)。清单现在只作为**可读的状态快照**保留。
func (v *VM) writeBundledManifest(m map[string]string) error {
var sb strings.Builder
for _, name := range bundledAdapterNames {
if h, ok := m[name]; ok {
sb.WriteString(name + "\t" + h + "\n")
}
}
tmp := v.manifestPath() + ".tmp"
if err := os.WriteFile(tmp, []byte(sb.String()), 0644); err != nil {
return err
}
return os.Rename(tmp, v.manifestPath())
}
func sha256Hex(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func luaValueToGo(lv lua.LValue) interface{} {
switch v := lv.(type) {
case lua.LString:
return string(v)
case lua.LNumber:
return float64(v)
case lua.LBool:
return bool(v)
case *lua.LTable:
if v.MaxN() > 0 {
arr := make([]interface{}, 0, v.MaxN())
v.ForEach(func(_, val lua.LValue) {
arr = append(arr, luaValueToGo(val))
})
return arr
}
m := make(map[string]interface{})
v.ForEach(func(key, val lua.LValue) {
m[key.String()] = luaValueToGo(val)
})
return m
default:
return nil
}
}
func goValueToLua(L *lua.LState, val interface{}) lua.LValue {
switch v := val.(type) {
case string:
return lua.LString(v)
case float64:
return lua.LNumber(v)
case int:
return lua.LNumber(v)
case int64:
return lua.LNumber(v)
case bool:
return lua.LBool(v)
case nil:
return lua.LNil
case []interface{}:
tbl := L.NewTable()
for i, item := range v {
tbl.RawSetInt(i+1, goValueToLua(L, item))
}
return tbl
case map[string]interface{}:
tbl := L.NewTable()
for k, item := range v {
tbl.RawSetString(k, goValueToLua(L, item))
}
return tbl
default:
b, jerr := json.Marshal(v)
if jerr == nil {
var iv interface{}
if json.Unmarshal(b, &iv) == nil {
return goValueToLua(L, iv)
}
}
return lua.LNil
}
}