Files
HomeAgent/internal/plugins/remotedevice/outputch.go
JianFeeeee f0be8cbaa5 feat(remotedevice): 设备能力 outputch 化 —— 每设备一个 device/<id> 通道 + 设备指令类工具补授权闸
用户指出 remotedevice 的能力应当 outputch 化。查证后发现比"应当"更严重:
设备方向**根本没有出站实现**。

## 查到的三个缺口

1. `devicectlDevice` 一直声明 `OutputCapabilities() = CapStructured`(对外宣称可作输出目标),
   但 `Execute` 的 switch 里**没有 "output" 分支** ⇒ `output_send__devicectl` 必然拿到
   `unknown device tool output`,回模型"通过 [devicectl] 通道发送失败"。
2. 全插件没有 `RegisterOutputChannel`,也没有任何 EmitOutput/output_send 路径:
   设备方向只有「工具(请求-响应)」与「设备→agent 注入」,**agent → 设备是断的**
   (唯一的下行通道是个 HTTP 端点 `/api/v1/device/push`,不在 agent 的工具/通道模型里)。
3. 寻址是聚合的:所有设备共用一个名字 `devicectl`,没有 `device/<id>`;且设备 caps 只在
   插件内部软检查(`SupportsTool`),**绕过**了内核的 `AllowedOutputs` 授权闸 ——
   驻留子只要拿到 `device_ctl_cmdrun` 就能指挥**任意**设备。

## 按方向切分(不是一刀切)

**出站/消息类 → 每设备一个输出通道 `device/<id>`**(与入站同名):
- 上线注册、掉线注销(caps 由设备声明的 caps 映射:文本恒有;有屏→图/文件;
  speaker→音频;可跑命令(cmd/cmdrun/cmdresult)或未声明已知能力→全能力,与
  `deviceSupportsTool` 的旧设备兼容规则一致)。断连不注销会留下死通道骗模型。
- 于是自动获得:内核按 caps 在**发送前**拦(送图给纯文本音箱直接拒);
  `output_list_channels` 能列出设备;`AllowedOutputs` 可按设备收窄给驻留子。
- 上下线钩子用 `Registry.SetPresenceHandler`(**同步回调**)而不是既有的 `ChangeChan`
  (那是 select+default,缓冲满会丢事件;丢一次就留下死通道或漏注册)。
- `devicectl` 保留为聚合通道,并把它"声明了却不实现"的 output 补实:按
  `meta.device_id`(或 meta 就是设备 id / args.device_id)路由;缺省时返回**可执行**的
  报错(列出在线设备),而不是含糊失败。

**RPC 类保留为工具**(`devicedetect`/`screensee`/`computeruse`/`clipboard*`/
`device_ctl_status|cmdrun|cmdresult`):它们的返回值(图像/命令输出/状态)必须进模型
上下文,做成通道会丢掉这个语义。

**并给设备指令类工具补上同一道授权闸**(core/toolcall.go):`device_id` 指向的设备
必须是本 agent 被授权的 `device/<id>`。根 agent 默认完整授权 ⇒ 无行为变化;
驻留子收窄后,"拿到工具就能指挥任意设备"的缺口被堵上(新增 3 条 core 测试钉住)。

**设备端参考实现**(`internal/devicebridge/client/bridge.go` + waiter):新增 `op=push`
分发与 `OnPush` 回调(文本/结构化;二进制走既有 `cmd_speech_*` → `DataHandler`),
waiter 把它打到终端。

## 设计口径(用户当场纠偏,已写进代码注释与 harness README)

**主动转发只有 webui 与 cli 两个交互界面**(webui 订阅 EventAgentOutput 渲染气泡、
cli 用同步回程写回终端)。其它通道一律要求 agent **显式** `output_send__<通道>`。
我第一版给 remotedevice 加了 `EventAgentOutput` 订阅来自动回投设备——那是凭空造了
第三个转发者,违背"输出是 agent 的主动调用",已撤回(该测试一并删除)。

