13 Commits

Author SHA1 Message Date
1b792b91f5 fix(packaging): amd64 GUI 从未走过 electron 缓存,且空壳 node_modules 被当作已安装
干净 worktree 上打包时 GUI 被静默跳过。两个缺陷叠加,都属于「所有外层
检查都通过,只有嵌套的运行时缺失,而没有任何东西喊出来」。

## 一:electron 架构名与 Debian 架构名混用

electron 官方发布物命名用 x64/arm64,Debian 用 amd64/arm64。缓存查找
一直统一用 TAR_ARCH(amd64),于是 electron-v*-linux-x64.zip 永远命中
不到。arm64 两边恰好同名,所以上次修 arm64 GUI 架构污染(743b963)时
这个不一致没暴露。

推论:v1.0.3 的 amd64 GUI 实际是靠「回退到 host node_modules/electron/
dist」这条路组装的,不是走缓存——那条回退只在目标架构 == host 架构时
才允许,恰好成立所以没出错。干净 checkout 里没有完整 node_modules,
回退路径也没有,GUI 就消失了。

修法:单独映射 ELECTRON_ARCH(amd64→x64,arm64→arm64)。

## 二:判 node_modules 目录存在,而非判 electron 包存在

npm install 失败(离线/网络受限)会留下只有一两个条目的空壳
node_modules。原判据 [ ! -d node_modules ] 认为「已安装」,于是跳过
install → ever 读不到版本 → 缓存匹配退化到通配 → host dist 也没有 →
静默跳过 GUI。包名、目录名、变体名全部正确,只是没有 GUI。

修法:判据改为 electron/package.json 是否存在;目录在而包缺失时明确
说明「疑似上次 npm install 未完成」再重试;install 失败给出明确提示
而不是继续往下走。

顺带给 ever 加兜底:读不到已安装版本时从 package.json 的依赖声明取
数字部分(那里是 "^33.0.0" 这类范围,仅用于给缓存匹配一个提示)。

## 验证

干净 worktree(/tmp/rel104,release/v1.0.x)上重跑:
  node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)
  electron 版本取自 package.json 依赖声明: 33.0.0(非精确)
  electron runtime: electron-v33.4.11-linux-x64.zip
  GUI built: build/homeagent-gui-linux-amd64 (263M, x86-64)

file -b 确认 electron 二进制为 x86-64,与目标架构一致(该硬校验由
743b963 引入,此处继续生效)。
2026-09-05 14:59:15 +08:00
208d39c296 chore(release): bump v1.0.4
v1.0.3 之后 release 线合入了两个修复(崩溃隔离测试误杀生产实例、
两处数据竞争),按发布流程第②步把版本号推进到 1.0.4。

同步更新:
  - internal/meta/meta.go    Version 1.0.3 → 1.0.4
  - deploy/packaging/installer.nsi   PRODUCT_VERSION 1.0.4
  - README.md / README_EN.md 项目状态加 v1.0.4 条目、下载文件名更新

SDKCompatibleVersion 保持 1.0.0:本版只改内核并发正确性,插件 ABI/
协议未变,存量 plugin.bin 无需重编。

此提交不 pick 回 main(main 版本号始终是下一个未发布版本)。
2026-09-05 07:26:01 +08:00
eb02f00998 fix(plugins): 修复隔离全量测试暴露的两处数据竞争
-go test ./... -race 全仓复验暴露的 7 处 race、5 个失败测试,全部定位。
两处独立缺陷,互不相关。

## 缺陷一(remotedevice,8 处 race):连接写无串行化

WARNING: DATA RACE
  Read at 0x... by goroutine 28:
    bufio.(*Writer).Available() / writeFrameHeader / PushData
  Previous write at 0x... by goroutine 27:
    bufio.(*Writer).Flush() / writeFrame / handleWS

同一连接的 bufio.Writer 被两条并发路径写:
  - handleWS 主循环:读到设备帧后回写 hello_ack/bind_ack/pong
  - PushJSON/PushData:agent→设备的下发路径,可来自任意 goroutine

bufio.Writer 不是线程安全的。不加锁就在 WriteByte/Flush 上撞——这
不是理论风险,TestWSPushDataAudio 的异步 PushData 与 handleWS 的
hello_ack 回写并发时被 -race 稳定抓到。

修法:wconn 增加 wmu(sync.Mutex),PushJSON/PushData 拿锁后整条
下发(start + N 个 chunk + end)持锁——设备侧按协议串行聚合,中途
被插帧会破坏协议顺序。handleWS 的 hello_ack/bind_ack/pong 也改走
同一把锁(wsWriteLocked 封装,避免调用方绕过)。设备已离线时不回写。

关键点:不能只锁 Push* 不锁 handleWS——那只是把竞争挪了个位置。

## 缺陷二(agentcli,1 处 race):共享读缓冲被并发读写

WARNING: DATA RACE
  Write at 0x... by goroutine 26:
    os.File.Read / (*linuxPty).Read / reader
  Previous read at 0x... by goroutine 25:
    runtime.slicecopy / readLoop

readLoop 创建 buf := make([]byte, ReadBufSize) 传给 reader goroutine
(t.session.Read(buf) 持续覆写),自己又在读到结果后
copy(data, buf[:r.n])——同一缓冲被读写并发。Go 的 pty 读走 OS 层
fd,专门在 reader 写下一段时读,跑 -race 稳定复现。

修法:readResult 携带 data field,reader 每次读完后把数据复制进自己
分配的切片再随结果传递,读取与拷贝之间不再共享任何可变状态。原 buf
保留(仍由 reader 独享用于 OS 读),readLoop 不再从其中 copy。

## 验证

  - 两个插件包 -race -count=2 全过
  - 全仓 go build / go vet / go test 通过
  - 全仓 go test ./... -race:32 包全过,0 DATA RACE,0 FAIL
  - SDK 冻结 diff = 0

其中 remotedevice 的 TestScreenseeEndToEnd / TestComputeruseEndToEnd
/ TestClipboardEndToEnd 原本因 race 挂,修后恢复全绿。
2026-09-05 07:24:02 +08:00
f478659b89 fix(test): 崩溃隔离测试误杀同机生产插件,且断言无效
## 现象

生产 homed 的 editdoc 子进程从 9-03 起被 SIGKILL 9 次,间隔完全不规律
(126~485 分钟),全部发生在无工具调用的空闲期。查过 OOM(dmesg/
journalctl -k/cgroup oom_kill 全为 0)、systemd 内存限制(MemoryMax=
infinity)、cron/timer、内核自身的 StopAll 路径、agent 执行过的 cmd_run
命令,以及 Pdeathsig 绑创建线程的可能——全部排除。

## 真因:测试杀了生产的进程

用 ftrace 的 signal_generate tracepoint 挂监视器后抓到发送者 cmdline:

  /tmp/go-build.../plugins.test -test.run=TestRealPlugin_CrashDoesNotKillKernel

findPluginPID 用**全系统** `pgrep -f plugin.bin`,然后只比"exe 路径含
editdoc"。生产实例的 /home/newqqagent/plugins/editdoc/plugin.bin 也满足
这个条件,谁先被 pgrep 列出来就杀谁。9 次 kill 全部落在有人跑 go test
的时段——18:18:30 那次正是一轮 `go test ./... -race` 的窗口。

之前几轮排查一直在生产实例内部找原因,方向从一开始就错了:杀手在仓库里。

## 更严重的是这个测试本身无效

旧断言是"SIGKILL 之后内核仍存活"。可内核本来就活着——即使信号发错了
对象(杀了生产实例的插件),测试内核的插件压根没死,断言照样通过。
**它在测一件没发生的事**,同时把生产环境打坏了,而绿色的测试结果掩盖了
这一切。这也是它能连续 9 次造成生产故障却从没被注意到的原因。

## 修法

findPluginPID 增加 root 硬约束:/proc/<pid>/exe 必须以测试自己的 plgDir
为前缀,且是目标插件,两道条件同时成立才算命中。root 为空直接 t.Fatal
——这不是可选过滤器,是防误杀的前提。

  - 用 exe 而非 cmdline:cmdline 可被进程自行改写,exe 符链由内核维护。
  - root 先过 EvalSymlinks:/tmp 在部分发行版上是符链,不归一化会让前缀
    比较永远不命中,退化成静默 Skip(那样测试就白跑了)。

断言改成两步:先轮询确认目标进程真的退出(3s 上限),再验内核未被连带。
两步都成立才能证明隔离生效。

## 验证

修复后跑 TestRealPlugin_CrashDoesNotKillKernel:
  - 杀的是 pid=3448366,exe 在 /tmp/hc_integration_3823918128/plugins 下 ✓
  - 生产 editdoc pid 测试前后均为 3362892,存活时长连续 ✓
  - 四个 TestRealPlugin_* 全部 PASS
2026-09-04 21:44:19 +08:00
743b963dec fix(packaging): arm64 GUI 塞了 x86-64 electron——按目标架构取运行时并强制校验
## 现象

v1.0.0 与 v1.0.1 的 arm64 full/client 包里,homed 与 waiter 都是正确的
aarch64,但 GUI 目录下的 electron 是 x86-64。实测从 gitcode 下载的
homeagent-full_1.0.1_arm64.deb:

  usr/bin/homed                    ELF 64-bit ARM aarch64   ✓
  usr/bin/waiter                   ELF 64-bit ARM aarch64   ✓
  usr/lib/homeagent-gui/electron   ELF 64-bit x86-64        ✗

在 arm64 机器上装完,双击 GUI 得到 Exec format error。

## 根因

build_gui 无条件 `cp -r "$gui_dir/node_modules/electron/dist"/*`,而那里
永远是 **host 架构**(本机 x64)。目录名 homeagent-gui-linux-arm64 只是
命名,内容从未跟着目标架构变。

这与 v1.0.0 arm64 缺 homed 是同一类错误:**产物名声称的架构与实际内容
不符**,且都因为没做交叉验证而漏过整个发布流程——包名对、目录名对、
主二进制对,只有一个嵌套的运行时是错的,没有任何一环会喊出来。

## 修法:三层取 + 一道强制校验

1. 优先从 electron 缓存取目标架构的 zip
   (~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip)。
   版本号从已安装的 node_modules/electron/package.json 读,保证运行时
   与 app 依赖一致。
2. 回退到 host node_modules/electron/dist 前**先比对架构**:只有目标
   架构 == host 架构才允许;否则打印缺哪个 zip、该放哪里,然后跳过 GUI。
3. 最后用 `file -b` 校验 electron 二进制的实际架构必须匹配目标架构,
   不符就删掉 GUI 目录并跳过。

第 3 步是关键。前两步是「尽量拿对的」,第 3 步是「绝不发错的」——
宁可不发 GUI,也不发装了跑不起来的包。`GUI built:` 日志行也加上架构
标注,日常构建就能看见。

## 验证

下载 arm64 electron 运行时(electron-v33.4.11-linux-arm64.zip,106MB,
unzip -t 无错,解出的 electron 确认为 ARM aarch64)放入缓存后重打包,
三个 arm64 deb 实测:

  server   homed=aarch64  waiter=aarch64
  full     homed=aarch64  waiter=aarch64  electron=aarch64
  client                  waiter=aarch64  electron=aarch64
  amd64 对照                              electron=x86-64

arm64 tar.gz 从 120M 涨到 125M,也印证运行时换成了正确架构。
2026-09-04 20:06:14 +08:00
6b87a1de14 fix(build): GUI 输出目录用 --config.directories.output,-o 是 --mac 的别名
electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的
Building 段),不是 output。于是 `-o "$BUILD_DIR"` 被当成 macOS 的 target
列表,报:

  ⨯ Unknown target: /home/program/trueagent/build

路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的
——这也是它看起来像「路径错」而实际是「参数位置错」的原因,v1.0.1 与
v1.0.3 两次发布都因此手工组装过 GUI。

改用 --config.directories.output=<dir>,已实测确认产物落在指定目录。

同时把 GUI 构建失败降级为警告:homed/waiter/initconfig 是发布主体,
而 GUI 依赖 electron 运行时下载(离线机器、arm64 缺缓存都会失败)。
set -euo pipefail 下不接住的话,一个可选组件会让整轮跨平台构建全废——
v1.0.3 就是这样只产出了 linux/amd64 三个二进制、arm64 与 windows
压根没跑到。
2026-09-04 19:01:29 +08:00
26dc76f1a6 chore(release): 版本号 bump 到 1.0.3
1.0.x 发布线的第二个 patch。SDKCompatibleVersion 保持 1.0.0:
本次是纯内核修复,ProtocolVersion 未变,1.0.0 编出的 plugin.bin 无需重编。

跳过 1.0.2:该号未曾发布也无 tag,留空以免与任何本地构建混淆。

README/README_EN 补 v1.0.3 条目并把安装包文件名示例更新到 1.0.3;
installer.nsi 的 PRODUCT_VERSION 缺省值同步(正式构建仍由
makensis -DPRODUCT_VERSION 注入,这里只是本地手工构建的兜底)。
2026-09-04 18:41:54 +08:00
440704cf27 fix(proc): stage 协调器双重解锁——内核本体 fatal 崩溃的真因
## 现象

2026-09-04 06:56:18 生产 homed 主进程直接死亡,退出码 2,
带走全部 27 个子进程插件。

  fatal error: sync: unlock of unlocked mutex
  proc.(*Host).endStage(...)             host.go:189
  proc.(*coreHandler).runStage.func1()   stage.go:94
  core.(*StageHost).RunStage.func1()     stages.go:190

stage.go:94 与 stages.go:190 各有一层 recover,专为「插件出错不拖垮内核」
而设,却全部失效:**sync.Mutex 的双重解锁走 runtime fatal,不是 panic,
recover 结构上就拦不住**。这就是本次「插件崩溃被隔离」的设计没能生效、
内核本体整体死亡的原因。

## 根因

endStage 把 coord.leave()(递减 inflight、判定「我是最后离开者」)放在
coordMu 临界区**之外**,而摘除 h.coord 在临界区**之内**,留出窗口:

  A.endStage: leave() → inflight 1→0, last=true,尚未摘除 h.coord
  B.beginStage: 看到 h.coord != nil,以「后到者」身份 enter,inflight 0→1
                (后到者按设计不取 stageMu)
  A.endStage: h.coord = nil;stageMu.Unlock()                    ← 第 1 次
  B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥

B 从未持有 stageMu,却因挂进一个正在收尾的协调器而被判成「最后离开者」,
对同一把锁解了两次。崩溃前一行日志是 config_list_keys 的结果——那一刻
正好有 stage 扇出,与竞态窗口重合。

## 修复

把「递减 inflight → 判定最后离开者 → 摘除 h.coord」收进同一个 coordMu
临界区,后到者再不可能挂进已收尾的协调器。为此把 leave() 拆成:
  - depart():纯计数,由 endStage 在 coordMu 内调用
  - finish():共享段回读 + arena 压实,在 coordMu 外、但仍在
    stageMu.Unlock() 之前(先放锁会让下一轮 stage 在回读未完时改写共享段)
leave() 保留给单测。

同一函数的第二个隐患一并修掉:首进者的 enter()(含 WriteAll 写共享段)
原先在 coordMu 之外,后到者可能拿到 coord 就去读**写了一半**的段。
现在 enter() 在锁内完成。

beginStage 错误路径的 stageMu.Unlock() 必须保留并已加注释说明:
runStage 的 defer endStage(coord) 是在 beginStage 返回 err 的检查**之后**
才注册的,这条路径上没有任何人会替它解锁,漏掉就是整个 stage 通道永久卡死。

锁序 stageMu → coordMu;endStage 只解锁 stageMu 不获取,无环。

## 验证

反向验证:把 host.go stash 回旧版跑新测试 → fatal error: sync: unlock of
unlocked mutex;恢复修复 → 通过。测试抓的确实是这个缺陷。

5 个回归用例(host_stage_test.go):
  - 后到者不复用已收尾的协调器(直接构造那个时序,不靠调度巧合)
  - 8 worker × 40 轮并发进出(旧实现下整个测试二进制 fatal 而非 FAIL)
  - 同阶段多插件扇出共用一个协调器、仅最后离开者解锁
  - 50 轮串行不泄漏(少解锁会在第二轮卡死)
  - 四阶段序列 pre_action→chat→after_toolcall→post_action

internal/plugin/... 全量 -race -count=2 通过。

## 同类缺陷审计(本 commit 未改动其他文件,仅记录结论)

针对「recover 拦不住的 runtime fatal」这一整类做了全仓审计:

1. 跨函数持锁(本缺陷的形状,脚本枚举 Lock/Unlock 不配对的函数)
   - proc/lock.go 的 Release/ForceRelease 同样「只 Unlock 不 Lock」,
     但两者都在 ownerMu 下先检查 held/owner 再解锁,非持有者直接返回,
     不存在双解锁路径。
   - 其余 22 处 Lock/Unlock 计数不等的函数逐一复核:全部是多分支早退各自
     解锁(waiter 的 goto nextMessage、sidecar.call 的五个错误分支、
     lua adapterPool 的 cond.Wait 池模式等),配对正确。
2. 并发 map 读写(同样是 runtime fatal)
   - 16 处「无锁访问 map」全部复核为安全:Locked 后缀约定(orderedLocked、
     defsLockedRegisterSource)、调用方持锁(document 的 addSummary/
     removeDoc/loadAll、registry 的 runStopHandlers/runOnRemoveHandlers)、
     或启动期单线程(knowledge.scanAll、static_embedder 构造后只读)。
3. close of closed channel
   - 全仓仅 sidecar.go 有同名变量的两处 close(ch),但作用于不同集合成员,
     且 Close() 前有 readerWg.Wait() 与 stopped 标志,reader 侧已 delete
     出 pending,不会双关。
   - 各插件 stopCh 的 close:healthcheck 用 select 守卫、clawhubadapter 用
     stopOnce、evtring 用 running 标志、timer 交给 StopHandler 单次调用。
     agentcli.Stop() 是裸 close(p.stopCh) 无幂等守卫,但 Registry 的六处
     Stop 调用点都在同一把 r.mu 下先 delete(r.plugins)+摘 r.instances 再
     Stop,不存在二次调用路径——记录为「依赖调用方约定」而非当前缺陷。
4. WaitGroup 误用:未发现 Add 出现在 goroutine 体内的形状。
5. 全仓 go test ./... -race:零 DATA RACE、零 FAIL。
2026-09-04 18:37:13 +08:00
c08383dc4b fix(release): upload_assets.py 按 go.mod 定位仓库根,不再数 dirname
脚本从 scripts/ 移到 deploy/scripts/ 后目录深度 1→2,而两层 dirname
是写死的,于是资产目录解析成 deploy/dist/release,上传直接
FileNotFoundError(v1.0.1 首次上传即因此失败)。

这与 v0.7.2 的 2c5f9ff 把 package/ 移到 deploy/packaging/ 打断
build.sh 的 PROJECT_ROOT 是同一个坑:目录搬家没更新相对路径。改成
向上找 go.mod,以后脚本放哪都不会错。

顺带把两个静默失败改为显式报错:目录不存在、目录下无可识别产物
(原先前者抛裸 FileNotFoundError,后者会打出 ALL OK 却一个都没传)。
2026-09-04 10:35:02 +08:00
e671a8c082 fix(build): arm64 交叉编译补 CXX——「刻意不设 CXX」的注释判断是错的
build.sh 的 linux/arm64 分支此前刻意不设 CXX,注释理由是「设了会让
Go 用 aarch64 的 g++ 去链接,而它对 host 产生的 .o 报 file format
not recognized」。