## 验收

- 单测:caps 映射词表;设备上线→注册通道(含同名 inputch)/掉线→注销;push 真落到
  WS 设备;聚合通道 `devicectl` 的寻址(无 device_id 报可执行错误、按 meta 投递、
  指定不存在设备报错);core 授权闸 3 例。
- 全量 `go test ./...` = 37 包 ok / 0 FAIL;`-race`(agent/plugins/plugin/devicebridge/sdk)干净。
- **真二进制端到端**(私有 netns + mock LLM + 真 WS 设备客户端 scripts/kernel-stress/devclient.py):
  设备上线 → 通道表出现 `device/pydev-1`(caps=[text file image audio structured],由
  `caps:["cmd"]` 映射)→ agent 经 `output_send__device/pydev-1` 主动发送 → 设备收到
  `{"op":"push","payload":"内核推给你的消息","type":"text"}` → 设备掉线 → 通道从表中消失。
2026-09-13 12:23:28 +08:00

256 lines
9.4 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 remotedevice
// 设备输出通道:把"agent 主动发给设备"做成**每设备一个输出通道** `device/<id>`。
//
// 为什么是输出通道而不是再加一批工具:
// - **寻址**:`output_send__device/<id>` 直接指名道姓;模型看 `output_list_channels`
// 就知道当前有哪些设备在线,不必先 `devicedetect` 再往参数里塞 device_id。
// - **能力**:caps 由设备声明的 caps 映射,**内核**在发送前就按 caps 拦
// (把图片发给只支持文本的音箱会被拒,而不是等设备侧报错)。
// - **授权**:`AllowedOutputs` 是内核级的授权闸(`executeOutputSendTool` 里先查
// `IsOutputAllowed`)。父 agent 因此可以"只授权某一台设备"给驻留子 ——
// 这在工具模型下做不到(拿到 `device_ctl_cmdrun` 就能对任意设备下指令)。
//
// 而 `screensee`/`computeruse`/`device_ctl_*` 这类**请求-响应**仍留作工具:
// 它们的返回值(图像/命令输出/状态)必须进模型上下文,做成通道会丢掉这个语义。
import (
"encoding/base64"
"encoding/json"
"fmt"
"log"
"strings"
"time"
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
"gitcode.com/JianFeeeee/HomeAgent/internal/sdk"
)
// deviceOutputCaps 把设备声明的 caps 映射成输出通道能力位。
//
// 映射依据(与 registry.go 的 capabilityTools/compatFullCaps 同一套词表):
// - 文本:任何设备都收(消息/指令都是文本)→ 总是 CapText
// - 结构化:能跑命令(cmd/cmdrun/cmdresult 视为历史全能力)→ 能渲染结构化结果
// - 音频:speaker/speakeruse,或 kind=speaker
// - 图片/文件:有屏(screen/screensue/screensee/gui)、剪切板、摄像头,或 kind 是带屏设备
// - **未声明任何已知能力** → 视为全能力(与 deviceSupportsTool 的旧设备兼容规则一致)
func deviceOutputCaps(caps []string, kind string) agentIO.OutputCapability {
full := agentIO.CapText | agentIO.CapFile | agentIO.CapImage | agentIO.CapAudio | agentIO.CapStructured
out := agentIO.CapText
known := false
for _, c := range caps {
switch c {
case "cmd", "cmdrun", "cmdresult":
// 历史"全能力"标记:这类设备能跑命令、能收结构化结果。
return full
case "screen", "screensue", "screensee", "gui", "display":
known = true
out |= agentIO.CapImage | agentIO.CapFile
case "clipboard", "clipboardsee", "clipboardsue":
known = true
out |= agentIO.CapFile
case "camera", "camerasue":
known = true
out |= agentIO.CapImage | agentIO.CapFile
case "speaker", "speakeruse", "audio":
known = true
out |= agentIO.CapAudio
case "computeruse":
known = true
out |= agentIO.CapStructured
}
}
// kind 兜底:带屏设备即便没声明 caps,也能收图和文件。
switch kind {
case "computer", "phone", "tablet", "tv":
known = true
out |= agentIO.CapImage | agentIO.CapFile | agentIO.CapStructured
case "speaker":
known = true
out |= agentIO.CapAudio
}
if !known {
return full // 旧设备兼容:未声明已知能力 ⇒ 全能力
}
return out
}
// deviceChannelName 是设备输出(也是输入)通道名:`device/<id>`。
//
// 入站与出站**同名**:两者指的是同一台设备,分成两个名字只会让模型与授权表更难对。
func deviceChannelName(id string) string { return "device/" + id }
// wireDeviceChannels 把"设备上下线"接到通道的登记/注销上。
//
// 一台设备 = 一对**同名**通道 `device/<id>`:入站(设备上报 → agent)与出站
// (agent → 设备)。用**同步回调**而不是 ChangeChan(后者是 select+default,
// 缓冲满会丢事件;丢一次就留下死通道或漏注册)。
//
// 抽成方法而不是内联在 Start 里:测试要能走**同一条**接线,
// 否则测试自己塞 handler,Start 忘了接线也照样绿。
func (p *Plugin) wireDeviceChannels() {
p.registry.SetPresenceHandler(
func(meta DeviceMeta) {
_ = p.sdk.RegisterInputChannel(deviceChannelName(meta.DeviceID), sdk.ChannelDef{})
p.ensureDeviceOutputChannel(meta.DeviceID)
},
func(id string) { p.dropDeviceOutputChannel(id) },
)
}
// ensureDeviceOutputChannel 给在线设备注册输出通道 device/<id>(幂等)。
func (p *Plugin) ensureDeviceOutputChannel(id string) {
if p.sdk == nil || id == "" {
return
}
meta, ok := p.registry.Get(id)
if !ok || !meta.Online {
return
}
ch := deviceChannelName(id)
caps := deviceOutputCaps(meta.Caps, meta.Kind)
desc := fmt.Sprintf("远程设备 %s(%s):agent 主动向该设备发送内容;能力位 %s",
id, fallback(meta.Name, meta.Kind), agentIO.OutputCapability(caps).String())
// 重复注册是安全的:芯片侧 Register 会合并(owner/capacity 取旧值)。
if err := p.sdk.RegisterOutputChannel(ch, int(caps), desc, sdk.ChannelDef{}, func(args map[string]interface{}) (interface{}, error) {
return pushToDevice(p.registry, id, args)
}); err != nil {
p.logf("register output channel %s: %v", ch, err)
return
}
p.logf("device %s online → 输出通道 %s(caps=%s)", id, ch, agentIO.OutputCapability(caps).String())
}
// dropDeviceOutputChannel 设备下线时注销它的输出通道。
//
// 不注销的后果:`output_list_channels` 一直列着它,模型会往死通道发消息,
// 拿到的却只是"发送已提交"之类的假回执。
func (p *Plugin) dropDeviceOutputChannel(id string) {
if p.sdk == nil || id == "" {
return
}
ch := deviceChannelName(id)
if err := p.sdk.UnregisterOutputChannel(ch); err != nil {
p.logf("unregister output channel %s: %v", ch, err)
return
}
p.logf("device %s offline → 注销输出通道 %s", id, ch)
}
// pushToDevice 把一次 output_send 的 {payload,type,meta} 转成下行帧发给设备。
//
// 线上格式(新增 op=push,与既有 op=cmd/cmd_speech_* 并列):
//
// {"op":"push","req_id":"...","type":"text|image|file|audio|structured","payload":"...","meta":"..."}
//
// 大负载(data URL 形式的图片/音频/文件)走既有分块通道 PushData,
// 避免把 base64 塞进一个超大文本帧。
func pushToDevice(reg *Registry, id string, args map[string]interface{}) (interface{}, error) {
payload, _ := args["payload"].(string)
typ, _ := args["type"].(string)
metaStr, _ := args["meta"].(string)
if payload == "" {
return nil, fmt.Errorf("payload 不能为空")
}
if typ == "" {
typ = "text"
}
reqID := fmt.Sprintf("push_%d", time.Now().UnixNano())
if data, mime, ok := decodeDataURL(payload); ok && typ != "text" && typ != "structured" {
if err := reg.PushData(id, reqID, typ, mime, data); err != nil {
return nil, err
}
return map[string]interface{}{"status": "sent", "req_id": reqID}, nil
}
frame := map[string]interface{}{
"op": "push",
"req_id": reqID,
"type": typ,
"payload": payload,
}
if metaStr != "" {
frame["meta"] = metaStr
}
if err := reg.PushJSON(id, frame); err != nil {
return nil, err
}
return map[string]interface{}{"status": "sent", "req_id": reqID}, nil
}
// decodeDataURL 解析 data:<mime>;base64,<data> 形式的内联负载。
func decodeDataURL(s string) (data []byte, mime string, ok bool) {
if !strings.HasPrefix(s, "data:") {
return nil, "", false
}
rest := strings.TrimPrefix(s, "data:")
comma := strings.Index(rest, ",")
if comma < 0 {
return nil, "", false
}
head, body := rest[:comma], rest[comma+1:]
if !strings.HasSuffix(head, ";base64") {
return nil, "", false
}
mime = strings.TrimSuffix(head, ";base64")
b, err := base64.StdEncoding.DecodeString(body)
if err != nil {
return nil, "", false
}
return b, mime, true
}
func fallback(s, def string) string {
if strings.TrimSpace(s) == "" {
return def
}
return s
}
func (p *Plugin) logf(format string, a ...interface{}) {
log.Printf("[remotedevice] "+format, a...)
}
// output 是**聚合通道** `devicectl` 的出站实现(每设备通道之外的另一条路)。
//
// 历史状态:devicectlDevice 一直声明 OutputCapabilities=CapStructured,
// 但 Execute 里根本没有 "output" 分支 ⇒ `output_send__devicectl` 必然报
// "unknown device tool output"。这里把它补实:按 meta/device_id 指到具体设备。
//
// 寻址方式(两者都收,模型的写法越少歧义越好):
// - args.meta 是 JSON 且含 device_id:{"device_id":"phone-1"}
// - args.meta 直接就是设备 id:phone-1
// - args.device_id
//
// 留空则返回**可执行**的提示(列出在线设备),而不是含糊报错 —— 模型据此重试。
func (d *devicectlDevice) output(args map[string]interface{}) (interface{}, error) {
deviceID, _ := args["device_id"].(string)
if deviceID == "" {
if metaStr, _ := args["meta"].(string); metaStr != "" {
var m map[string]interface{}
if json.Unmarshal([]byte(metaStr), &m) == nil {
deviceID, _ = m["device_id"].(string)
if deviceID == "" {
deviceID, _ = m["device"].(string)
}
}
if deviceID == "" {
deviceID = strings.TrimSpace(metaStr)
}
}
}
if deviceID == "" {
ids := []string{}
for _, m := range d.reg.OnlineList() {
ids = append(ids, m.DeviceID)
}
if len(ids) == 0 {
return nil, fmt.Errorf("devicectl 需要 meta.device_id 才能投递;当前没有在线设备(device_list_channels 可看每台设备的 device/<id> 通道)")
}
return nil, fmt.Errorf("devicectl 需要 meta.device_id(或直接用通道 device/<id>);当前在线设备: %s", strings.Join(ids, ", "))
}
return pushToDevice(d.reg, deviceID, args)
}