那个判断是错的。那个报错的真因是 cmd/{homed,waiter}/*.syso(x86-64
COFF Windows 资源对象)被 Go 无条件链进了目标,与 CXX 无关。四组对照:

  syso 在   + 无 CXX → Relocations in generic ELF (EM: 183)
  syso 在   + 有 CXX → 000000.o: file format not recognized
  syso 隐藏 + 无 CXX → Relocations in generic ELF (EM: 183)
  syso 隐藏 + 有 CXX → 成功,ELF aarch64

两个条件缺一不可。之前诊断时只单独试了其中一个,得出错误结论后写进
注释固化了下来,于是 arm64 的 homed 一直编不出(v1.0.0 发布时 arm64
deb 里只有 waiter/initconfig)。

本脚本的 hide_syso_for_target 已处理 syso 那半,这里补上 CXX 那半。
实测 v1.0.1:build.sh linux/arm64 直接产出 ELF aarch64,arm64 的
full/server deb 里 homed 与 waiter 均为 aarch64。
2026-09-04 10:24:24 +08:00
dcaea64439 chore(release): 版本号 bump 到 1.0.1
patch 版:仅内核与内置插件改动,插件 ABI/协议未变
(ProtocolVersion 与 SDKCompatibleVersion 保持 1.0.0),
1.0.0 编出的 plugin.bin 无需重编。

- internal/meta: Version 1.0.0 → 1.0.1
- installer.nsi: PRODUCT_VERSION 缺省值同步(仅本地手工构建用,
  release 由 makensis -DPRODUCT_VERSION 注入)
- README/README_EN: 项目状态补 v1.0.1 条目,下载章节安装包名同步
2026-09-04 09:18:01 +08:00
3907347cac fix(multimodal): 媒体改挂独立 user message,落实「注入后续对话」的原意
插件三个工具的返回文案一直写着「已将图片注入后续对话」,b777322 的提交
说明也写着「模型在下一轮 LLM 请求里直接看到图」。但实现是把 block 挂在
tool message 的 content 数组上——role=tool 上的多模态 content 不被当作
可视内容。

同一张图、同一个模型、三轮实测:

  图在 user message        → 3/3 读到,prompt_tokens 7089
  图在 tool message        → 0/3(模型答「我没能读到这张图」),tokens 7967
  tool 纯文本 + 后接 user  → 3/3 读到,tokens 7570

tool message 那轮 token 反而更高,说明 base64 确实进了上游,只是模型看
不到它。这解释了为什么此前只有回退链(转文字进 tool message 的纯文本
content)能用,而「主模型直接看图」这条路从 b777322 起就没通过——当时
的验证只看了 prompt_tokens 涨了 8500,没有校验模型答案对不对。

改为:tool message 保持纯文本结果,媒体另起一条紧随其后的 user message
承载,并在首个 text 块标注 [以下是 <tool> 注入的媒体内容],避免模型误
以为是用户新发的图。位置必须紧跟 toolMsg,中间插入其他消息会让
tool_call_id 配对断开。

验证(生产,答案预先封存、生成时不读):
- AUTO 源 vision=true 直视路径:随机三色带 → 答「紫、蓝、黄」,与封存
  答案一致,日志无 modal fallback(确实走的直视),耗时 9.6s
  (回退链同一用例需 ~90s,省掉了绕视觉模型一圈)
- see_video 6 帧直视:23s(回退链合包版 131s,逐块版 363s),模型正确
  描述测试图卡的彩条布局、彩虹带滚动与计数器递增
- 负向:AUTO 源改回 vision=false,回退链仍正常转写,模型如实标注来源
2026-09-04 07:49:30 +08:00
09071dc235 fix(multimodal): 修多模态假成功 + 落地视觉回退链 + see_video 帧数语义
## 起因

生产盲测:模型调 multimodal_see_picture 后声称看到了图,实际一个字
都没收到。工具却返回「[已将图片注入后续对话]」。

链路:core.llm.model=AUTO → llmsproxy 按优先级选 big-pickle(prio=100)
→ 转 opencode zen。llmsproxy 的 opencode.lua 明写着:

    -- zen 上游 schema 只接受 text content part(无视觉/音频能力)
    if part.type ~= nil and part.type ~= "text" then  -- 丢弃

判据:256x256 纯红 PNG,带图与不带图的 prompt_tokens 都是 256。
图片贡献零 token,即根本没进上游。

内核序列化与注入链本身是对的(Message.MarshalJSON 正确产出 content
数组,SetToolBlocks → IOManager → ConsumeToolBlocks → toolMsg.Blocks
全通)。缺的是「主模型能否消费这些块」这一判断——内核此前完全没有
多模态能力的概念(grep supportsVision|multimodal 在 agent/ 零命中)。

这与 v1.0.0 修的 output_send 假成功同类:告诉调用方成功而实际未送达。

## 1. 能力声明

新增 core.llm.sources.<name>.vision / .audio(走既有 sourceFieldDefs,
WebUI 配置页自动出现),types.LLMSource 与 api.BaseConfig 同步加字段。

新增 agentAPI.ModalProvider 接口 + ProviderSupportsVision/Audio 判定:
未实现该接口的 provider 一律按不支持处理。保守侧是刻意的——宁可多走
一次文字回退,也不能把图默默扔给会剥掉它的上游。

为何是声明而非探测:探测需额外真实调用且结果不稳定(取决于 AUTO 当次
路由到哪);而 200 响应 + 相同 token 数从响应侧无法区分「看到了但没
内容」和「被剥掉了」。

## 2. 回退链(modalfallback.go)

实现了 config/registry.go 里注册但从未被读取的 image/audio
fallback_provider + fallback_model(此前 0 处读取点)。

prepareToolBlocks 在 process.go 注入前判定:能直视就原样透传;不能就
调声明了该能力的源转写成文字,带 [由 X 转写,非当前模型直接感知] 标注。

几处刻意的设计:
- 逐模态判定,不一刀切。很多视觉模型能看图但听不到音频,全部降级会
  白白把可直视的图变成二手描述
- 混合场景下转写文字作为 text 块并入 native,两部分同时到达模型
- 配置指向未声明能力的源时拒绝并继续找——照用只会重演静默剥离
- 未配 fallback_provider 但某源声明了 vision 时自动扫出来用;静默失败
  比多找一个能用的源更糟
- 空回复算失败。上游剥掉媒体后模型往往回「我没看到图片」或空串,两种
  都说明回退链也没真看到
- 多媒体块按模态合包为一次请求(见下)

## 3. 批量合包(生产实测驱动的返工)

首版逐块调用,生产 see_video 6 帧实测:4 帧里 3 帧超时,整轮 363 秒。
改为按模态合包一次请求后同一用例 131 秒、6/6 成功。

顺带把 modalFallbackTimeout 从 90s 提到 180s:生产经网关转
claude-opus-5 看一张 400x400 图要 ~81s,90s 贴着上限。
多张时 detail 默认 low 控体积,单张用 high 看细节;插件显式给了
detail 则尊重它。

## 4. see_video 帧数语义

fps=1/N 是频率(每 N 秒一帧)不是数量。20s 视频实测:
frames=4 → 5 帧、frames=10 → 2 帧、frames=1 → 20 帧,要得越多拿得越少;
长视频下 frames=4 会产出 时长/4 帧,靠 i>=9 的 break 兜着才没炸上下文,
而那个 break 用的是 ReadDir 索引,跳过条目后与实际帧数错位。

改为 ffprobe 取时长 → fps=N/时长 + -frames:v N 硬封顶。
0.4s/3s/20s/120s × frames=1/2/4/7/10 全部精确。

极短视频的坑:fps=1 在 0.4s 素材上产出 0 帧(不足一秒抽不出),所以
时长探测失败时不能退化成 fps=1,改为不传 -vf 只靠 -frames:v。

## 验证

- modalfallback_test.go 14 例:直视透传 / 回退转写 / 无源如实报告 /
  未实现接口按不支持 / 混合模态拆分 / 空回复算失败 / 块数上限 /
  多图合一次调用 / detail 策略 / 拒绝未声明能力的源 / 未配置时自动扫源
- go test ./... 全绿,go vet 无警告
- 生产盲测(答案预先封存、生成时不读):随机三色带 → 模型答
  「紫、蓝、红」,与封存答案完全一致
- 负向验证:拿掉回退源后模型如实回答「没看到图片内容」并引用工具返回
  的配置提示,且主动纠正了上一轮的答案
- 生产 see_video 6 帧:单次转写,模型正确描述测试图卡的计数器递增与
  彩虹带滚动
2026-09-04 06:25:51 +08:00
21 changed files with 1610 additions and 142 deletions

View File

@ -193,6 +193,12 @@ internal/
## 项目状态
**v1.0.4** — 两处数据竞争修复(现网 `/api/v1/device/ws` 通道与终端推流)。此前 `-race` 全仓复验即暴露:`remotedevice` 网关对同一连接的 `bufio.Writer` 由两条路径并发写(`handleWS` 主循环回写 hello_ack/绑定回执/pong`PushJSON`/`PushData` 的 agent→设备下发`bufio.Writer` 非线程安全,`TestWSPushDataAudio` 异步下发即稳定撞车;`agentcli` 终端把共享读缓冲传给 reader goroutineOS 层持续覆写)又在 `readLoop``copy(data, buf[:r.n])`,读写并发。修法:连接级写锁(`wconn.wmu`Push* 与 handleWS 共用同一把锁,`PushData` 整条下发持锁保证协议顺序)与「读结果随 `readResult` 自带切片传递、不再共享缓冲」。全仓 `go test ./... -race` 由 7 处 race / 5 个测试 FAIL 变为 32 包全绿。
**v1.0.3** — 内核 stage 协调器双重解锁修复。现网 homed 主进程曾一次 `fatal error: sync: unlock of unlocked mutex` 整体死亡(带走全部 27 个子进程插件):`Host.endStage` 把「递减 inflight、判定最后离开者」放在 `coordMu` 临界区之外,而摘除协调器在临界区之内,于是后到插件能挂进一个正在收尾的协调器、被误判成最后离开者,对同一把 `stageMu` 解了两次。**`sync.Mutex` 双重解锁是 runtime fatal 而非 panic两层 `recover` 结构上拦不住**,这才让「插件崩溃不拖垮内核」的隔离设计整体失效。修法是把计数、判定、摘除收进同一临界区,并把首进者写共享段的 `enter()` 也移入锁内(此前后到者可能读到写一半的段)。配套 5 个回归用例,含把旧实现 stash 回来验证测试确实能复现 fatal 的反向验证。
**v1.0.1** — 多模态 bugfix。插件 ABI/协议未变1.0.0 编出的 `plugin.bin` 无需重编。修三类缺陷1**看图假成功**——媒体块挂在 tool message 上不被模型当作可视内容实测同一张图tool message 0/3 读到、独立 user message 3/3改为另起一条紧随其后的 user message 承载落实插件文案一直在说的「注入后续对话」2**新增多模态能力声明与回退链**——`core.llm.sources.<name>.vision/.audio` 声明源能否真正处理媒体(网关会静默剥离 `image_url` 后仍返回 200带图与不带图 prompt_tokens 完全相同),不支持时自动走视觉源转写成文字,并落实了 `core.input_processing.image.fallback_provider` 这批早已注册却从未被读取的配置项3**`see_video` 帧数语义反了**——`fps=1/N` 是频率不是数量20s 视频请求 10 帧只得 2 帧、请求 1 帧反得 20 帧,改为 `ffprobe` 取时长 + `fps=N/时长` + `-frames:v` 硬封顶。
**v1.0.0** — 外部插件从 C ABI 动态库迁移到**子进程 + 共享内存**。首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件须用新版 `plugindev` 重编为 `plugin.bin`**业务代码零改动**)。消除 6 类此前在生产造成故障的缺陷:热重载失效(`DF_1_NODELETE``dlclose` 成 no-op、崩溃隔离缺失插件 panic 带崩 homed、stage lost update副本模型丢失 35.8~36.8%、cgo 超时不可中断(线程线性泄漏)、`output_send` 假成功模型收到「已发送」而消息未送达、Windows 能力断层(只见 3 个 stage 字段且无法写回。三面通信stdio JSON-RPC控制+ 共享内存段(数据)+ 事件环通知权限梯度显式化为三道闸。RPC 往返 p50 24.1µs崩溃到恢复 <1s
**v0.9.0** C ABI v2外部插件 Stage 回调支持写回`invoke_stage` 增加 result 输出插件可在 OnInput/AfterToolcall/PostAction 修改 RawMessage/LLMText/ToolResults 等并同步回内核ABI 版本随内核 minor 对齐v0.9.x ABIVersion=2`version_min=1` 向后兼容旧插件)。同步修复工具循环 zen 兼容补位误伤首轮 system 上下文的问题配套 SDK 提供增强版 sanitizer 示例 UTF-8/U+FFFD/ANSI 转义全链路清洗)。** ABI 已随 v1.0.0 退场。**
@ -218,7 +224,7 @@ internal/
| **client** | waiter + 桌面 GUI | 连接远程 HomeAgent |
- Linux`.deb`amd64/arm64)、`.rpm`x86_64)、`.tar.gz`
- Windows`HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe`NSIS 安装向导
- Windows`HomeAgent_v1.0.4_{Full,Server,Client}_win64.exe`NSIS 安装向导
- 免安装`homeagent-bin-<os>_<arch>.tar.gz` homed/waiter/initconfig
- 校验`SHA256SUMS`

View File

@ -179,6 +179,12 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
## Project Status
**v1.0.4** — Two data-race fixes (the live `/api/v1/device/ws` gateway and terminal streaming). A full `-race` pass exposed both: `remotedevice` wrote one connection's `bufio.Writer` from two concurrent paths (`handleWS` loop replies hello_ack/bind_ack/pong, plus `PushJSON`/`PushData` agent→device pushes) — `bufio.Writer` is not thread-safe, and `TestWSPushDataAudio` async push hit it reliably; `agentcli` handed the shared read buffer to the reader goroutine (which the OS keeps overwriting) while `readLoop` did `copy(data, buf[:r.n])` — concurrent read/write of the same buffer. Fix: connection-level write lock (`wconn.wmu`, shared by Push* and handleWS; `PushData` holds it across the whole start/chunks/end sequence to preserve protocol order) plus carrying read results in per-result slices instead of a shared buffer. Repo-wide `go test ./... -race` went from 7 races / 5 failing tests to all-clean.
**v1.0.3** — Kernel stage-coordinator double-unlock fix. The production `homed` main process once died outright with `fatal error: sync: unlock of unlocked mutex`, taking all 27 subprocess plugins with it: `Host.endStage` performed "decrement inflight, decide whether I'm the last leaver" *outside* the `coordMu` critical section while detaching the coordinator *inside* it, so a late-arriving plugin could attach to a coordinator that was already finishing, be misjudged as the last leaver, and unlock the same `stageMu` twice. **A `sync.Mutex` double unlock is a runtime fatal, not a panic, so the two layers of `recover` structurally cannot catch it**—which is exactly why the "a crashing plugin must not take down the kernel" isolation design failed wholesale here. The fix folds counting, decision, and detach into one critical section, and also moves the first arriver's `enter()` (which writes the shared segment) inside the lock—previously a late arriver could read a half-written segment. Ships with 5 regression cases, including a reverse check that stashes the old implementation back to confirm the tests really do reproduce the fatal.
**v1.0.1** — Multimodal bugfix. The plugin ABI/protocol is unchanged, so `plugin.bin` artifacts built for 1.0.0 need no rebuild. Three defects fixed: (1) **vision silently failing**—media blocks attached to a tool message are not treated as viewable content by the model (measured on one image: 0/3 read from a tool message, 3/3 from a standalone user message); media now rides its own user message placed immediately after, which is what the plugin's own wording ("injected into the following conversation") always claimed; (2) **new multimodal capability declaration + fallback chain**`core.llm.sources.<name>.vision/.audio` declares whether a source can genuinely process media (a gateway may strip `image_url` and still return 200, with identical prompt_tokens with and without the image); when it cannot, media is transcribed to text via a vision-capable source, finally wiring up the long-registered but never-read `core.input_processing.image.fallback_provider` settings; (3) **`see_video` frame-count semantics were inverted**—`fps=1/N` is a *rate*, not a count, so a 20s video yielded 2 frames when 10 were requested and 20 frames when 1 was requested; now `ffprobe` measures duration and the filter becomes `fps=N/duration` with `-frames:v` as a hard cap.
**v1.0.0** — External plugins moved from C ABI shared libraries to **subprocess + shared memory**. The first release that no longer loads `.so`/`.dll`, and it is incompatible with 0.9.x (existing plugins must be rebuilt into `plugin.bin` with the new `plugindev`, though **business code needs zero changes**). Eliminates 6 classes of defects that had caused production incidents: hot-reload silently failing (`DF_1_NODELETE` making `dlclose` a no-op), no crash isolation (a plugin panic took down homed), stage lost updates (35.8~36.8% loss under the copy model), uncancellable cgo timeouts (linear OS-thread leaks), `output_send` reporting false success (the model was told "sent" while the message never went out), and Windows capability degradation (only 3 stage fields visible, no write-back). Three communication planes: stdio JSON-RPC (control) + shared memory segment (data) + event ring (notification); the privilege gradient is now enforced by three explicit gates. RPC round-trip p50 24.1µs; crash-to-recovery under 1s.
**v0.9.0** — C ABI v2: external plugin Stage callbacks can now write back (`invoke_stage` gained a result out-param; plugins may mutate RawMessage/LLMText/ToolResults etc. in OnInput/AfterToolcall/PostAction and have them synced to the core). ABI version now tracks core minor releases (v0.9.x → ABIVersion=2, `version_min=1` keeps old plugins loadable). Also fixes the tool-loop zen-compat placeholder that wrongly fired on first-turn system context tail. The SDK ships an enhanced sanitizer example (bad-UTF-8 / U+FFFD / ANSI-escape scrub across the whole pipeline). **This ABI retired with v1.0.0.**
@ -204,7 +210,7 @@ External plugin development: see [homeagent-sdk](https://gitcode.com/JianFeeeee/
| **client** | waiter + desktop GUI | Connecting to a remote HomeAgent |
- Linux: `.deb` (amd64/arm64), `.rpm` (x86_64), `.tar.gz`
- Windows: `HomeAgent_v1.0.0_{Full,Server,Client}_win64.exe` (NSIS installer)
- Windows: `HomeAgent_v1.0.4_{Full,Server,Client}_win64.exe` (NSIS installer)
- Portable: `homeagent-bin-<os>_<arch>.tar.gz` (homed/waiter/initconfig)
- Verification: `SHA256SUMS`

View File

@ -295,6 +295,8 @@ func main() {
ContextWindow: src.ContextWindow,
MaxConcurrent: src.MaxConcurrent,
Priority: src.Priority,
Vision: src.Vision,
Audio: src.Audio,
}, luaVM, src.Name, src.Adapter)
providerMgr.Register(src.Name, luaProvider)
if src.Adapter != "" {

View File

@ -27,10 +27,19 @@ COMPONENT="${2:-all}"
case "$TARGET" in
native) GOOS="" GOARCH="" ;;
linux/amd64) GOOS=linux GOARCH=amd64 CC="${CC:-}" ;;
# arm64 刻意不设 CXX设了会让 Go 用 aarch64 的 g++ 去链接,
# 而它对 host 产生的 .o 报 "file format not recognized"
# gojieba 的 C++ 源仍由 CC 对应的 gcc 驱动编译gcc 能编 C++)。
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" ;;
# arm64 必须同时给 CXXgojieba 是 C++,缺 CXX 时 cgo 用宿主 g++ 编出
# x86-64 的 .o链接时报 "Relocations in generic ELF (EM: 183)"183 = aarch64
#
# 此处曾有一条注释写着「arm64 刻意不设 CXX」理由是设了会报
# "file format not recognized"。那个判断是错的:那个报错的真因是
# cmd/{homed,waiter}/*.sysox86-64 COFF Windows 资源对象)被链进了目标,
# 与 CXX 无关。四组对照:
# syso 在 + 无 CXX → Relocations in generic ELF (EM: 183)
# syso 在 + 有 CXX → 000000.o: file format not recognized
# syso 隐藏 + 无 CXX → Relocations in generic ELF (EM: 183)
# syso 隐藏 + 有 CXX → 成功ELF aarch64
# 本脚本的 hide_syso_for_target 已处理前一个条件,这里补上后一个。
linux/arm64) GOOS=linux GOARCH=arm64 CC="${CC:-aarch64-linux-gnu-gcc}" CXX="${CXX:-aarch64-linux-gnu-g++}" ;;
darwin/amd64) GOOS=darwin GOARCH=amd64 CC="${CC:-}" ;;
darwin/arm64) GOOS=darwin GOARCH=arm64 CC="${CC:-}" ;;
# Windows 必须同时给 CXXgojieba 是 C++,缺 CXX 时 cgo 回退到宿主 g++
@ -144,6 +153,14 @@ build_initconfig() {
}
# ---- gui (Electron) ----
#
# 输出目录必须用 --config.directories.output**不能用 -o**
# electron-builder 的 `-o` 是 `--mac`/`--macos` 的短别名(见 --help 的 Building 段),
# 不是 output。此前 `-o "$BUILD_DIR"` 被当成 macOS 的 target 列表,报
# Unknown target: /home/program/trueagent/build
# (路径被 lowercase 后去匹配 target 名表,所以错误信息里的路径是全小写的,
# 这也是它看起来像「路径错」而实际是「参数位置错」的原因)。
# v1.0.1 与 v1.0.3 两次发布都因此手工组装过 GUI。
build_gui() {
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
echo "[SKIP] gui ${GOOS}/${GOARCH} — electron-builder handles cross-platform natively; run 'all' on CI host"
@ -161,12 +178,21 @@ build_gui() {
# 不传 --configelectron-builder 默认从 package.json 的 "build" 键读配置。
# 传 --config package.json 会让它把**整个** package.json 当配置校验,
# 于是 devDependencies / build / scripts 全被判为 "unknown property" 而失败。
(cd "$gui_dir" && npx electron-builder \
--linux --win --mac \
--x64 --arm64 \
-p never \
-o "$BUILD_DIR")
echo " OK"
#
# GUI 失败不中断整体构建homed/waiter/initconfig 是发布的主体,
# 而 GUI 依赖 electron 运行时下载离线机器、arm64 缺缓存都会失败)。
# set -e 下若不接住,一个可选组件会让整轮跨平台构建全废。
if (cd "$gui_dir" && npx electron-builder \
--linux --win --mac \
--x64 --arm64 \
-p never \
--config.directories.output="$BUILD_DIR"); then
echo " OK"
else
echo " WARN: gui 构建失败(可选组件,不影响 homed/waiter/initconfig"
echo " Linux 包可用 deploy/packaging/package-linux.sh 内置的手工组装路径"
return 0
fi
}
# ---- dispatch ----

View File

@ -14,7 +14,7 @@
# 此前硬编码 0.8.0 而 release 已到 1.0.0,装出来的包在「添加/删除程序」里
# 会显示错误版本DisplayVersion 也取自这个宏)。
!ifndef PRODUCT_VERSION
!define PRODUCT_VERSION "1.0.0"
!define PRODUCT_VERSION "1.0.4"
!endif
!if "${VARIANT}" == "full"

View File

@ -9,14 +9,20 @@ PACKAGE_ROOT="${PROJECT_ROOT}/deploy/packaging/linux"
GO="${GO:-$(command -v go 2>/dev/null || echo "go")}"
ARCH="${1:-amd64}" # amd64 or arm64
# electron 官方发布物用 x64/arm64 命名,而 Debian 用 amd64/arm64。
# 两者在 arm64 上恰好同名amd64 上不同——此前缓存查找统一用 TAR_ARCH
# amd64于是 electron-v*-linux-x64.zip 永远命中不到amd64 GUI 只能
# 靠"回退到 host node_modules"这条路组装。干净 worktree 里没有完整
# node_modulesGUI 就被静默跳过。故单独映射。
ACTION="${2:-all}" # all, build, deb, tar, rpm
DEB_ARCH="$ARCH"
RPM_ARCH="$ARCH"
TAR_ARCH="$ARCH"
case "$ARCH" in
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64" ;;
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64" ;;
amd64) DEB_ARCH="amd64"; RPM_ARCH="x86_64"; TAR_ARCH="amd64"; ELECTRON_ARCH="x64" ;;
arm64) DEB_ARCH="arm64"; RPM_ARCH="aarch64"; TAR_ARCH="arm64"; ELECTRON_ARCH="arm64" ;;
*) echo "Unknown arch: $ARCH (use amd64 or arm64)"; exit 1 ;;
esac
@ -122,6 +128,15 @@ build_go() {
}
# ---- build GUI (manual directory assembly, avoids electron-packager network issues) ----
#
# electron 运行时必须按**目标架构**取,不能用 host 的
# node_modules/electron/dist——那里永远是 host 架构(本机 x64
# v1.0.0 / v1.0.1 的 arm64 full/client 包都踩了这个坑:目录名带
# -arm64、homed/waiter 确实是 aarch64但里面的 electron 是 x86-64
# 在 arm64 机器上一启动就是 Exec format error从未被交叉验证过
#
# 现在改为优先从 electron 缓存里取对应架构的 zip并在最后做
# 一道强制校验:架构不符就删掉目录并跳过 GUI宁可不发也不发坏包。
build_gui() {
local gui_dir="$PROJECT_ROOT/cmd/gui"
local gui_out="$BUILD_DIR/homeagent-gui-linux-${TAR_ARCH}"
@ -133,22 +148,81 @@ build_gui() {
echo ">>> Building GUI directory for linux/$ARCH..."
if [ ! -d "$gui_dir/node_modules" ]; then
# 判据是 electron 包本身在不在,而不是 node_modules 目录在不在。
#
# npm install 失败(离线、网络受限)会留下一个只有一两个条目的空壳
# node_modules目录存在但 electron 缺失。只看目录会以为"已安装"
# 于是 ever 读不到版本、缓存匹配退化、最后走到"host dist 也没有"而
# 静默跳过 GUI——包名和目录名全都正确只是没有 GUI没有任何一步报错。
if [ ! -f "$gui_dir/node_modules/electron/package.json" ]; then
if [ -d "$gui_dir/node_modules" ]; then
echo " node_modules 存在但 electron 缺失(疑似上次 npm install 未完成)"
fi
echo " npm install..."
(cd "$gui_dir" && npm install --production)
if ! (cd "$gui_dir" && npm install --production); then
echo " WARNING: npm install 失败——离线环境下这是预期的。"
echo " GUI 需要 cmd/gui/node_modules/electron 或 ~/.cache/electron 缓存。"
fi
fi
local electron_dir="$gui_dir/node_modules/electron/dist"
if [ ! -f "$electron_dir/electron" ]; then
echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped."
return
# electron 版本优先从已安装的包里读,保证运行时与 app 依赖一致。
# 读不到时退而从 package.json 的依赖声明里取数字部分(它可能写成
# "^33.0.0" 这类范围,只用于给缓存匹配一个提示,匹配不上仍会走通配)。
local ever
ever=$(python3 -c "import json;print(json.load(open('$gui_dir/node_modules/electron/package.json'))['version'])" 2>/dev/null || true)
if [ -z "$ever" ]; then
ever=$(python3 -c "
import json, re
d = json.load(open('$gui_dir/package.json'))
spec = (d.get('devDependencies', {}) or {}).get('electron') or (d.get('dependencies', {}) or {}).get('electron') or ''
m = re.search(r'(\\d+(?:\\.\\d+)*)', spec)
print(m.group(1) if m else '')
" 2>/dev/null || true)
[ -n "$ever" ] && echo " electron 版本取自 package.json 依赖声明: $ever(非精确)"
fi
mkdir -p "$gui_out"
# 优先:缓存里的目标架构 zip~/.cache/electron/<hash>/electron-v<ver>-linux-<arch>.zip
local zip=""
if [ -n "$ever" ]; then
zip=$(find "$HOME/.cache/electron" -name "electron-v${ever}-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
fi
if [ -z "$zip" ]; then
zip=$(find "$HOME/.cache/electron" -name "electron-v*-linux-${ELECTRON_ARCH}.zip" 2>/dev/null | head -1)
fi
if [ -n "$zip" ]; then
echo " electron runtime: $(basename "$zip")"
unzip -q -o "$zip" -d "$gui_out"
else
# 回退:仅当目标架构 == host 架构时才能用 host 的 dist
local host_arch
case "$(uname -m)" in
x86_64) host_arch=amd64 ;;
aarch64|arm64) host_arch=arm64 ;;
*) host_arch=unknown ;;
esac
if [ "$TAR_ARCH" != "$host_arch" ]; then
echo " WARNING: 缺 electron-v*-linux-${ELECTRON_ARCH}.zip 缓存,且目标架构与 host"
echo " ($host_arch) 不同——不能用 host 的 electron 冒充。跳过 GUI。"
echo " 解法:下载 electron-v${ever:-<ver>}-linux-${ELECTRON_ARCH}.zip 到"
echo " ~/.cache/electron/<任意子目录>/ 后重跑。"
rm -rf "$gui_out"
return
fi
local electron_dir="$gui_dir/node_modules/electron/dist"
if [ ! -f "$electron_dir/electron" ]; then
echo " WARNING: electron binary not found at $electron_dir. GUI will be skipped."
rm -rf "$gui_out"
return
fi
echo " electron runtime: host node_modules (同架构 $host_arch)"
cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null
fi
mkdir -p "$gui_out/resources/app/node_modules"
mkdir -p "$gui_out/resources/app/renderer"
# copy electron runtime (binary + shared libs)
cp -r "$electron_dir"/* "$gui_out/" 2>/dev/null
rm -f "$gui_out/resources/default_app.asar" 2>/dev/null
# copy app source
@ -190,7 +264,28 @@ LAUNCHER
chmod +x "$gui_out/homeagent-gui"
chmod +x "$gui_out/electron"
echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1))"
# 最后一道强制校验electron 二进制的实际架构必须匹配目标架构。
# 不做这步就会重现 v1.0.0/v1.0.1 的隐形坏包:包名、目录名、
# homed/waiter 全对,只有 electron 是错架构,直到用户在 arm64 机器上
# 双击才发现 Exec format error。
local want_pat
case "$TAR_ARCH" in
amd64) want_pat="x86-64" ;;
arm64) want_pat="aarch64" ;;
*) want_pat="" ;;
esac
if [ -n "$want_pat" ]; then
local got
got=$(file -b "$gui_out/electron" 2>/dev/null || echo "")
if ! printf '%s' "$got" | grep -q "$want_pat"; then
echo " ERROR: electron 架构不符——期望 $want_pat,实际: ${got%%,*}"
echo " 删除 GUI 目录并跳过(宁可不发,也不发装了跑不起来的包)。"
rm -rf "$gui_out"
return
fi
fi
echo " GUI built: $gui_out ($(du -sh "$gui_out" | cut -f1), $(file -b "$gui_out/electron" | cut -d, -f2 | tr -d ' '))"
echo ""
}

View File

@ -66,19 +66,41 @@ def put_file(url: str, headers: dict, path: str) -> tuple[int, str]:
return 0, f"{type(e).__name__}: {e}"
def project_root() -> str:
"""向上找带 go.mod 的目录作为仓库根。
为何不数 dirname本脚本初版在 scripts/(深度 1移到 deploy/scripts/
(深度 2后写死的两层 dirname 就指向了 deploy/dist/release上传直接
FileNotFoundError。这正是 v0.7.2 那次 package/ → deploy/packaging/ 打断
PROJECT_ROOT 的同一个坑,改成按标记文件定位以后怎么挑位置都不会错。
"""
d = os.path.dirname(os.path.abspath(__file__))
while d != os.path.dirname(d):
if os.path.exists(os.path.join(d, "go.mod")):
return d
d = os.path.dirname(d)
# 实在找不到(脚本被单独拷出仓库)就回退到 cwd给 ASSET_DIR 一个机会
return os.getcwd()
def main() -> int:
if len(sys.argv) < 3:
print(__doc__)
return 2
tag, token = sys.argv[1], sys.argv[2]
outdir = os.environ.get("ASSET_DIR") or os.path.join(
os.path.dirname(os.path.dirname(os.path.abspath(__file__))),
"dist",
"release",
project_root(), "dist", "release"
)
if not os.path.isdir(outdir):
print(f"error: 资产目录不存在: {outdir}")
print(" 用 ASSET_DIR=<目录> 显式指定,或先跑构建生成 dist/release/")
return 2
files = sys.argv[3:] or sorted(
f for f in os.listdir(outdir) if is_artifact(f)
)
if not files:
print(f"error: {outdir} 下没有可识别的发布产物")
return 2
print(f"repo={REPO} tag={tag} dir={outdir}", flush=True)
failed = []
for name in files:

View File

@ -235,6 +235,31 @@ type RoutableProvider interface {
Priority() int // AUTO 跨源选择的优先级,大者优先
}
// ModalProvider 声明自身的多模态能力。单独抽接口而不合进 Provider
// 第三方 Provider 实现无需改动,未实现时按纯文本处理(保守侧)。
type ModalProvider interface {
SupportsVision() bool
SupportsAudio() bool
}
// ProviderSupportsVision 安全判定任意 Provider 能否看图。
// 未实现 ModalProvider 的一律返回 false宁可多走一次文字回退
// 也不能把图默默扔给一个会把它剥掉的上游。
func ProviderSupportsVision(p Provider) bool {
if mp, ok := p.(ModalProvider); ok {
return mp.SupportsVision()
}
return false
}
// ProviderSupportsAudio 安全判定任意 Provider 能否听音频。
func ProviderSupportsAudio(p Provider) bool {
if mp, ok := p.(ModalProvider); ok {
return mp.SupportsAudio()
}
return false
}
// ModelContextWindow 返回模型的最大上下文窗口token 数)
// 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率
func ModelContextWindow(model string) int {
@ -284,6 +309,11 @@ type BaseConfig struct {
ContextWindow int `json:"context_window"`
MaxConcurrent int `json:"max_concurrent"`
Priority int `json:"priority"`
// Vision/Audio 声明这条链路能否真正处理多模态内容块。
// 网关可能静默剥离 image_url 后仍返回 200所以不能从响应推断能力。
Vision bool `json:"vision"`
Audio bool `json:"audio"`
}
// LuaAdaptedProvider 使用 Lua 脚本做请求/响应变换,直接发起 HTTP 调用
@ -343,6 +373,11 @@ func (p *LuaAdaptedProvider) Name() string { return p.name }
func (p *LuaAdaptedProvider) Model() string { return p.cfg.Model }
func (p *LuaAdaptedProvider) Priority() int { return p.cfg.Priority }
// SupportsVision/SupportsAudio 实现 ModalProvider值来自部署时声明
// core.llm.sources.<name>.vision / .audio
func (p *LuaAdaptedProvider) SupportsVision() bool { return p.cfg.Vision }
func (p *LuaAdaptedProvider) SupportsAudio() bool { return p.cfg.Audio }
func (p *LuaAdaptedProvider) Chat(ctx context.Context, req *CompletionRequest) (*CompletionResponse, error) {
if p.cfg.Model != "" && (req.Model == "" || req.Model == "AUTO") {
req.Model = p.cfg.Model

View File

@ -0,0 +1,362 @@
package core
import (
"context"
"fmt"
"log"
"strings"
"time"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
)
// 多模态回退链:主模型看不到图/听不到音频时,改用一个声明了 vision/audio
// 能力的源把媒体转写成文字,再以 text block 注入。
//
// 为何必须有这条链core.llm.model=AUTO 时实际落到哪个上游由网关按优先级决定,
// 而网关可能把 image_url 块静默剥离后转发给纯文本上游llmsproxy 的
// opencode adapter 就明写着 "multimodal part not supported by zen" 并丢弃
// 非 text part。请求依然返回 200带图与不带图的 prompt_tokens 完全相同,
// 模型于是回答「我没有看到图片」,而内核以为注入成功。
//
// 没有这条链的话multimodal 插件在任何非视觉主模型下都只能假成功。
const (
// modalFallbackTimeout 单次转写调用的上限。
//
// 为何是 180s生产实测经网关转 claude-opus-5 看一张 400x400 图要 ~81s
// 90s 阅则定时贴着上限,多帧批量请求更慢。宁可等也不要徒劳一趟。
modalFallbackTimeout = 180 * time.Second
// modalFallbackMaxTokens 转写输出上限。描述一组图/一段音频不需要长文,
// 且这段文字要塞回主模型上下文,过长会挤掉真正的对话内容。
modalFallbackMaxTokens = 1500
// modalFallbackMaxBlocks 单次最多转写多少个媒体块。
// see_video 一次能注入 10 帧;即使批量合包,图越多上游越慢也越容易超
// 单请求体积限制。超出部分如实报告未转写。
modalFallbackMaxBlocks = 6
)
// modalFallbackResult 描述一次回退转写的结果,供调用方决定注入什么。
type modalFallbackResult struct {
// Text 是转写出的文字(已含来源标注),为空表示没有可注入内容。
Text string
// Converted 实际成功转写的块数。
Converted int
// Skipped 因超出 modalFallbackMaxBlocks 而未处理的块数。
Skipped int
// Notice 给模型看的说明(能力缺失、转写失败等),始终如实。
Notice string
}
// resolveModalFallback 按配置挑一个能处理该模态的 provider。
//
// 顺序:配置指定的 fallback_provider → 任意声明了该能力的已注册源。
// 后者是刻意的兜底:用户可能只在源上声明了 vision 而忘了填 fallback_provider
// 此时静默失败比多找一个能用的源更糟。
func (a *Agent) resolveModalFallback(kind string) (agentAPI.Provider, string) {
if a.providerManager == nil {
return nil, ""
}
var configured string
switch kind {
case "image":
configured = strings.TrimSpace(a.inputCfg.Image.FallbackProvider)
case "audio":
configured = strings.TrimSpace(a.inputCfg.Audio.FallbackProvider)
}
supports := func(p agentAPI.Provider) bool {
if p == nil {
return false
}
if kind == "audio" {
return agentAPI.ProviderSupportsAudio(p)
}
return agentAPI.ProviderSupportsVision(p)
}
if configured != "" {
p := a.providerManager.Get(configured)
if p == nil {
log.Printf("[agent] modal fallback %s: configured provider %q not registered", kind, configured)
} else if !supports(p) {
// 配置指向了一个没声明该能力的源:照用只会重演静默剥离,
// 因此拒绝并继续找,日志点明配置与声明不一致。
log.Printf("[agent] modal fallback %s: provider %q does not declare the capability, ignoring", kind, configured)
} else if !a.providerManager.IsAvailable(configured) {
log.Printf("[agent] modal fallback %s: provider %q in cooldown, trying others", kind, configured)
} else {
return p, configured
}
}
// 兜底:扫已注册源,取第一个声明了该能力且当前可用的。
for _, name := range a.providerManager.List() {
if name == configured {
continue // 上面已试过
}
p := a.providerManager.Get(name)
if supports(p) && a.providerManager.IsAvailable(name) {
return p, name
}
}
return nil, ""
}
// modalFallbackModel 返回该模态回退调用应使用的模型名(空则用源自身默认)。
func (a *Agent) modalFallbackModel(kind string) string {
switch kind {
case "image":
return strings.TrimSpace(a.inputCfg.Image.FallbackModel)
case "audio":
return strings.TrimSpace(a.inputCfg.Audio.FallbackModel)
}
return ""
}
// modalFallbackPrompt 返回转写用的提示词,配置为空时给一个可用默认。
func (a *Agent) modalFallbackPrompt(kind string) string {
switch kind {
case "image":
if s := strings.TrimSpace(a.inputCfg.Image.DescribePrompt); s != "" {
return s
}
return "请详细描述这张图片的内容,包括其中的文字、物体、人物、场景等信息。"
case "audio":
if s := strings.TrimSpace(a.inputCfg.Audio.DescribePrompt); s != "" {
return s
}
return "请转写这段音频的内容。"
}
return ""
}
// transcribeBlocksForFallback 把主模型看不懂的媒体块转写成文字。
//
// blocks 里的 text 块原样保留它们本来就能被理解image_url/audio_url
// **按模态批量合包,每类只发一次请求**。
//
// 为何必须批量而不是逐块:生产实测 see_video 注入 6 帧时,逐帧调用让
// 4 帧里 3 帧超时,整轮拖到 363 秒。而视觉模型本来就能在一条消息里看
// 多张图——一次调用不仅快上一个数量级,模型还能看到帧与帧的时间推进
// 关系,分析质量更好。
//
// 返回的 Text 已带来源标注,让模型知道这是转写而非自己直接看到的。这点
// 很重要:模型据此能判断细节可靠性,也不会在用户追问像素级细节时编造。
func (a *Agent) transcribeBlocksForFallback(blocks []agentAPI.ContentBlock) modalFallbackResult {
var res modalFallbackResult
var kept []string // 原样保留的 text 块
var converted []string // 转写结果
var notices []string
// 先按模态分组,同时应用块数上限。
var imgURLs, imgDetails []string
var audURLs []string
mediaSeen := 0
for _, b := range blocks {
switch b.Type {
case "text":
if b.Text != "" {
kept = append(kept, b.Text)
}
continue
case "image_url":
mediaSeen++
if mediaSeen > modalFallbackMaxBlocks {
res.Skipped++
continue
}
if b.ImageURL != nil && b.ImageURL.URL != "" {
imgURLs = append(imgURLs, b.ImageURL.URL)
imgDetails = append(imgDetails, b.ImageURL.Detail)
}
case "audio_url":
mediaSeen++
if mediaSeen > modalFallbackMaxBlocks {
res.Skipped++
continue
}
if b.AudioURL != nil && b.AudioURL.URL != "" {
audURLs = append(audURLs, b.AudioURL.URL)
}
default:
continue // 未知块类型:主模型也看不懂,丢弃
}
}
// 图片:一次请求带全部帧
if len(imgURLs) > 0 {
p, srcName := a.resolveModalFallback("image")
if p == nil {
notices = append(notices, "当前模型不支持图片,且没有可用的视觉回退源"+
"(配置 core.input_processing.image.fallback_provider"+
"并在该源上设置 core.llm.sources.<name>.vision=true")
} else if text, err := a.chatModalFallbackBatch(p, "image", imgURLs, imgDetails); err != nil {
// 转写失败必须说出来。静默跳过会让模型以为「图里没内容」,
// 而事实是没人看过这些图。
notices = append(notices, fmt.Sprintf("图片转写失败(源 %s: %v", srcName, err))
log.Printf("[agent] modal fallback image(%d) via %s failed: %v", len(imgURLs), srcName, err)
} else {
label := "图片内容"
if len(imgURLs) > 1 {
label = fmt.Sprintf("%d 张图片/视频帧内容", len(imgURLs))
}
converted = append(converted, fmt.Sprintf("[%s · 由 %s 转写,非当前模型直接感知]\n%s", label, srcName, text))
res.Converted += len(imgURLs)
}
}
// 音频:同样一次请求
if len(audURLs) > 0 {
p, srcName := a.resolveModalFallback("audio")
if p == nil {
notices = append(notices, "当前模型不支持音频,且没有可用的音频回退源"+
"(配置 core.input_processing.audio.fallback_provider"+
"并在该源上设置 core.llm.sources.<name>.audio=true")
} else if text, err := a.chatModalFallbackBatch(p, "audio", audURLs, nil); err != nil {
notices = append(notices, fmt.Sprintf("音频转写失败(源 %s: %v", srcName, err))
log.Printf("[agent] modal fallback audio(%d) via %s failed: %v", len(audURLs), srcName, err)
} else {
converted = append(converted, fmt.Sprintf("[音频内容 · 由 %s 转写,非当前模型直接感知]\n%s", srcName, text))
res.Converted += len(audURLs)
}
}
if res.Skipped > 0 {
notices = append(notices, fmt.Sprintf(
"另有 %d 个媒体块未转写(单次上限 %d",
res.Skipped, modalFallbackMaxBlocks))
}
var parts []string
parts = append(parts, kept...)
parts = append(parts, converted...)
if len(notices) > 0 {
parts = append(parts, "[注意] "+strings.Join(notices, ""))
}
res.Text = strings.Join(parts, "\n\n")
res.Notice = strings.Join(notices, "")
return res
}
// prepareToolBlocks 判定当前主模型能否直接消费这批媒体块。
//
// 返回 (native, ""):能直接看/听,原样作为 Blocks 注入。
// 返回 (nil, text) :不能,已经回退链转写成文字,调用方并进纯文本 content。
// 返回 (nil, "") :既不能直接看也没回退源且无话可说(理论上不发生,
// transcribeBlocksForFallback 至少会给一条 notice
//
// 为何逐模态判定而不是一刀切:一批块里可能图能看、音频不能听(很多视觉
// 模型就是这样)。全部走回退会白白把本可直视的图降级成二手文字描述。
func (a *Agent) prepareToolBlocks(blocks []agentAPI.ContentBlock) ([]agentAPI.ContentBlock, string) {
canVision := agentAPI.ProviderSupportsVision(a.provider)
canAudio := agentAPI.ProviderSupportsAudio(a.provider)
var native []agentAPI.ContentBlock
var needFallback []agentAPI.ContentBlock
for _, b := range blocks {
switch b.Type {
case "image_url":
if canVision {
native = append(native, b)
} else {
needFallback = append(needFallback, b)
}
case "audio_url":
if canAudio {
native = append(native, b)
} else {
needFallback = append(needFallback, b)
}
default:
native = append(native, b) // text 等一律直通
}
}
if len(needFallback) == 0 {
return native, ""
}
res := a.transcribeBlocksForFallback(needFallback)
log.Printf("[agent] modal fallback: %d block(s) transcribed, %d skipped (provider=%s vision=%v audio=%v)",
res.Converted, res.Skipped, a.provider.Name(), canVision, canAudio)
// 部分能直视、部分需转写:把转写文字作为 text 块并入 native
// 这样两部分内容同时到达模型。
if len(native) > 0 {
if res.Text != "" {
native = append(native, agentAPI.ContentBlock{Type: "text", Text: res.Text})
}
return native, ""
}
return nil, res.Text
}
// chatModalFallbackBatch 向回退 provider 发**一次**请求,带上该模态的全部媒体。
//
// 多张图合包而非逐张调用:既为避开 N 倍往返延迟(生产实测逐帧调用使
// see_video 6 帧拖到 363 秒且 3/4 帧超时),也因为视觉模型看到成组帧时能
// 描述帧间变化,而逐帧转写只能得到 N 段互不相关的静态描述。
func (a *Agent) chatModalFallbackBatch(p agentAPI.Provider, kind string, urls, details []string) (string, error) {
if len(urls) == 0 {
return "", fmt.Errorf("no media to transcribe")
}
prompt := a.modalFallbackPrompt(kind)
if len(urls) > 1 && kind == "image" {
// 多张时补一句,否则模型容易只描述第一张。
prompt = fmt.Sprintf("%s\n\n共 %d 张(若为视频关键帧则按时间顺序),"+
"请逐张编号描述,并在最后概括帧间变化。", prompt, len(urls))
}
msg := agentAPI.Message{
Role: "user",
Blocks: []agentAPI.ContentBlock{{Type: "text", Text: prompt}},
}
for i, u := range urls {
if kind == "audio" {
msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{
Type: "audio_url",
AudioURL: &agentAPI.AudioURL{URL: u},
})
continue
}
detail := ""
if i < len(details) {
detail = details[i]
}
if detail == "" {
// 单张时看清细节;多张(视频帧)用 low 控住体积与耗时。
if len(urls) > 1 {
detail = "low"
} else {
detail = "high"
}
}
msg.Blocks = append(msg.Blocks, agentAPI.ContentBlock{
Type: "image_url",
ImageURL: &agentAPI.ImageURL{URL: u, Detail: detail},
})
}
ctx, cancel := context.WithTimeout(a.ctx, modalFallbackTimeout)
defer cancel()
resp, err := p.Chat(ctx, &agentAPI.CompletionRequest{
Model: a.modalFallbackModel(kind),
Messages: []agentAPI.Message{msg},
MaxTokens: modalFallbackMaxTokens,
})
if err != nil {
return "", err
}
out := strings.TrimSpace(resp.Content)
if out == "" {
// 空回复不能当成功。上游剥掉媒体块后模型往往回一句「我没看到图片」
// 或干脆空串——两种都说明这条回退链也没真看到。
return "", fmt.Errorf("回退源返回空内容(该源可能同样不支持此模态)")
}
return out, nil
}

View File

@ -0,0 +1,428 @@
package core
import (
"context"
"strings"
"testing"
agentAPI "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api"
"gitcode.com/JianFeeeee/HomeAgent/pkg/types"
)
// stubProvider 是一个可声明多模态能力的假 provider。
// 记录收到的请求,供断言「回退链是否真的调了它」。
type stubProvider struct {
name string
vision bool
audio bool
reply string
err error
calls int
lastReq *agentAPI.CompletionRequest
}
func (s *stubProvider) Name() string { return s.name }
func (s *stubProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
s.calls++
s.lastReq = req
if s.err != nil {
return nil, s.err
}
return &agentAPI.CompletionResponse{Content: s.reply}, nil
}
func (s *stubProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
ch := make(chan agentAPI.StreamChunk)
close(ch)
return ch, nil
}
func (s *stubProvider) MaxContextTokens() int { return 8192 }
func (s *stubProvider) SupportsVision() bool { return s.vision }
func (s *stubProvider) SupportsAudio() bool { return s.audio }
// plainProvider 不实现 ModalProvider用于验证「未声明即按不支持处理」。
type plainProvider struct{ name string }
func (p *plainProvider) Name() string { return p.name }
func (p *plainProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
return &agentAPI.CompletionResponse{Content: "ok"}, nil
}
func (p *plainProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
ch := make(chan agentAPI.StreamChunk)
close(ch)
return ch, nil
}
func (p *plainProvider) MaxContextTokens() int { return 8192 }
const testPNG = "data:image/png;base64,iVBORw0KGgoAAAANSUhEUg=="
const testWAV = "data:audio/wav;base64,UklGRiQAAABXQVZF"
func imageBlock() agentAPI.ContentBlock {
return agentAPI.ContentBlock{
Type: "image_url",
ImageURL: &agentAPI.ImageURL{URL: testPNG, Detail: "auto"},
}
}
func audioBlock() agentAPI.ContentBlock {
return agentAPI.ContentBlock{
Type: "audio_url",
AudioURL: &agentAPI.AudioURL{URL: testWAV},
}
}
// newFallbackAgent 组装一个只带 provider/manager/inputCfg 的最小 Agent。
// 不走 New():那会拉起 embedder、记忆、后台循环与本测试无关。
func newFallbackAgent(main agentAPI.Provider, mgr *agentAPI.ProviderManager, cfg types.InputProcessingConfig) *Agent {
ctx, cancel := context.WithCancel(context.Background())
return &Agent{
provider: main,
providerManager: mgr,
inputCfg: cfg,
ctx: ctx,
cancel: cancel,
}
}
func TestPrepareToolBlocks_VisionCapableProviderPassesThrough(t *testing.T) {
main := &stubProvider{name: "vision-main", vision: true}
mgr := agentAPI.NewProviderManager()
mgr.Register("vision-main", main)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{})
native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{imageBlock()})
if len(native) != 1 || native[0].Type != "image_url" {
t.Fatalf("能看图的主模型应原样透传 image_url得到 %+v", native)
}
if fallbackText != "" {
t.Fatalf("不该触发回退,却返回了文字: %q", fallbackText)
}
if main.calls != 0 {
t.Fatalf("不该额外调用 provider实际调了 %d 次", main.calls)
}
}
func TestPrepareToolBlocks_TextOnlyProviderFallsBackToTranscription(t *testing.T) {
main := &stubProvider{name: "text-main"} // vision=false
vis := &stubProvider{name: "vis-src", vision: true, reply: "一只橘猫坐在窗台上"}
mgr := agentAPI.NewProviderManager()
mgr.Register("text-main", main)
mgr.Register("vis-src", vis)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{
Image: types.ImageProcessingConfig{
FallbackProvider: "vis-src",
DescribePrompt: "描述这张图",
},
})
native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{imageBlock()})
if len(native) != 0 {
t.Fatalf("纯文本主模型不该收到原生块,得到 %+v", native)
}
if !strings.Contains(fallbackText, "一只橘猫坐在窗台上") {
t.Fatalf("回退文字应含转写内容,得到 %q", fallbackText)
}
// 关键:模型必须知道这是二手转写而非自己直接看到的
if !strings.Contains(fallbackText, "非当前模型直接感知") {
t.Fatalf("回退文字必须标注来源,得到 %q", fallbackText)
}
if vis.calls != 1 {
t.Fatalf("应调用视觉源 1 次,实际 %d", vis.calls)
}
if main.calls != 0 {
t.Fatalf("不该拿图去问纯文本主模型,实际调了 %d 次", main.calls)
}
}
func TestPrepareToolBlocks_NoFallbackSourceReportsHonestly(t *testing.T) {
main := &stubProvider{name: "text-main"}
mgr := agentAPI.NewProviderManager()
mgr.Register("text-main", main)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{})
native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{imageBlock()})
if len(native) != 0 {
t.Fatalf("不该透传,得到 %+v", native)
}
// 这是本次修复的核心:没有能力也没有回退源时必须明说,
// 而不是静默丢弃让模型以为自己看过图了。
if !strings.Contains(fallbackText, "不支持图片") {
t.Fatalf("必须如实说明看不到图,得到 %q", fallbackText)
}
if !strings.Contains(fallbackText, "fallback_provider") {
t.Fatalf("应给出可操作的配置提示,得到 %q", fallbackText)
}
}
func TestPrepareToolBlocks_ProviderWithoutModalInterfaceTreatedAsTextOnly(t *testing.T) {
main := &plainProvider{name: "legacy"} // 未实现 ModalProvider
mgr := agentAPI.NewProviderManager()
mgr.Register("legacy", main)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{})
native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{imageBlock()})
if len(native) != 0 {
t.Fatalf("未声明能力的 provider 应按不支持处理,却透传了 %+v", native)
}
if fallbackText == "" {
t.Fatal("应给出说明而非静默")
}
}
func TestPrepareToolBlocks_MixedModalitySplitsCorrectly(t *testing.T) {
// 主模型能看图但听不到音频——很多视觉模型正是这样。
// 图应直视,只有音频走回退,不能一刀切全部降级。
main := &stubProvider{name: "vision-only", vision: true}
aud := &stubProvider{name: "aud-src", audio: true, reply: "背景有钢琴声"}
mgr := agentAPI.NewProviderManager()
mgr.Register("vision-only", main)
mgr.Register("aud-src", aud)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{
Audio: types.AudioProcessingConfig{FallbackProvider: "aud-src"},
})
native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{imageBlock(), audioBlock()})
if fallbackText != "" {
t.Fatalf("有原生块时转写应并入 native不该走 content 分支,得到 %q", fallbackText)
}
var imgCount, textCount int
for _, b := range native {
switch b.Type {
case "image_url":
imgCount++
case "text":
textCount++
if !strings.Contains(b.Text, "背景有钢琴声") {
t.Fatalf("text 块应含音频转写,得到 %q", b.Text)
}
}
}
if imgCount != 1 {
t.Fatalf("图应原样保留 1 个,得到 %d", imgCount)
}
if textCount != 1 {
t.Fatalf("音频转写应产出 1 个 text 块,得到 %d", textCount)
}
if aud.calls != 1 {
t.Fatalf("应调音频源 1 次,实际 %d", aud.calls)
}
}
func TestTranscribeBlocks_EmptyReplyCountsAsFailure(t *testing.T) {
// 回退源返回空串,往往意味着它上游也剥掉了媒体块。
// 这种情况绝不能当成功——否则又是一次假成功。
main := &stubProvider{name: "text-main"}
vis := &stubProvider{name: "vis-src", vision: true, reply: " "}
mgr := agentAPI.NewProviderManager()
mgr.Register("text-main", main)
mgr.Register("vis-src", vis)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{
Image: types.ImageProcessingConfig{FallbackProvider: "vis-src"},
})
res := a.transcribeBlocksForFallback([]agentAPI.ContentBlock{imageBlock()})
if res.Converted != 0 {
t.Fatalf("空回复不应计入成功转写,得到 Converted=%d", res.Converted)
}
if !strings.Contains(res.Notice, "转写失败") {
t.Fatalf("应报告转写失败,得到 Notice=%q", res.Notice)
}
}
func TestTranscribeBlocks_CapsBlockCount(t *testing.T) {
// see_video 能一次注入 10 帧。上限存在的理由不再是“逐帧调用慢”(现已合包),
// 而是图越多单请求体积越大、上游越慢且易超限。
main := &stubProvider{name: "text-main"}
vis := &stubProvider{name: "vis-src", vision: true, reply: "逐帧描述…"}
mgr := agentAPI.NewProviderManager()
mgr.Register("text-main", main)
mgr.Register("vis-src", vis)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{
Image: types.ImageProcessingConfig{FallbackProvider: "vis-src"},
})
blocks := make([]agentAPI.ContentBlock, 10)
for i := range blocks {
blocks[i] = imageBlock()
}
res := a.transcribeBlocksForFallback(blocks)
if res.Converted != modalFallbackMaxBlocks {
t.Fatalf("应只转写 %d 个,实际 %d", modalFallbackMaxBlocks, res.Converted)
}
if res.Skipped != 10-modalFallbackMaxBlocks {
t.Fatalf("应跳过 %d 个,实际 %d", 10-modalFallbackMaxBlocks, res.Skipped)
}
// 批量合包:上限内的帧应合成**一次**调用,而不是每帧一次。
// 生产实测逐帧调用使 see_video 6 帧拖到 363s 且 3 帧超时。
if vis.calls != 1 {
t.Fatalf("多帧应合包为 1 次调用,实际 %d 次", vis.calls)
}
// 且那一次请求里应带满上限数量的 image 块(加一个 text 提示块)
if vis.lastReq == nil || len(vis.lastReq.Messages) != 1 {
t.Fatal("应只发一条 user 消息")
}
imgBlocks := 0
for _, b := range vis.lastReq.Messages[0].Blocks {
if b.Type == "image_url" {
imgBlocks++
}
}
if imgBlocks != modalFallbackMaxBlocks {
t.Fatalf("单次请求应带 %d 个 image 块,实际 %d", modalFallbackMaxBlocks, imgBlocks)
}
// 跳过的部分也必须告知,否则模型以为自己看全了整段视频
if !strings.Contains(res.Notice, "未转写") {
t.Fatalf("应告知有块未转写,得到 %q", res.Notice)
}
}
func TestTranscribeBlocks_MultiImageBatchedIntoOneCall(t *testing.T) {
// 上限以内的多张图典型see_video 4 帧)同样只能一次调用。
main := &stubProvider{name: "text-main"}
vis := &stubProvider{name: "vis-src", vision: true, reply: "1) 开场 2) 中段 3) 结尾"}
mgr := agentAPI.NewProviderManager()
mgr.Register("text-main", main)
mgr.Register("vis-src", vis)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{
Image: types.ImageProcessingConfig{FallbackProvider: "vis-src"},
})
res := a.transcribeBlocksForFallback([]agentAPI.ContentBlock{
imageBlock(), imageBlock(), imageBlock(),
})
if vis.calls != 1 {
t.Fatalf("3 张图应合为 1 次调用,实际 %d", vis.calls)
}
if res.Converted != 3 {
t.Fatalf("应计入 3 个已转写,实际 %d", res.Converted)
}
if res.Skipped != 0 {
t.Fatalf("不该有跳过,实际 %d", res.Skipped)
}
// 多张时提示词应补上张数,否则模型容易只描述第一张
prompt := vis.lastReq.Messages[0].Blocks[0].Text
if !strings.Contains(prompt, "3 张") {
t.Fatalf("多张提示词应声明张数,得到 %q", prompt)
}
// 帧序列:插件显式给了 detail 就尊重它see_video 本来就传 low
// 只在未指定时才由回退链按张数选默认。
for _, b := range vis.lastReq.Messages[0].Blocks {
if b.Type == "image_url" && b.ImageURL.Detail != "auto" {
t.Fatalf("应保留插件显式指定的 detail=auto得到 %q", b.ImageURL.Detail)
}
}
}
func TestTranscribeBlocks_MultiImageDefaultsToLowDetail(t *testing.T) {
// 未指定 detail 的多张图(帧序列)用 low 控住体积与耗时。
main := &stubProvider{name: "text-main"}
vis := &stubProvider{name: "vis-src", vision: true, reply: "三帧描述"}
mgr := agentAPI.NewProviderManager()
mgr.Register("text-main", main)
mgr.Register("vis-src", vis)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{
Image: types.ImageProcessingConfig{FallbackProvider: "vis-src"},
})
bare := agentAPI.ContentBlock{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: testPNG}}
a.transcribeBlocksForFallback([]agentAPI.ContentBlock{bare, bare, bare})
for _, b := range vis.lastReq.Messages[0].Blocks {
if b.Type == "image_url" && b.ImageURL.Detail != "low" {
t.Fatalf("多张未指定时应默认 low得到 %q", b.ImageURL.Detail)
}
}
}
func TestTranscribeBlocks_SingleImageUsesHighDetail(t *testing.T) {
// 单张图see_picture要看清细节不吝惜 token。
main := &stubProvider{name: "text-main"}
vis := &stubProvider{name: "vis-src", vision: true, reply: "一只橘猫"}
mgr := agentAPI.NewProviderManager()
mgr.Register("text-main", main)
mgr.Register("vis-src", vis)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{
Image: types.ImageProcessingConfig{FallbackProvider: "vis-src"},
})
// Detail 置空,让回退链自己定
a.transcribeBlocksForFallback([]agentAPI.ContentBlock{
{Type: "image_url", ImageURL: &agentAPI.ImageURL{URL: testPNG}},
})
for _, b := range vis.lastReq.Messages[0].Blocks {
if b.Type == "image_url" && b.ImageURL.Detail != "high" {
t.Fatalf("单张应用 high detail得到 %q", b.ImageURL.Detail)
}
}
}
func TestResolveModalFallback_RejectsProviderNotDeclaringCapability(t *testing.T) {
// 配置指向一个没声明 vision 的源:照用只会重演静默剥离。
// 应拒绝它,并继续找真正声明了能力的源。
main := &stubProvider{name: "text-main"}
wrong := &stubProvider{name: "wrong-src"} // vision=false
right := &stubProvider{name: "right-src", vision: true}
mgr := agentAPI.NewProviderManager()
mgr.Register("text-main", main)
mgr.Register("wrong-src", wrong)
mgr.Register("right-src", right)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{
Image: types.ImageProcessingConfig{FallbackProvider: "wrong-src"},
})
p, name := a.resolveModalFallback("image")
if name != "right-src" {
t.Fatalf("应跳过未声明能力的 wrong-src 而选中 right-src得到 %q", name)
}
if p == nil {
t.Fatal("应返回可用 provider")
}
}
func TestResolveModalFallback_ScansForCapableSourceWhenUnconfigured(t *testing.T) {
// 用户可能只在源上声明了 vision 却忘了填 fallback_provider。
// 静默失败比多找一个能用的源更糟。
main := &stubProvider{name: "text-main"}
vis := &stubProvider{name: "some-vision-src", vision: true}
mgr := agentAPI.NewProviderManager()
mgr.Register("text-main", main)
mgr.Register("some-vision-src", vis)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{})
_, name := a.resolveModalFallback("image")
if name != "some-vision-src" {
t.Fatalf("未配置时应扫出声明了能力的源,得到 %q", name)
}
}
func TestPrepareToolBlocks_TextBlocksAlwaysPassThrough(t *testing.T) {
main := &stubProvider{name: "text-main"}
mgr := agentAPI.NewProviderManager()
mgr.Register("text-main", main)
a := newFallbackAgent(main, mgr, types.InputProcessingConfig{})
native, fallbackText := a.prepareToolBlocks([]agentAPI.ContentBlock{
{Type: "text", Text: "纯文字说明"},
})
if len(native) != 1 || native[0].Text != "纯文字说明" {
t.Fatalf("text 块应无条件直通,得到 %+v / %q", native, fallbackText)
}
}

View File

@ -313,10 +313,21 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
msgs = append(msgs, agentAPI.Message{Role: "assistant", Content: msgContent, ReasoningContent: resp.ReasoningContent, ToolCalls: []agentAPI.ToolCall{tc}})
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block
// process.go 拾起并追加到 tool message 的 content 数组OpenAI 多模态格式),
// 让下一轮 LLM 请求在 tool message 里看到图/音频。
// 多模态工具结果:插件通过 SDK.SetToolBlocks 注入 image_url/audio_url block
//
// 媒体不挂在 tool message 上,而是另起一条紧随其后的 user message——
// 这也是插件文案一直在说的「注入后续对话」。
// 为何不能挂 tool message同一张图、同一模型、三轮实测——
// 图在 user message → 3/3 读到
// 图在 tool message → 0/3模型答「没能读到这张图」
// tool 纯文本 + 后接 user → 3/3 读到
// tool message 那轮 prompt_tokens 反而更高7967 vs 7089base64 确实
// 进了上游,但 role=tool 上的多模态 content 数组不被当作可视内容。
//
// 主模型不支持该模态时更不能直接塞:网关会把 image_url 静默剥离后仍
// 返回 200模型回答「我没有看到图片」而内核以为注入成功。改走回退链。
toolMsg := agentAPI.Message{Role: "tool", ToolCallID: tc.ID, Content: result}
var mediaMsg *agentAPI.Message
if rawBlocks := a.io.ConsumeToolBlocks(); len(rawBlocks) > 0 {
var blocks []agentAPI.ContentBlock
for _, b := range rawBlocks {
@ -333,10 +344,30 @@ func (a *Agent) process(input string, stageCtx *sdk.StageContext) (response stri
}
}
if len(blocks) > 0 {
toolMsg.Blocks = blocks
if native, fallbackText := a.prepareToolBlocks(blocks); len(native) > 0 {
// 能直视:另起一条 user message 承载媒体,并补一句来源说明,
// 否则模型会把它当成用户新发的图而不是工具拉回来的。
mediaBlocks := append([]agentAPI.ContentBlock{{
Type: "text",
Text: fmt.Sprintf("[以下是 %s 注入的媒体内容]", tc.Name),
}}, native...)
mediaMsg = &agentAPI.Message{Role: "user", Blocks: mediaBlocks}
} else if fallbackText != "" {
// 回退链已把媒体转写成文字:并进 tool message 的纯文本 content
// 不再另起消息(文字在 tool message 里本来就能被读到)。
toolMsg.Content = result + "\n\n" + fallbackText
result = toolMsg.Content
if len(toolResults) > 0 {
toolResults[len(toolResults)-1].Output = result
}
}
}
}
msgs = append(msgs, toolMsg)
if mediaMsg != nil {
// 必须紧跟在 toolMsg 之后:中间插入其他消息会让 tool_call_id 配对断开。
msgs = append(msgs, *mediaMsg)
}
a.publishEvent(events.EventToolCall, map[string]interface{}{
"tool": tc.Name,

View File

@ -188,6 +188,8 @@ var sourceFieldDefs = []struct {
{"adapter_path", "string", "适配器路径"},
{"max_concurrent", "int", "并发上限"},
{"priority", "int", "AUTO 优先级(大者优先)"},
{"vision", "bool", "支持图片"},
{"audio", "bool", "支持音频"},
}
// registerSourceDefs 注册 core.llm.sources.<name>.* 的 ConfigDef
@ -861,6 +863,8 @@ func (r *ConfigRegistry) ToConfig() *types.Config {
MaxConcurrent: readInt(p+".max_concurrent", 8),
Priority: readInt(p+".priority", 0),
ThinkingEnabled: readBool(p+".thinking_enabled", false),
Vision: readBool(p+".vision", false),
Audio: readBool(p+".audio", false),
})
}

View File

@ -10,7 +10,10 @@ var (
// 1.0.0:外部插件从 C ABI 动态库迁到子进程 + 共享内存。
// 这是首个不再加载 `.so`/`.dll` 的版本,与 0.9.x 不兼容(存量插件必须
// 用新版 plugindev 重编),故跃到主版本号。
Version = "1.0.0"
//
// 1.0.1:多模态修复。仅内核与内置插件改动,插件 ABI/协议未变,
// 1.0.0 编出的 plugin.bin 无需重编。
Version = "1.0.4"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"

View File

@ -144,48 +144,83 @@ func (h *Host) Close() error {
//
// 首个进入者:获取 stageMu独占共享段→ 把内核 StageContext 写入段。
// 后续进入者:仅递增 inflight。
//
// enter() 在 coordMu 内完成,两个原因:
// 1. 首进者的 WriteAll 未结束前不能让后到者拿到 coord 就去读共享段
// (旧码的后到者 enter 立即返回,可能读到写一半的段)。
// 2. 与 endStage 的摘除互斥,防止后到者挂进一个正在收尾的协调器
// (具体见 endStage 的注释)。
//
// 锁序stageMu → coordMu。endStage 只解锁 stageMu、不获取所以无环。
func (h *Host) beginStage(sc *pubsdk.StageContext) (*stageCoordinator, error) {
h.coordMu.Lock()
first := h.coord == nil
if first {
// 独占共享段直到本次 stage 全部插件离开
if h.coord == nil {
// 首个进入者:独占共享段直到本次 stage 全部插件离开。
// 必须先放 coordMu 再取 stageMu不能反序。
h.coordMu.Unlock()
h.stageMu.Lock()
h.coordMu.Lock()
// 双检:等锁期间可能已有其他插件建好协调器(它们会先拿到 stageMu
if h.coord != nil {
first = false
h.stageMu.Unlock()
} else {
h.coord = newStageCoordinator(h.seg)
}
}
coord := h.coord
h.coordMu.Unlock()
if err := coord.enter(sc, first); err != nil {
if first {
h.coordMu.Lock()
h.coord = nil
if h.coord == nil {
coord := newStageCoordinator(h.seg)
h.coord = coord
if err := coord.enter(sc, true); err != nil {
// 注意runStage 的 defer endStage(coord) 是在 beginStage
// 返回 err 的检查之后才注册的,所以这条路径上
// endStage 永远不会被调用——stageMu 必须在此自行释放,
// 否则整个 stage 通道永久卡死。
h.coord = nil
h.coordMu.Unlock()
h.stageMu.Unlock()
return nil, err
}
h.coordMu.Unlock()
h.stageMu.Unlock()
h.locks.bind(coord.lock)
return coord, nil
}
// 双检失败:等锁期间已有其他插件建好协调器,退回后到者路径。
h.stageMu.Unlock()
}
coord := h.coord
if err := coord.enter(sc, false); err != nil {
h.coordMu.Unlock()
return nil, err
}
h.coordMu.Unlock()
h.locks.bind(coord.lock)
return coord, nil
}
// endStage 由插件 handler 返回时调用。
// 最后离开者:把共享段结果读回内核 StageContext → 压实 arena → 释放 stageMu。
//
// coordMu 必须覆盖「递减 inflight → 判定最后离开者 → 摘除 h.coord」全过程。
// 旧码把 leave() 放在 coordMu 之外,留出了这个窗口(即 2026-09-04 06:56:18
// 线上 fatal error: sync: unlock of unlocked mutex 的真因):
//
// A.endStage: leave() → inflight 1→0, last=true尚未摘除 h.coord
// B.beginStage: 看到 h.coord != nil以「后到者」身份 enterinflight 0→1
// (后到者不取 stageMu
// A.endStage: h.coord = nilstageMu.Unlock() ← 第 1 次
// B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥
//
// B 从未持有 stageMu它是后到者却因为挂进了一个正在收尾的协调器
// 而成为“最后离开者”于是对同一把锁解了两次。sync.Mutex 的双重解锁是
// runtime fatal**recover 捕不到**——这就是为何 stage.go / stages.go 里
// 那两层 recover 全部失效、整个 homed 直接死掉的原因。
func (h *Host) endStage(coord *stageCoordinator) error {
last, err := coord.leave()
if !last {
return err
}
h.coordMu.Lock()
h.coord = nil
last, sc, written := coord.depart()
if last && h.coord == coord {
h.coord = nil
}
h.coordMu.Unlock()
if !last {
return nil
}
// finish 必须在 stageMu.Unlock() 之前:先放锁会让下一轮 stage
// 在回读未完时就改写共享段。
err := coord.finish(sc, written)
h.stageMu.Unlock()
return err
}
@ -232,26 +267,38 @@ func (c *stageCoordinator) enter(sc *pubsdk.StageContext, first bool) error {
// leave 登记一个插件离开;返回是否为最后一个离开者。
//
// 最后离开者负责把共享段结果读回内核 StageContext并压实 arena
// (此时无插件持锁,满足 §3.3 的压实前提)。
// 拆成两段depart() 只动计数(由 endStage 在 coordMu 内调用,使
// 「递减 → 判定最后者 → 摘除 h.coord」成为原子操作finish() 做
// 共享段回读与压实。本方法保留给单测用。
func (c *stageCoordinator) leave() (last bool, err error) {
c.mu.Lock()
c.inflight--
last = c.inflight == 0
sc := c.ctxRef
written := c.written
c.mu.Unlock()
if !last || !written || sc == nil {
last, sc, written := c.depart()
if !last {
return last, nil
}
return last, c.finish(sc, written)
}
// depart 递减 inflight 并报告是否为最后离开者。
func (c *stageCoordinator) depart() (last bool, sc *pubsdk.StageContext, written bool) {
c.mu.Lock()
defer c.mu.Unlock()
c.inflight--
return c.inflight == 0, c.ctxRef, c.written
}
// finish 把共享段结果读回内核 StageContext 并压实 arena
// (此时无插件持锁,满足 §3.3 的压实前提)。
func (c *stageCoordinator) finish(sc *pubsdk.StageContext, written bool) error {
if !written || sc == nil {
return nil
}
if rErr := c.seg.ReadInto(sc); rErr != nil {
return last, fmt.Errorf("回读共享段: %w", rErr)
return fmt.Errorf("回读共享段: %w", rErr)
}
if reclaimed := c.seg.Compact(); reclaimed > 0 {
log.Printf("[proc] stage 结束arena 压实回收 %d 字节", reclaimed)
}
return last, nil
return nil
}
// ShmSize 返回共享段大小(供诊断/日志)。

View File

@ -0,0 +1,220 @@
package proc
import (
"sync"
"sync/atomic"
"testing"
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
// 本文件是 2026-09-04 06:56:18 线上 crash 的回归测试。
//
// 崩溃形态homed 主进程直接死亡,退出码 2。
//
// fatal error: sync: unlock of unlocked mutex
// proc.(*Host).endStage(...) host.go:189
// proc.(*coreHandler).runStage.func1() stage.go:94
// core.(*StageHost).RunStage.func1() stages.go:190
//
// 注意 stage.go 与 stages.go 各有一层 recover却都没拦住——
// sync.Mutex 的双重解锁是 runtime fatalrecover 捕不到。这是本次
// "整个内核本体崩溃"而非"插件崩溃被隔离"的直接原因。
// TestEndStage_LateArrivalNoDoubleUnlock 复现根因竞态。
//
// 旧实现把 leave() 放在 coordMu 之外,留出这个窗口:
//
// A.endStage: leave() → inflight 1→0, last=true尚未摘除 h.coord
// B.beginStage: 看到 h.coord != nil以「后到者」身份 enterinflight 0→1
// (后到者不取 stageMu
// A.endStage: h.coord = nil; stageMu.Unlock() ← 第 1 次
// B.endStage: leave() → inflight 1→0, last=true → stageMu.Unlock() ← 第 2 次 💥
//
// B 从未持有 stageMu却因为挂进了一个正在收尾的协调器而成为
// "最后离开者",于是对同一把锁解了两次。
//
// 本测试直接驱动 depart/enter 制造那个时序,不依赖调度巧合。
func TestEndStage_LateArrivalNoDoubleUnlock(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
scA := &pubsdk.StageContext{RawMessage: "A"}
coordA, err := host.beginStage(scA)
if err != nil {
t.Fatalf("A beginStage: %v", err)
}
// A 收尾:修复后 depart 与摘除 h.coord 在同一个 coordMu 临界区内,
// 所以此刻起 h.coord 已是 nilB 不可能再挂进 A 的协调器。
if err := host.endStage(coordA); err != nil {
t.Fatalf("A endStage: %v", err)
}
// B 现在进入:必须成为新的首进者(拿到自己的 stageMu
// 而不是挂进 A 那个已收尾的协调器。
scB := &pubsdk.StageContext{RawMessage: "B"}
coordB, err := host.beginStage(scB)
if err != nil {
t.Fatalf("B beginStage: %v", err)
}
if coordB == coordA {
t.Fatal("B 不该复用 A 已收尾的协调器——这正是 double-unlock 的来源")
}
if err := host.endStage(coordB); err != nil {
t.Fatalf("B endStage: %v", err)
}
// 若上面多解了一次锁,这里会 fatalruntime 级,测试进程直接死);
// 能走到这一步说明配对正确。
scC := &pubsdk.StageContext{RawMessage: "C"}
coordC, err := host.beginStage(scC)
if err != nil {
t.Fatalf("C beginStage: %v", err)
}
if err := host.endStage(coordC); err != nil {
t.Fatalf("C endStage: %v", err)
}
}
// TestEndStage_ConcurrentChurnNoFatal 高并发进出:真实触发线上那个窗口。
//
// 旧实现下这个测试会以 fatal error: sync: unlock of unlocked mutex 结束
// (整个测试二进制死亡,不是 FAIL。修复后应干净通过。
func TestEndStage_ConcurrentChurnNoFatal(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
const workers = 8
const rounds = 40
var wg sync.WaitGroup
var failures atomic.Int64
for w := 0; w < workers; w++ {
wg.Add(1)
go func() {
defer wg.Done()
for r := 0; r < rounds; r++ {
sc := &pubsdk.StageContext{RawMessage: "churn"}
coord, err := host.beginStage(sc)
if err != nil {
failures.Add(1)
return
}
if err := host.endStage(coord); err != nil {
failures.Add(1)
return
}
}
}()
}
wg.Wait()
if n := failures.Load(); n > 0 {
t.Fatalf("%d 次 begin/end 失败", n)
}
}
// TestBeginStage_MultiPluginSameStage 同阶段多插件扇出:
// 首进者取 stageMu后到者只递增 inflight最后离开者才解锁。
// 验证并发扇出这一原始设计仍然成立§0.2 第 1 条)。
func TestBeginStage_MultiPluginSameStage(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
sc := &pubsdk.StageContext{RawMessage: "fanout"}
// 三个插件先后进入同一次 stage
c1, err := host.beginStage(sc)
if err != nil {
t.Fatalf("plugin1 beginStage: %v", err)
}
c2, err := host.beginStage(sc)
if err != nil {
t.Fatalf("plugin2 beginStage: %v", err)
}
c3, err := host.beginStage(sc)
if err != nil {
t.Fatalf("plugin3 beginStage: %v", err)
}
// 同一次 stage 内必须共用一个协调器(共享同一份 StageContext 段)
if c1 != c2 || c2 != c3 {
t.Fatal("同阶段并发插件应共用一个协调器")
}
// 前两个离开不该释放 stageMu
if err := host.endStage(c1); err != nil {
t.Fatalf("plugin1 endStage: %v", err)
}
if err := host.endStage(c2); err != nil {
t.Fatalf("plugin2 endStage: %v", err)
}
// 最后一个离开才释放
if err := host.endStage(c3); err != nil {
t.Fatalf("plugin3 endStage: %v", err)
}
// 锁已释放:新一轮能立即开始
c4, err := host.beginStage(sc)
if err != nil {
t.Fatalf("新一轮 beginStage 应成功stageMu 已释放): %v", err)
}
if c4 == c1 {
t.Fatal("新一轮应是新的协调器")
}
if err := host.endStage(c4); err != nil {
t.Fatalf("新一轮 endStage: %v", err)
}
}
// TestBeginStage_SerialRounds 长串行:确认没有单向泄漏(少解锁会在第二轮卡死)。
func TestBeginStage_SerialRounds(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
for round := 0; round < 50; round++ {
sc := &pubsdk.StageContext{RawMessage: "serial"}
coord, err := host.beginStage(sc)
if err != nil {
t.Fatalf("round %d beginStage: %v", round, err)
}
if err := host.endStage(coord); err != nil {
t.Fatalf("round %d endStage: %v", round, err)
}
}
}
// TestBeginStage_PhaseSequence 模拟一条消息走完 pre_action → chat → post_action。
func TestBeginStage_PhaseSequence(t *testing.T) {
host, err := NewHost()
if err != nil {
t.Fatalf("NewHost: %v", err)
}
defer host.Close()
phases := []pubsdk.Stage{"pre_action", "chat", "after_toolcall", "post_action"}
for msg := 0; msg < 10; msg++ {
for _, p := range phases {
sc := &pubsdk.StageContext{RawMessage: "msg", Phase: p}
coord, err := host.beginStage(sc)
if err != nil {
t.Fatalf("msg %d phase %s beginStage: %v", msg, p, err)
}
if err := host.endStage(coord); err != nil {
t.Fatalf("msg %d phase %s endStage: %v", msg, p, err)
}
}
}
}

View File

@ -18,10 +18,10 @@ import (
)
const (
DefaultTimeout = 5 * time.Minute
ReadBufSize = 4096
MaxOutputBuffer = 128 * 1024
DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知
DefaultTimeout = 5 * time.Minute
ReadBufSize = 4096
MaxOutputBuffer = 128 * 1024
DefaultNotifyBytes = 2048 // 积累 2KB 未读输出再通知
DefaultNotifyInterval = 2 * time.Second // 同一终端两次通知的最小间隔(兜底)
)
@ -68,12 +68,12 @@ type TerminalSession struct {
done chan struct{}
// 通知节流字段
unreadBytes int // 最近一次通知后积累的未读字节数
lastNotify time.Time // 最近一次通知时间
lastData time.Time // 最近一次读到的数据时间(用于判定输出停止)
lastFeedback time.Time // 最近一次定时反馈时间
backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍
watch terminalWatch // 该终端的提醒规则
unreadBytes int // 最近一次通知后积累的未读字节数
lastNotify time.Time // 最近一次通知时间
lastData time.Time // 最近一次读到的数据时间(用于判定输出停止)
lastFeedback time.Time // 最近一次定时反馈时间
backoff time.Duration // 输出风暴退避:持续高速输出时通知间隔翻倍
watch terminalWatch // 该终端的提醒规则
// 实时画面推流terminal_output 事件)
stream bytes.Buffer // 待推送的增量输出,由 readLoop 每 200ms flush 一次
@ -226,7 +226,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
Description: "创建一个新的交互式终端会话。返回终端 ID后续通过此 ID 进行读写操作。适用于运行交互式程序如 vim、ssh、top、nano 等。" +
"通知模式通过 notify 参数选择(默认 exitexit=仅命令执行结束后提醒一次interval=定时反馈(如 interval=30s 每 30 秒反馈一次状态摘要);" +
"buffer=未读输出积累到指定字节数后提醒(如 buffer=8192多个模式用逗号组合如 interval=30s,buffer=8192。终端默认 5 分钟后自动关闭,可通过 timeout 参数调整。",
NoMemory: true,
NoMemory: true,
Parameters: map[string]interface{}{
"type": "object",
"properties": map[string]interface{}{
@ -283,7 +283,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
})
s.RegisterTool("terminal_read", sdk.ToolDef{
Name: "terminal_read",
Name: "terminal_read",
Description: "读取指定终端的输出。mode=new默认返回自上次读取以来的新输出并清空缓冲mode=now 返回终端当前显示的全部屏幕内容(不清空缓冲)。如需持续监控请多次调用。",
NoMemory: true,
Parameters: map[string]interface{}{
@ -759,11 +759,11 @@ func (p *Plugin) handleList() (interface{}, error) {
defer p.mu.Unlock()
type termInfo struct {
ID string `json:"id"`
Command string `json:"command"`
Uptime string `json:"uptime"`
ID string `json:"id"`
Command string `json:"command"`
Uptime string `json:"uptime"`
ExpiresIn string `json:"expires_in"`
Running bool `json:"running"`
Running bool `json:"running"`
}
var terms []termInfo
@ -787,8 +787,8 @@ func (p *Plugin) handleList() (interface{}, error) {
}
return map[string]interface{}{
"status": "ok",
"count": len(terms),
"status": "ok",
"count": len(terms),
"terminals": terms,
}, nil
}
@ -797,6 +797,9 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
defer p.wg.Done()
defer close(t.done)
// reader 协程独享这个读缓冲:结果随 readResult 携带,
// readLoop 不再从其中做 copy见 reader 注释,那是对共享缓冲
// 的并发读写,-race 实测触发)。
buf := make([]byte, ReadBufSize)
pollInterval := 200 * time.Millisecond
@ -816,7 +819,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
t.lastFeedback = now
t.mu.Unlock()
// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低
// 硬上限:未读输出积累达到该值也通知一次(防大输出静默丢失),频率极低
hardNotifyBytes := 64 * 1024
hardNotifyInterval := 10 * time.Second
// 输出停止判定:超过该时长无新数据则视为输出停止
@ -886,9 +889,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
return
}
if r.n > 0 {
data := make([]byte, r.n)
copy(data, buf[:r.n])
t.appendOutput(data)
t.appendOutput(r.data)
// 缓冲阈值通知(仅当 agent 显式选择 buffer 模式,或未读积累达到硬上限)。
// 默认模式(仅 exit 提醒)下不随输出流通知,杜绝通知风暴。
@ -951,15 +952,28 @@ func previewTail(s string, n int) string {
}
type readResult struct {
n int
err error
n int
data []byte
err error
}
// reader 从终端读取输出并通过 channel 交给 readLoop。
//
// 读到的数据**随结果一起传**而不是复用外层共享的 buf
// reader 是唯一写 buf 的 goroutinereadLoop 又常在 reader 尚未
// 写完下一段时就从 buf[:r.n] 做 copy——同一个 shared buf 被并发
// 读写就是 data race-race 实测触发)。改为每个结果自带切片后,
// 读与拷贝天然隔离,不再共享可变状态。
func (p *Plugin) reader(t *TerminalSession, buf []byte, ch chan<- readResult) {
for {
n, err := t.session.Read(buf)
var data []byte
if n > 0 {
data = make([]byte, n)
copy(data, buf[:n])
}
select {
case ch <- readResult{n, err}:
case ch <- readResult{n, data, err}:
case <-t.stopCh:
return
}

View File

@ -7,6 +7,7 @@ import (
"os"
"os/exec"
"path/filepath"
"strconv"
"strings"
"gitcode.com/JianFeeeee/HomeAgent/internal/plugin"
@ -61,7 +62,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
},
"frames": map[string]interface{}{
"type": "integer",
"description": "提取关键帧数量(默认 4最大 10",
"description": "均匀抽取的关键帧数量(默认 4最大 10。按视频总时长均分,不是每几秒一帧。",
},
},
"required": []string{"path"},
@ -175,6 +176,21 @@ func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error
}
}
// 用 ffprobe 拿时长,才能把「抽 N 帧」翻译成 ffmpeg 的帧率。
//
// 为何不能直接写 fps=1/Nfps 是**频率**(每 N 秒一帧),不是**数量**。
// 20 秒视频实测fps=1/4 → 5 帧fps=1/10 → 2 帧fps=1/1 → 20 帧——
// 要得越多拿得越少,且长视频下 frames=4 会产出时长/4 帧直接炸上下文。
// 正确写法是 fps=N/时长 配 -frames:v N实测 N=1/4/10 均精确)。
dur := probeDuration(ffmpegPath, path)
var vfArgs []string
if dur > 0 {
vfArgs = []string{"-vf", fmt.Sprintf("fps=%d/%.3f", nFrames, dur)}
}
// 拿不到时长(无 ffprobe / 容器无时长元数据):不传 -vf只靠 -frames:v
// 取开头 N 帧。不能退化成 fps=1不足 1 秒的素材一帧也抽不出来(实测
// 0.4s 视频 fps=1 → 0 帧),而 fps=N/dur 在 0.4s 上依然精确。
// 用 ffmpeg 提取关键帧
tmpDir, err := os.MkdirTemp("", "mm_video_*")
if err != nil {
@ -183,36 +199,46 @@ func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error
defer os.RemoveAll(tmpDir)
outPattern := filepath.Join(tmpDir, "frame_%03d.jpg")
cmd := exec.Command(ffmpegPath, "-i", path, "-vf", fmt.Sprintf("fps=1/%d", nFrames),
"-q:v", "5", outPattern)
// -frames:v 硬封顶:即使 fps 计算因时长误差多给了帧,也不会超出请求数量。
ffArgs := []string{"-v", "error", "-i", path}
ffArgs = append(ffArgs, vfArgs...)
ffArgs = append(ffArgs, "-q:v", "5", "-frames:v", strconv.Itoa(nFrames), outPattern)
cmd := exec.Command(ffmpegPath, ffArgs...)
if out, err := cmd.CombinedOutput(); err != nil {
return fmt.Sprintf("ffmpeg 提取帧失败: %v\n%s", err, string(out)), nil
}
// 读取提取的帧
// 读取提取的帧。按 blocks 长度而非目录索引封顶:
// 跳过的条目(非 jpg / 读失败 / 过大)会让索引与实际帧数错位。
entries, _ := os.ReadDir(tmpDir)
var blocks []pubsdk.ContentBlock
for i, entry := range entries {
if strings.HasSuffix(entry.Name(), ".jpg") {
b, err := os.ReadFile(filepath.Join(tmpDir, entry.Name()))
if err != nil {
continue
}
if len(b) > 2*1024*1024 {
continue // 跳过过大帧
}
dURL := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(b)
blocks = append(blocks, pubsdk.ContentBlock{
Type: "image_url",
ImageURL: &pubsdk.ImageURL{URL: dURL, Detail: "low"},
})
if i >= 9 { // 最多 10 帧
break
}
var skippedLarge int
for _, entry := range entries {
if len(blocks) >= nFrames {
break
}
if !strings.HasSuffix(entry.Name(), ".jpg") {
continue
}
b, err := os.ReadFile(filepath.Join(tmpDir, entry.Name()))
if err != nil {
continue
}
if len(b) > 2*1024*1024 {
skippedLarge++
continue
}
dURL := "data:image/jpeg;base64," + base64.StdEncoding.EncodeToString(b)
blocks = append(blocks, pubsdk.ContentBlock{
Type: "image_url",
ImageURL: &pubsdk.ImageURL{URL: dURL, Detail: "low"},
})
}
if len(blocks) == 0 {
if skippedLarge > 0 {
return fmt.Sprintf("提取到 %d 帧但全部超过 2MB 单帧上限,未注入", skippedLarge), nil
}
return "视频中未提取到有效帧", nil
}
@ -220,9 +246,39 @@ func (p *Plugin) handleSeeVideo(args map[string]interface{}) (interface{}, error
p.sdk.SetToolBlocks(blocks)
text := fmt.Sprintf("[已将 %d 个视频关键帧注入后续对话] %s", len(blocks), path)
if skippedLarge > 0 {
text += fmt.Sprintf("(另有 %d 帧超 2MB 已跳过)", skippedLarge)
}
if len(blocks) < nFrames {
text += fmt.Sprintf("(请求 %d 帧,实际只取到 %d 帧,视频可能过短)", nFrames, len(blocks))
}
return text, nil
}
// probeDuration 用 ffprobe 取视频时长(秒),拿不到返回 0。
//
// ffprobe 与 ffmpeg 同包同目录,所以从已找到的 ffmpeg 路径推导而非重新搜一遍。
func probeDuration(ffmpegPath, videoPath string) float64 {
probe := "ffprobe"
if strings.Contains(ffmpegPath, "/") {
probe = filepath.Join(filepath.Dir(ffmpegPath), "ffprobe")
if _, err := os.Stat(probe); err != nil {
probe = "ffprobe"
}
}
out, err := exec.Command(probe, "-v", "error",
"-show_entries", "format=duration",
"-of", "default=nw=1:nk=1", videoPath).Output()
if err != nil {
return 0
}
d, err := strconv.ParseFloat(strings.TrimSpace(string(out)), 64)
if err != nil {
return 0
}
return d
}
// ── listen ───────────────────────────────────────────────────────
func (p *Plugin) handleListen(args map[string]interface{}) (interface{}, error) {

View File

@ -213,27 +213,68 @@ func TestRealPlugin_CrashDoesNotKillKernel(t *testing.T) {
t.Fatal("editdoc 未加载")
}
// 找插件子进程并 SIGKILL
pid := findPluginPID(t, "editdoc")
// 找插件子进程并 SIGKILL
//
// 必须拿 plgDir 限定范围:旧实现用全系统 pgrep -f plugin.bin 后
// 只比“路径含 editdoc”于是在跑着生产实例的机器上它会把
// /home/newqqagent/plugins/editdoc/plugin.bin 当成目标杀掉(实测 9 次,
// 全部落在有人跑 go test 的时段)。更糟的是此时本测试仍会通过:
// 它断言的是测试内核存活,而那个内核的插件压根没死——**它在测一件
// 没发生的事**,同时还把生产环境打坏了。
pid := findPluginPID(t, plgDir, "editdoc")
if pid == 0 {
t.Skip("未找到插件子进程(进程名匹配失败)")
t.Skip("未找到本测试自己拉起的插件子进程")
}
t.Logf("kill 插件进程 pid=%d", pid)
t.Logf("kill 插件进程 pid=%d (exe 在 %s 下)", pid, plgDir)
if err := syscall.Kill(pid, syscall.SIGKILL); err != nil {
t.Fatalf("kill: %v", err)
}
// 内核必须存活并能继续工作
time.Sleep(300 * time.Millisecond)
// 先确认目标进程真的死了。
//
// 这步不能省:旧版直接断言“内核存活”,而内核本来就活着——
// 即使 SIGKILL 发错了对象(杀了生产实例的插件)测试也会结束。
// 先验“目标真死”再验“内核未被连带”,两步都成立才能证明隔离生效。
deadline := time.Now().Add(3 * time.Second)
dead := false
for time.Now().Before(deadline) {
if syscall.Kill(pid, 0) != nil {
dead = true
break
}
time.Sleep(50 * time.Millisecond)
}
if !dead {
t.Fatalf("pid=%d 在 SIGKILL 后 3s 内未退出,崩溃隔离无从验证", pid)
}
// 内核(本测试进程)必须存活并能继续工作
if env.pluginReg.List() == nil {
t.Fatal("内核在插件崩溃后不可用")
}
t.Logf("插件崩溃后内核存活,已加载插件数=%d", len(env.pluginReg.List()))
t.Logf("插件进程已确认退出,内核存活,已加载插件数=%d", len(env.pluginReg.List()))
}
// findPluginPID 按二进制路径找插件子进程 pid。
func findPluginPID(t *testing.T, name string) int {
// findPluginPID 在**指定插件目录下**找插件子进程 pid。
//
// root 参数是硬约束,不是可选过滤器:本函数的唯一用途是给崩溃隔离
// 测试提供一个“可以安全 SIGKILL 的 pid”而安全的定义就是它必须属于
// 本测试自己的临时目录。不带这个约束就会误杀同机生产实例的插件。
//
// 匹配依据是 /proc/<pid>/exe 的真实路径必须以 root 为前缀。
// 用 exe 而不用 cmdlinecmdline 可被进程自行改写,而 exe 符链由内核维护。
// root 先过一道 EvalSymlinks/tmp 在部分发行版上是符链(如 macOS 的
// /tmp -> /private/tmp不归一化会让前缀比较永远不命中退化成静默 Skip。
func findPluginPID(t *testing.T, root, name string) int {
t.Helper()
if root == "" {
t.Fatal("findPluginPID: root 不得为空(防止误杀全系统同名插件)")
}
realRoot, err := filepath.EvalSymlinks(root)
if err != nil {
realRoot = root
}
out, err := exec.Command("pgrep", "-f", "plugin.bin").Output()
if err != nil {
return 0
@ -244,15 +285,18 @@ func findPluginPID(t *testing.T, name string) int {
if pid == 0 {
continue
}
// 校验 cwd 或 cmdline 含插件名
exe, err := os.Readlink(fmt.Sprintf("/proc/%d/exe", pid))
if err == nil && strings.Contains(exe, name) {
return pid
if err != nil {
continue
}
cwd, err := os.Readlink(fmt.Sprintf("/proc/%d/cwd", pid))
if err == nil && strings.Contains(cwd, name) {
return pid
// 两道条件同时成立才算命中:在本测试的目录树内,且是目标插件
if !strings.HasPrefix(exe, realRoot+string(os.PathSeparator)) {
continue
}
if !strings.Contains(exe, name) {
continue
}
return pid
}
return 0
}

View File

@ -37,6 +37,24 @@ type DeviceMeta struct {
type wconn struct {
deviceID string
w *bufio.Writer
// wmu 序列化对该连接 bufio.Writer 的所有写。
//
// 两个角色会并发写同一连接handleWS 主循环(读设备帧后的 hello_ack/
// bind_ack/pong 回写)与 PushJSON/PushDataagent→设备的下发路径可能
// 来自任意 goroutine。bufio.Writer 不是线程安全的,不加锁会在
// WriteByte/Flush 上产生 data race生产实测触发
wmu sync.Mutex
}
// lockWrite 对 wconn 加写锁并返回 writer调用方必须 defer unlockWrite。
// 单独写成方法而不是直接暴露字段,避免调用方绕过锁。
func (c *wconn) lockWrite() *bufio.Writer {
c.wmu.Lock()
return c.w
}
func (c *wconn) unlockWrite() {
c.wmu.Unlock()
}
// Registry 是设备接入网关的注册表:管理在线连接、设备元数据。线程安全。
@ -319,7 +337,9 @@ func (r *Registry) PushJSON(deviceID string, payload map[string]interface{}) err
if !ok {
return fmt.Errorf("device %s not online", deviceID)
}
return writeText(c.w, mustJSON(payload))
w := c.lockWrite()
defer c.unlockWrite()
return writeText(w, mustJSON(payload))
}
// PushCmd 向设备发送命令执行请求。
@ -348,7 +368,11 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err
if !ok {
return fmt.Errorf("device %s not online", deviceID)
}
if err := writeText(c.w, mustJSON(map[string]interface{}{
// 整条下发start + N 个 chunk + end持锁设备侧按协议串行聚合
// 若中途被 handleWS 的 hello/pong 插帧会破坏协议顺序。
w := c.lockWrite()
defer c.unlockWrite()
if err := writeText(w, mustJSON(map[string]interface{}{
"op": "cmd_speech_start",
"req_id": reqID,
"kind": kind,
@ -363,11 +387,11 @@ func (r *Registry) PushData(deviceID, reqID, kind, mime string, data []byte) err
if end > len(data) {
end = len(data)
}
if err := writeBinary(c.w, data[off:end]); err != nil {
if err := writeBinary(w, data[off:end]); err != nil {
return fmt.Errorf("push data chunk: %w", err)
}
}
if err := writeText(c.w, mustJSON(map[string]interface{}{
if err := writeText(w, mustJSON(map[string]interface{}{
"op": "cmd_speech_end",
"req_id": reqID,
})); err != nil {
@ -618,6 +642,26 @@ func (r *Registry) ServeWS(w http.ResponseWriter, req *http.Request) {
go r.handleWS(conn, rw)
}
// wsWriteLocked 在指定设备连接的写锁保护下执行写回调。
//
// handleWS 主循环与 Push* 是两条并发写同一 bufio.Writer 的路径,
// 必须共用同一把锁。handleWS 里拿到的是 rw.Writer与 conns 存储的是
// 同一个对象),回写前必须经此函数取锁,否则跟 Push* 依然会撞。
//
// 注意设备已离线conns 中已删除)时直接报错——设备断开后仍尝试
// 回写没有意义,还可能在已关闭的 bufio 上写入。
func (r *Registry) wsWriteLocked(deviceID string, fn func(w *bufio.Writer) error) error {
r.mu.RLock()
c, ok := r.conns[deviceID]
r.mu.RUnlock()
if !ok {
return fmt.Errorf("device %s not online", deviceID)
}
w := c.lockWrite()
defer c.unlockWrite()
return fn(w)
}
func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
defer conn.Close()
var curID string
@ -635,7 +679,9 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
payload, isClose, opcode, err := readFrame(rw.Reader)
if err != nil {
if err == errPing {
if werr := writePong(rw.Writer); werr != nil {
// pong 也走写锁:它可能在 Push* 持锁推送大块数据时到达。
err := r.wsWriteLocked(curID, writePong)
if err != nil {
return
}
continue
@ -679,11 +725,13 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
r.mu.Lock()
r.conns[meta.DeviceID] = &wconn{deviceID: meta.DeviceID, w: rw.Writer}
r.mu.Unlock()
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{
"op": "hello_ack",
"device": meta.DeviceID,
"online": true,
})); err != nil {
if err := r.wsWriteLocked(meta.DeviceID, func(w *bufio.Writer) error {
return writeText(w, mustJSON(map[string]interface{}{
"op": "hello_ack",
"device": meta.DeviceID,
"online": true,
}))
}); err != nil {
return
}
case "bind":
@ -694,11 +742,17 @@ func (r *Registry) handleWS(conn net.Conn, rw *bufio.ReadWriter) {
// 默认不授权bind 仅验证 token + 登记设备;授权完全由用户手动
// GUI 设备页 / REST /api/v1/device/auth控制绝不自动授权。
}
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true})); err != nil {
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": true}))
})
if err != nil {
return
}
} else {
if err := writeText(rw.Writer, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"})); err != nil {
err := r.wsWriteLocked(curID, func(w *bufio.Writer) error {
return writeText(w, mustJSON(map[string]interface{}{"op": "bind_ack", "ok": false, "error": "bad token"}))
})
if err != nil {
return
}
}

View File

@ -139,6 +139,8 @@ func (l *llmImpl) ReloadFromConfig() error {
ContextWindow: src.ContextWindow,
MaxConcurrent: src.MaxConcurrent,
Priority: src.Priority,
Vision: src.Vision,
Audio: src.Audio,
}, l.lua, src.Name, src.Adapter)
l.mgr.Register(src.Name, provider)
if src.Adapter != "" {

View File

@ -107,6 +107,17 @@ type LLMSource struct {
MaxConcurrent int `json:"max_concurrent,omitempty"`
Priority int `json:"priority,omitempty"`
ThinkingEnabled bool `json:"thinking_enabled,omitempty"`
// Vision/Audio 声明该源能否真正处理多模态内容块。
//
// 为何必须显式声明而不是探测:网关(如 llmsproxy会把 image_url 块静默剥离后
// 转发给纯文本上游,请求依然 200,​​带图与不带图的 prompt_tokens 完全相同。
// 模型于是回答「我没有看到图片」,而内核以为注入成功——这正是 v1.0.0 之前
// output_send 假成功的同一类缺陷:告诉调用方成功而实际未送达。
// 探测需要额外一次真实调用且结果不稳定(取决于 AUTO 路由到哪个上游),
// 因此改为部署时声明。留空false按不支持处理走文字回退链。
Vision bool `json:"vision,omitempty"`
Audio bool `json:"audio,omitempty"`
}
type LLMConfig struct {