mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-21 17:38:10 +00:00
feat(core): 注入行为的记忆/裁剪标志位落地 + jieba 词库内嵌 + Windows 改走 WSL
配套 SDK 提交:homeagent-sdk ba49dfd(公开 API 纯追加,无签名变更)。
本仓第三方的库镜像同步至该版本,以保证全新 clone 能编译。
## 1. 注入标志位(内核侧)
- 7 条注入路径(排队/中断/同步 × 纯文本/带媒体 + 旧 NoMem 变体)解析并转发
no_memory / context_policy / cleaner_name;策略在入口**校验**,
非法值报错而不是静默降级成 none(降级会让调用方以为自己声明的裁剪在生效)。
- 新增 validateContextPolicy(与 tool.register 同一套规则)与 pubSdkInjectOpts。
- input.register 不再手写字段白名单重建 ChannelDef,改为整体传递 + 补 ContextPolicy。
- io 层:applyInjectOpts 把标志位写进事件 payload,仅非零时写
(零值与旧 payload 逐字节一致,事件订阅方与旧内核都不受影响)。
- ioAdapter / procCore / internal-sdk 别名补齐六个 *Opts 实现。
## 2. 修掉「输入无条件裁剪」这个真缺陷
eventloop 此前对**每条非中断输入**都调 `context.Prune(...)`:破坏性(低相关事件被
归档移出上下文)且无法从调用点看出是谁触发的。改为 pruneOnInput/pruneDeclared:
优先级:注入点声明(payload.context_policy)> 通道声明(ChannelDef.ContextPolicy)
> 默认**不裁剪**
查询向量仍取清洗后的内容;新增 cleanInputFor 解析清洗文本,优先级为
注入点声明的 cleaner(cleaner_name)> 按 source 查到的通道 cleaner > 原文,
名字查不到时**记日志再回退**(注入是 fire-and-forget,插件看不到错误,
至少要在内核日志留下「你声明的清洗没生效」的痕迹)。
## 3. jieba 词库内嵌(修「猜 GOMODCACHE → 静默失效」)
原 jiebaDictDir() 去猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块
缓存 → GetJieba() 返回 nil → 分词/关键词提取/NLP 依存解析(进而 doc→graph 三元组
抽取)/静态词向量 tokenizer **一律静默返回空列表**,只有一行日志。本机看起来正常
只因开发机与生产机重合、恰好有那份缓存。
现在词库随二进制分发:internal/memory/jiebadict/ 5 文件约 11.6MB + go:embed,
按**内容哈希**命名缓存目录落盘(词库升级不复用旧文件),已齐全则跳过写入。
模块缓存降为兜底。homed 体积 32MB。
顺带确认(并有测试佐证):gojieba 的 Tag() 不需要 pos_dict/ 目录——
cppjieba 的 PosTagger 从主词典每行的词性列取 tag。
## 4. homed 放弃 Windows 原生,改走 WSL2
插件体系依赖「继承的 fd」+「统一共享内存区的段内偏移解引用」,Windows 既无 fd
继承语义,其句柄模型也无法表达后者;强行适配等于再维护一套平台专属 ABI
(C ABI 时代三套 ABI 并存曾导致改写型插件在某平台静默失效)。
- cmd/homed/platform_{windows,other}.go:原生 Windows 启动即拒绝并打印 WSL2 指引。
- internal/plugin/proc/shmalloc_windows.go:allocShm 直接返回「请用 WSL2」,
**不返回半可用的段**(与 shmalloc_other.go 同风格:未支持平台显式报错);
procEnvForShm 返回 nil。顺手修掉两处长期编译错误
(cryptorand→rand、h.evData→h.unified.evtData),使 GOOS=windows 至少能编译。
注:homed 本就编不出 Windows——internal/memory 依赖 cgo-only 的 gojieba。
- deploy/packaging/installer.nsi:不再安装 homed.exe/initconfig.exe,改为携带
**linux payload** 并调用新的 install-via-wsl.ps1;退出码 20/21 表示
「需先装 WSL/发行版」,走指引而非报错。
- deploy/packaging/windows/install-via-wsl.ps1(新):检测 WSL → 引导安装 →
确保 WSL2 → 送包进发行版 → 在 WSL 内按 Linux 方式安装。**复用 Linux 包与
linux/setup.sh**,不另写一套安装逻辑;落点与 deb 布局统一
(/usr/bin/homed + /usr/lib/homeagent/setup.sh)。
- deploy/packaging/linux/setup.sh:API Key 允许 HOMEAGENT_API_KEY 覆盖
(否则安装器界面显示一份、config.db 里另一份 → 登录不上)。
- deploy/packaging/build.sh:windows 目标只构建 waiter + gui,并新增
stage_linux_payload 把 Linux 包暂存给安装器;homed/initconfig 在 windows
目标下明确拒绝。
## 5. 插件调用点统一写明意图
- webui 的 OpenAI 兼容端点(固定提示词模板)→ InjectTextSyncNoMemory。
- agentcli 的 5 处纯状态通知(已启动/超时/执行结束/进程退出/读取结束)→ NoMemory;
**带输出**的 2 处(定时反馈、有新输出)刻意保留记忆并注明理由。
- timer 的定时提醒 → NoMemory(中断本来也隐含 NoMemory,这里是写明意图)。
## 6. 版本
meta.Version 仍为 1.2.0(main 是下一个未发布中版本);
SDKCompatibleVersion 1.1.0 → **1.2.0**(本内核已实现 SDK 1.2.0 全部新增方法)。
## 测试
- core:默认不裁剪(无声明/none/空)、通道 opt-in、注入点双向覆盖通道、
nil context/io 安全、cleaner 优先级与未知名回退。
- io:零值 opts 与历史 payload 逐键相同;text/中断/媒体三类注入标志位都落到
payload;旧方法仍生效。
- proc:validateContextPolicy 只接受 ""/none/prune,报错含位置与实际值;
**跨进程** e2e——testdata 插件经 io.injectText 送出三个标志位,断言它们穿过 RPC
到达内核。
- memory:模块缓存不可见时内嵌词库仍可用(分词与 POS 内容词均非空)、
落盘幂等、内容哈希稳定。
验证:go build ./... / go vet ./... / go vet -tags onnxruntime ./...
go test -short ./internal/memory/... ./internal/nlp/... ./internal/plugin/...
./internal/agent/{core,io}/... ./pkg/...
This commit is contained in:
@ -52,6 +52,11 @@ import (
|
||||
)
|
||||
|
||||
func main() {
|
||||
// 平台门放在最前面:比 flag 解析还早,因为原生 Windows 上根本不应进入任何
|
||||
// 初始化路径(会去建共享段、拉插件进程)。理由与 WSL 指引见
|
||||
// platform_windows.go。
|
||||
requireSupportedPlatform()
|
||||
|
||||
dataDir := flag.String("data", "", "data directory (default: auto-detect next to binary)")
|
||||
httpAddr := flag.String("webui", "", "webui listen address (default: webui.listen_addr from config)")
|
||||
cliSocket := flag.String("socket", "", "cli unix socket path (default: <data>/cli.sock)")
|
||||
|
||||
9
cmd/homed/platform_other.go
Normal file
9
cmd/homed/platform_other.go
Normal file
@ -0,0 +1,9 @@
|
||||
//go:build !windows
|
||||
|
||||
package main
|
||||
|
||||
// requireSupportedPlatform 在受支持的平台上不做任何事。
|
||||
//
|
||||
// 平台策略见 platform_windows.go:只有 homed 放弃 Windows 原生支持
|
||||
// (插件体系依赖 fd 继承与共享内存段内偏移),Windows 用户走 WSL2。
|
||||
func requireSupportedPlatform() {}
|
||||
44
cmd/homed/platform_windows.go
Normal file
44
cmd/homed/platform_windows.go
Normal file
@ -0,0 +1,44 @@
|
||||
//go:build windows
|
||||
|
||||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// requireSupportedPlatform 在原生 Windows 上直接拒绝启动 homed。
|
||||
//
|
||||
// 为什么不做原生支持(不是「还没来得及做」,是设计上不做):
|
||||
//
|
||||
// homed 的插件体系建立在两个原语上——**继承的 fd**(Single memfd: 统一共享
|
||||
// 内存区 + eventfd 通知)与**同段内相对偏移解引用**(各进程 mmap 到不同虚拟
|
||||
// 基址,段内一律用偏移互相读写,这样插件回调才能就地改写内核看到的那份数据)。
|
||||
//
|
||||
// Windows 的等价物是命名内核对象(CreateFileMappingW / OpenEventW)+句柄表,
|
||||
// 没有 fd 继承语义(os/exec 的 ExtraFiles 在 Windows 上直接不被支持),
|
||||
// 生命周期与权限模型也按句柄而非进程继承来组织。要在其上重建这套语义,
|
||||
// 等于再维护一套平台专属 ABI 与安全边界——而 C ABI 时代正是「三套 ABI 并存
|
||||
// 导致改写型插件在某个平台上静默失效」的教训(§9.2)。
|
||||
//
|
||||
// 所以选择:**原生 Windows 不提供 homed**。Windows 用户跑 WSL2——
|
||||
// WSL2 里就是普通 linux/amd64,走与我们测试矩阵完全相同的那条路径。
|
||||
//
|
||||
// 注意范围:只有 homed 如此。plugindev 工具链仍可在 Windows 上运行
|
||||
// (在 Windows 上开发、为 WSL 构建 linux 插件是合理工作流)。
|
||||
func requireSupportedPlatform() {
|
||||
fmt.Fprintln(os.Stderr, "homed 不支持 Windows 原生运行。")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "原因:子进程插件依赖 fd 继承 + 统一共享内存区的段内偏移解引用,")
|
||||
fmt.Fprintln(os.Stderr, "而 Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属")
|
||||
fmt.Fprintln(os.Stderr, "ABI——C ABI 时代三套 ABI 并存曾导致改写型插件在某个平台上静默失效。")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "请改用 WSL2:")
|
||||
fmt.Fprintln(os.Stderr, " 1. wsl --install -d Ubuntu # 安装 WSL2")
|
||||
fmt.Fprintln(os.Stderr, " 2. 在 WSL 内下载 linux/amd64 的 homed 与插件(.hmap)")
|
||||
fmt.Fprintln(os.Stderr, " 3. 在 WSL 内运行 homed:与 Linux 主机完全相同,无需额外配置")
|
||||
fmt.Fprintln(os.Stderr, "")
|
||||
fmt.Fprintln(os.Stderr, "数据目录可放在 /mnt/c/... 下以便与 Windows 侧共享,")
|
||||
fmt.Fprintln(os.Stderr, "但不建议(跨文件系统 IO 慢、inotify 语义受限);推荐放在 WSL 内部路径。")
|
||||
os.Exit(2)
|
||||
}
|
||||
@ -55,7 +55,7 @@ case "$TARGET" in
|
||||
;;
|
||||
*)
|
||||
echo "Unknown target: $TARGET"
|
||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all]"
|
||||
echo "Usage: $0 [native|linux/amd64|linux/arm64|darwin/amd64|darwin/arm64|windows/amd64|all] [all|homed|waiter|initconfig|gui|payload]"
|
||||
echo " [all|homed|waiter|initconfig|gui]"
|
||||
exit 1
|
||||
esac
|
||||
@ -152,6 +152,39 @@ build_initconfig() {
|
||||
echo " OK ($(du -h "$out" | cut -f1))"
|
||||
}
|
||||
|
||||
# ---- linux-payload(给 Windows 安装器用的 Linux 包)----
|
||||
#
|
||||
# Windows 不再安装 homed.exe:homed 依赖 fd 继承 + 统一共享内存区的段内偏移
|
||||
# 解引用,Windows 句柄模型无法表达(见 cmd/homed/platform_windows.go)。
|
||||
# Windows 安装器改为引导到 WSL2,并把 **Linux 包**送进发行版里安装。
|
||||
# 因此 Windows 安装包必须带上 Linux 产物——这一段就是把它暂存到
|
||||
# build/linux-payload/(installer.nsi 从这里 File /r 打进安装包)。
|
||||
#
|
||||
# 复用 package-linux.sh 的产物,而不是在这里另行编译:WSL 里跑的就是普通
|
||||
# linux/amd64,安装内容必须与 Linux 原生安装**完全一致**,否则又变成两个平台。
|
||||
stage_linux_payload() {
|
||||
local src="$PROJECT_ROOT/dist/linux"
|
||||
local out="$BUILD_DIR/linux-payload"
|
||||
|
||||
rm -rf "$out"
|
||||
mkdir -p "$out"
|
||||
|
||||
local found=0
|
||||
for f in "$src"/*.deb "$src"/*.tar.gz; do
|
||||
[ -f "$f" ] || continue
|
||||
cp "$f" "$out/"
|
||||
found=$((found + 1))
|
||||
done
|
||||
|
||||
if [ "$found" -eq 0 ]; then
|
||||
echo "[FAIL] build/linux-payload 为空:先运行 package-linux.sh 产出 dist/linux/*.deb|*.tar.gz" >&2
|
||||
echo " (Windows 安装器会把这里的包送进 WSL 安装;空包等于装不上)" >&2
|
||||
return 1
|
||||
fi
|
||||
echo "[BUILD] linux-payload ← $found 个包"
|
||||
ls -1 "$out" | sed 's/^/ /'
|
||||
}
|
||||
|
||||
# ---- gui (Electron) ----
|
||||
build_gui() {
|
||||
if [ -n "${GOOS:-}" ] && [ "$GOOS" != "$("$GO" env GOOS)" ]; then
|
||||
@ -179,13 +212,34 @@ build_gui() {
|
||||
}
|
||||
|
||||
# ---- dispatch ----
|
||||
case "$COMPONENT" in
|
||||
all) build_homed; build_waiter; build_initconfig; build_gui ;;
|
||||
homed) build_homed ;;
|
||||
waiter) build_waiter ;;
|
||||
initconfig) build_initconfig ;;
|
||||
gui) build_gui ;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
esac
|
||||
if [ "${GOOS:-}" = "windows" ]; then
|
||||
# Windows 目标:构建的**不是** homed——它已放弃 Windows 原生支持。
|
||||
# 需要的是:Linux 包(送进 WSL 安装)+ Windows 侧客户端(waiter CLI / GUI)。
|
||||
case "$COMPONENT" in
|
||||
all) build_waiter; stage_linux_payload; build_gui ;;
|
||||
waiter) build_waiter ;;
|
||||
payload) stage_linux_payload ;;
|
||||
gui) build_gui ;;
|
||||
homed|initconfig)
|
||||
echo "homed/initconfig 不再提供 Windows 原生构建:请用 WSL2(或用 linux/amd64 目标)。" >&2
|
||||
echo "原因见 cmd/homed/platform_windows.go。" >&2
|
||||
exit 1
|
||||
;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
else
|
||||
case "$COMPONENT" in
|
||||
all) build_homed; build_waiter; build_initconfig; build_gui ;;
|
||||
homed) build_homed ;;
|
||||
waiter) build_waiter ;;
|
||||
initconfig) build_initconfig ;;
|
||||
gui) build_gui ;;
|
||||
*)
|
||||
echo "Unknown component: $COMPONENT"
|
||||
exit 1
|
||||
;;
|
||||
esac
|
||||
fi
|
||||
|
||||
@ -216,17 +216,26 @@ FunctionEnd
|
||||
|
||||
Section "Install" SEC_INSTALL
|
||||
SetOutPath "$INSTDIR"
|
||||
; WSL 引导脚本随安装包分发(它负责检测/引导 WSL 并把 Linux 包装进发行版)
|
||||
File "..\..\deploy\packaging\windows\install-via-wsl.ps1"
|
||||
CreateDirectory "$INSTDIR\data"
|
||||
CreateDirectory "$INSTDIR\data\log"
|
||||
CreateDirectory "$INSTDIR\data\plugins"
|
||||
CreateDirectory "$INSTDIR\data\adapters"
|
||||
|
||||
; homed **不再装到 Windows**:插件体系依赖 fd 继承与统一共享内存区的段内偏移
|
||||
; 解引用,Windows 的句柄模型无法表达(见 cmd/homed/platform_windows.go)。
|
||||
; Windows 侧改为引导到 WSL2,把 **Linux 包**送进发行版里按 Linux 的方式安装。
|
||||
; 所以这里带的是 linux/amd64 的 payload,不是 homed.exe。
|
||||
!if "${HAS_CORE}" == "1"
|
||||
File "..\..\build\initconfig.exe"
|
||||
File "..\..\build\homed.exe"
|
||||
SetOutPath "$PLUGINSDIR\linux-payload"
|
||||
File /r "..\..\build\linux-payload\*.*"
|
||||
SetOutPath "$INSTDIR"
|
||||
!endif
|
||||
|
||||
!if "${HAS_WAITER}" == "1"
|
||||
; waiter 是 CLI 客户端:WSL 侧会装上 Linux 版;Windows 侧仍可保留原生版
|
||||
; (它只是个客户端,不走插件体系)。
|
||||
File "..\..\build\waiter.exe"
|
||||
!endif
|
||||
|
||||
@ -237,11 +246,24 @@ Section "Install" SEC_INSTALL
|
||||
!endif
|
||||
|
||||
!if "${HAS_CORE}" == "1"
|
||||
DetailPrint "初始化配置数据库..."
|
||||
nsExec::Exec '"$INSTDIR\initconfig.exe" -data "$INSTDIR\data" -username "$webuiUsername" -password "$webuiPassword" -apikey "$apiKey"'
|
||||
; 在 WSL2 里安装 homed。凭据(页面上收的那三个)透传进去,避免
|
||||
; 「界面显示一份、config.db 里另一份」导致登录不上。
|
||||
DetailPrint "检测 WSL 并在其中安装 HomeAgent..."
|
||||
nsExec::ExecToStack 'powershell -NoProfile -ExecutionPolicy Bypass -File "$INSTDIR\install-via-wsl.ps1" -PayloadDir "$PLUGINSDIR\linux-payload" -ApiKey "$apiKey" -WebUIUser "$webuiUsername" -WebUIPass "$webuiPassword"'
|
||||
Pop $0
|
||||
Pop $1
|
||||
${If} $0 != 0
|
||||
DetailPrint "警告: 数据库初始化可能未成功完成"
|
||||
; 退出码含义见 install-via-wsl.ps1:20/21 是「WSL 或发行版缺失,需要先装」,
|
||||
; 属于可指引的用户动作,不当成安装失败来恐吓人。
|
||||
${If} $0 == 20
|
||||
MessageBox MB_ICONINFORMATION|MB_OK "未检测到 WSL。$\r$\n$\r$\n请在管理员 PowerShell 中执行:$\r$\n wsl --install$\r$\n$\r$\n然后重启 Windows,再重新运行本安装程序。"
|
||||
${ElseIf} $0 == 21
|
||||
MessageBox MB_ICONINFORMATION|MB_OK "WSL 已安装,但还没有发行版。$\r$\n$\r$\n请先执行:$\r$\n wsl --install -d Ubuntu$\r$\n$\r$\n完成首次初始化后再重新运行本安装程序。"
|
||||
${Else}
|
||||
MessageBox MB_ICONEXCLAMATION|MB_OK "WSL 内安装失败(退出码 $0)。$\r$\n$\r$\n可进入 WSL 手动排查:wsl -d Ubuntu$\r$\n安装脚本输出见上方日志。"
|
||||
${EndIf}
|
||||
${Else}
|
||||
DetailPrint "HomeAgent 已在 WSL2 内安装完成"
|
||||
${EndIf}
|
||||
!endif
|
||||
|
||||
|
||||
@ -16,8 +16,12 @@ fi
|
||||
|
||||
mkdir -p "$DATA_DIR"
|
||||
|
||||
# 生成随机凭据
|
||||
API_KEY=$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")
|
||||
# 生成随机凭据。
|
||||
#
|
||||
# 允许环境变量覆盖:安装器(包括 Windows 上的 WSL 引导安装)已经在界面上
|
||||
# 向用户收过这些值,若不接受传入就只能两个地方各生成一份,用户看到的那份
|
||||
# 与实际写入 config.db 的那份不一致——那种错会直接表现为「登录不上」。
|
||||
API_KEY="${HOMEAGENT_API_KEY:-$(cat /proc/sys/kernel/random/uuid 2>/dev/null | tr -d '-' || echo "homeagent$(date +%s)")}"
|
||||
WEBUI_USER="${WEBUI_USER:-admin}"
|
||||
WEBUI_PASS="${WEBUI_PASS:-$(openssl rand -hex 12 2>/dev/null || echo "homeagent")}"
|
||||
|
||||
|
||||
259
deploy/packaging/windows/install-via-wsl.ps1
Normal file
259
deploy/packaging/windows/install-via-wsl.ps1
Normal file
@ -0,0 +1,259 @@
|
||||
<#
|
||||
.SYNOPSIS
|
||||
在 WSL2 中安装 HomeAgent(homed + 插件 + WebUI)。
|
||||
|
||||
.DESCRIPTION
|
||||
Windows 不再提供 homed 的原生安装。原因见 cmd/homed/platform_windows.go:
|
||||
homed 的插件体系依赖「继承的 fd」与「统一共享内存区的段内偏移解引用」,
|
||||
Windows 的句柄模型无法表达这两者;强行适配等于再维护一套平台专属 ABI,
|
||||
而 C ABI 时代三套 ABI 并存正是「改写型插件在某个平台上静默失效」的根因。
|
||||
|
||||
本脚本因此把 Windows 安装流程变成一条引导链:
|
||||
检测 WSL → 必要时引导安装 → 配置(默认版本 2 / systemd)
|
||||
→ 把 **Linux 包** 送进发行版 → 在 WSL 内按 Linux 的方式安装。
|
||||
|
||||
它复用 Linux 侧的安装包与初始化脚本,不另写一套安装逻辑——
|
||||
「WSL 里就是普通 linux/amd64」这一点必须保持成立,否则等于又开了第三个平台。
|
||||
|
||||
.PARAMETER PayloadDir
|
||||
内含 Linux 安装包的目录(安装器把它解到临时目录后传进来)。
|
||||
优先取 *.deb;没有 deb 时回退 *.tar.gz。
|
||||
|
||||
.PARAMETER Distro
|
||||
目标发行版名。省略则用默认发行版;没有发行版时引导安装 Ubuntu。
|
||||
|
||||
.PARAMETER DataDir
|
||||
WSL 内的数据目录。默认 /var/lib/homeagent(与 Linux 原生安装一致)。
|
||||
不建议放 /mnt/c/...:跨文件系统 IO 慢,且 inotify 语义受限。
|
||||
|
||||
.NOTES
|
||||
⚠️ 本脚本在开发环境(Linux)中只能做语法/逻辑审查,**未在真实 Windows + WSL
|
||||
上执行过**。首次使用请逐段核对输出;下面每个阶段都打印了实际执行的命令,
|
||||
便于定位到具体哪一步与预期不符。
|
||||
#>
|
||||
[CmdletBinding()]
|
||||
param(
|
||||
[Parameter(Mandatory = $true)][string]$PayloadDir,
|
||||
[string]$Distro = "",
|
||||
[string]$DataDir = "/var/lib/homeagent",
|
||||
[string]$ApiKey = "",
|
||||
[string]$WebUIUser = "",
|
||||
[string]$WebUIPass = "",
|
||||
[switch]$Uninstall
|
||||
)
|
||||
|
||||
$ErrorActionPreference = "Stop"
|
||||
$script:StageNo = 0
|
||||
$script:DistroName = $Distro
|
||||
|
||||
function Write-Stage([string]$Text) {
|
||||
$script:StageNo++
|
||||
Write-Host ""
|
||||
Write-Host ("=" * 64) -ForegroundColor DarkGray
|
||||
Write-Host ("[$script:StageNo] $Text") -ForegroundColor Cyan
|
||||
Write-Host ("=" * 64) -ForegroundColor DarkGray
|
||||
}
|
||||
|
||||
function Write-Ok([string]$Text) { Write-Host " ✓ $Text" -ForegroundColor Green }
|
||||
function Write-Warn2([string]$Text) { Write-Host " ! $Text" -ForegroundColor Yellow }
|
||||
function Fail([string]$Text, [string]$Hint = "") {
|
||||
Write-Host ""
|
||||
Write-Host " 安装中止:$Text" -ForegroundColor Red
|
||||
if ($Hint) { Write-Host " $Hint" -ForegroundColor Yellow }
|
||||
exit 1
|
||||
}
|
||||
|
||||
# ── 0. 前置检查 ────────────────────────────────────────────────────────────
|
||||
Write-Stage "前置检查"
|
||||
|
||||
$identity = [Security.Principal.WindowsPrincipal][Security.Principal.WindowsIdentity]::GetCurrent()
|
||||
if (-not $identity.IsInRole([Security.Principal.WindowsBuiltInRole]::Administrator)) {
|
||||
# 装 WSL 与写 \\wsl$ 都需要管理员。不静默提权:用户应当看到发生了什么。
|
||||
Fail "需要管理员权限" "请以管理员身份重新运行安装程序。"
|
||||
}
|
||||
Write-Ok "管理员权限"
|
||||
|
||||
if (-not (Get-Command wsl.exe -ErrorAction SilentlyContinue)) {
|
||||
Write-Warn2 "未找到 wsl.exe"
|
||||
Write-Host " homed 不再提供 Windows 原生版本,必须通过 WSL2 运行。"
|
||||
Write-Host ""
|
||||
Write-Host " 在管理员 PowerShell 中执行:" -ForegroundColor Yellow
|
||||
Write-Host " wsl --install" -ForegroundColor White
|
||||
Write-Host " 然后重启 Windows,再重新运行本安装程序。"
|
||||
Write-Host ""
|
||||
Write-Host " (Windows 10 需 2004+ 且启用虚拟机平台;Windows 11 开箱可用)"
|
||||
exit 20
|
||||
}
|
||||
Write-Ok "wsl.exe 可用"
|
||||
|
||||
# ── 1. 检测 WSL 状态与发行版 ───────────────────────────────────────────────
|
||||
Write-Stage "检测 WSL 与发行版"
|
||||
|
||||
# wsl -l -v 在「没有发行版」时返回非零,且输出是 UTF-16LE——直接解析会踩编码坑。
|
||||
# 用 --status 取默认发行版,再单独枚举列表。
|
||||
$distros = @()
|
||||
try {
|
||||
$raw = (& wsl.exe -l -q 2>$null | Out-String)
|
||||
$distros = $raw -split "`r?`n" | ForEach-Object { $_.Trim() } | Where-Object { $_ -ne "" }
|
||||
} catch {
|
||||
$distros = @()
|
||||
}
|
||||
|
||||
if ($distros.Count -eq 0) {
|
||||
Write-Warn2 "WSL 已安装,但没有任何发行版"
|
||||
Write-Host ""
|
||||
Write-Host " 请先安装发行版(推荐 Ubuntu):" -ForegroundColor Yellow
|
||||
Write-Host " wsl --install -d Ubuntu" -ForegroundColor White
|
||||
Write-Host ""
|
||||
Write-Host " 首次启动 Ubuntu 会要求创建 Linux 用户名与密码,完成后重新运行本安装程序。"
|
||||
exit 21
|
||||
}
|
||||
|
||||
if ($script:DistroName -eq "") {
|
||||
try {
|
||||
$script:DistroName = (& wsl.exe --status 2>$null | Select-String -Pattern "Default Distribution" |
|
||||
ForEach-Object { ($_ -split ":")[1].Trim() })
|
||||
} catch { }
|
||||
if (-not $script:DistroName) { $script:DistroName = $distros[0] }
|
||||
}
|
||||
Write-Ok "发行版:$($script:DistroName)(共 $($distros.Count) 个:$($distros -join ', '))"
|
||||
|
||||
# ── 2. 确保是 WSL2 ─────────────────────────────────────────────────────────
|
||||
Write-Stage "确保使用 WSL2"
|
||||
|
||||
# WSL1 没有真正的 Linux 内核、没有 systemd,且在共享内存/事件语义上与 WSL2 不同。
|
||||
# homed 依赖 eventfd + mmap 语义,WSL1 会以难以诊断的方式失败,因此显式要求 WSL2。
|
||||
try {
|
||||
$verLine = (& wsl.exe -l -v 2>$null | Out-String) -split "`r?`n" |
|
||||
Where-Object { $_ -match [regex]::Escape($script:DistroName) } | Select-Object -First 1
|
||||
if ($verLine -match "\b1\b") {
|
||||
Write-Warn2 "该发行版当前是 WSL1,正在升级为 WSL2 ..."
|
||||
& wsl.exe --set-version $script:DistroName 2
|
||||
if ($LASTEXITCODE -ne 0) { Fail "WSL2 升级失败" "可手动执行:wsl --set-version $($script:DistroName) 2" }
|
||||
}
|
||||
} catch { }
|
||||
& wsl.exe --set-default-version 2 | Out-Null
|
||||
Write-Ok "已使用 WSL2"
|
||||
|
||||
# ── 3. 准备 Linux 包 ───────────────────────────────────────────────────────
|
||||
Write-Stage "准备 Linux 安装包"
|
||||
|
||||
$deb = Get-ChildItem -Path $PayloadDir -Filter "*.deb" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
$tar = Get-ChildItem -Path $PayloadDir -Filter "*.tar.gz" -ErrorAction SilentlyContinue | Select-Object -First 1
|
||||
if ($deb) {
|
||||
$pkg = $deb.FullName
|
||||
$pkgKind = "deb"
|
||||
} elseif ($tar) {
|
||||
$pkg = $tar.FullName
|
||||
$pkgKind = "tar"
|
||||
} else {
|
||||
Fail "在 $PayloadDir 下既没找到 .deb 也没找到 .tar.gz" "安装器应把 Linux 包解到该目录。"
|
||||
}
|
||||
Write-Ok "使用 $(Split-Path $pkg -Leaf)($pkgKind)"
|
||||
|
||||
# ── 4. 把包送进 WSL ────────────────────────────────────────────────────────
|
||||
Write-Stage "把安装包送入 WSL"
|
||||
|
||||
# 走 /mnt/c 而不是 \\wsl$:前者是 WSL 稳定的对外通道,且不需要额外的 UNC 权限;
|
||||
# 后者在某些 Windows 版本上对 Program Files 路径有重定向限制。
|
||||
$winPath = (Resolve-Path $pkg).Path
|
||||
$mntPath = "/mnt/" + $winPath.Substring(0, 1).ToLower() + ($winPath.Substring(2) -replace '\\', '/')
|
||||
Write-Host " 源:$mntPath"
|
||||
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc "mkdir -p /tmp/homeagent-install"
|
||||
if ($LASTEXITCODE -ne 0) { Fail "无法在 WSL 内创建临时目录" "确认发行版可正常启动:wsl -d $($script:DistroName)" }
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc "cp '$mntPath' /tmp/homeagent-install/"
|
||||
if ($LASTEXITCODE -ne 0) { Fail "复制安装包失败" }
|
||||
Write-Ok "已送到 /tmp/homeagent-install/"
|
||||
|
||||
# ── 5. 在 WSL 内安装 ───────────────────────────────────────────────────────
|
||||
Write-Stage "在 WSL 内安装 homed"
|
||||
|
||||
# 凭据经环境变量传给 setup.sh(它已支持 HOMEAGENT_API_KEY / WEBUI_USER / WEBUI_PASS)。
|
||||
# 不传的话就会「界面显示一份、config.db 里另一份」,用户直接登录不上。
|
||||
$credEnv = ""
|
||||
if ($ApiKey) { $credEnv += "export HOMEAGENT_API_KEY='$ApiKey'; " }
|
||||
if ($WebUIUser) { $credEnv += "export WEBUI_USER='$WebUIUser'; " }
|
||||
if ($WebUIPass) { $credEnv += "export WEBUI_PASS='$WebUIPass'; " }
|
||||
|
||||
# 安装逻辑复用 Linux 侧:deb 走 apt(postinst 会调用 setup.sh 生成凭据与 config.db),
|
||||
# tar 则解包到你同一套布局再执行同一份 setup.sh。刻意不在这里重写安装步骤——
|
||||
# 「WSL 里就是普通 linux/amd64」必须保持成立,否则等于又开了第三个平台。
|
||||
if ($pkgKind -eq "deb") {
|
||||
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
|
||||
set -e
|
||||
$credEnv
|
||||
export HOMEAGENT_DATA='$DataDir'
|
||||
apt-get update -qq
|
||||
DEBIAN_FRONTEND=noninteractive apt-get install -y -qq '$inWslPkg'
|
||||
"@
|
||||
} else {
|
||||
$inWslPkg = "/tmp/homeagent-install/" + (Split-Path $pkg -Leaf)
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
|
||||
set -e
|
||||
$credEnv
|
||||
mkdir -p /opt/homeagent /tmp/homeagent-extract
|
||||
tar -xzf '$inWslPkg' -C /tmp/homeagent-extract
|
||||
cd /tmp/homeagent-extract
|
||||
# 与 deb 完全相同的布局:/usr/bin/homed + /usr/lib/homeagent/setup.sh。
|
||||
# 两套安装若落到不同路径,之后的升级/排障就会出现「按文档找不到文件」。
|
||||
install -m 0755 homed /usr/bin/homed
|
||||
install -m 0755 waiter /usr/bin/waiter
|
||||
[ -f initconfig ] && install -m 0755 initconfig /usr/bin/initconfig
|
||||
if [ -f homeagent.service ]; then
|
||||
install -m 0644 homeagent.service /etc/systemd/system/homeagent.service
|
||||
fi
|
||||
mkdir -p /usr/lib/homeagent
|
||||
if [ -f setup.sh ]; then install -m 0755 setup.sh /usr/lib/homeagent/setup.sh; fi
|
||||
export HOMEAGENT_DATA='$DataDir'
|
||||
if [ -x /usr/lib/homeagent/setup.sh ]; then bash /usr/lib/homeagent/setup.sh; fi
|
||||
"@
|
||||
}
|
||||
if ($LASTEXITCODE -ne 0) {
|
||||
Fail "WSL 内安装失败(退出码 $LASTEXITCODE)" "可进入 WSL 手动排查:wsl -d $($script:DistroName)"
|
||||
}
|
||||
Write-Ok "安装完成"
|
||||
|
||||
# ── 6. 启动与自启 ──────────────────────────────────────────────────────────
|
||||
Write-Stage "启动 homed 与自启配置"
|
||||
|
||||
& wsl.exe -d $script:DistroName -u root -- bash -lc @"
|
||||
if command -v systemctl >/dev/null 2>&1 && systemctl list-unit-files 2>/dev/null | grep -q homeagent; then
|
||||
systemctl enable homeagent 2>/dev/null || true
|
||||
systemctl restart homeagent
|
||||
echo ' ✓ systemd 服务 homeagent 已启动并设为自启'
|
||||
else
|
||||
# 没有 systemd(WSL2 默认可能没开):用 nohup 起,并把自启交给 Windows 侧的计划任务。
|
||||
pkill -f '/usr/bin/homed' 2>/dev/null || true
|
||||
nohup /usr/bin/homed -data '$DataDir' > /var/log/homeagent-boot.log 2>&1 &
|
||||
echo ' ✓ 已用 nohup 启动(未检测到 systemd)'
|
||||
fi
|
||||
"@
|
||||
|
||||
$creds = & wsl.exe -d $script:DistroName -u root -- bash -lc "cat '$DataDir/credentials.txt' 2>/dev/null || true"
|
||||
|
||||
Write-Host ""
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host " HomeAgent 已在 WSL2($($script:DistroName))内安装完成" -ForegroundColor Green
|
||||
Write-Host "============================================================" -ForegroundColor Green
|
||||
Write-Host ""
|
||||
Write-Host " WebUI:http://localhost:8080" -ForegroundColor White
|
||||
Write-Host " (WSL2 会把 WSL 内的端口映射到 Windows 的 localhost,无需额外配置)"
|
||||
Write-Host ""
|
||||
if ($creds) {
|
||||
Write-Host " 初始凭据(也保存在 WSL 内 $DataDir/credentials.txt):" -ForegroundColor Yellow
|
||||
Write-Host $creds
|
||||
} else {
|
||||
Write-Host " 未读到凭据文件,请进入 WSL 检查:cat $DataDir/credentials.txt" -ForegroundColor Yellow
|
||||
}
|
||||
Write-Host ""
|
||||
Write-Host " 常用操作(在 PowerShell 中):"
|
||||
Write-Host " 进入 WSL : wsl -d $($script:DistroName)"
|
||||
Write-Host " 查看日志 : wsl -d $($script:DistroName) -u root -- journalctl -u homeagent -f"
|
||||
Write-Host " 重启服务 : wsl -d $($script:DistroName) -u root -- systemctl restart homeagent"
|
||||
Write-Host ""
|
||||
Write-Host " 注意:WSL 实例不会随 Windows 启动而自动拉起。若需要开机自启,"
|
||||
Write-Host " 可创建一个登录时触发的计划任务执行:"
|
||||
Write-Host " wsl -d $($script:DistroName) -u root -- systemctl start homeagent"
|
||||
exit 0
|
||||
@ -400,7 +400,7 @@ func (a *Agent) processInput(evt *agentIO.InputEvent) {
|
||||
}
|
||||
a.publishEvent(events.EventRawInput, rawPayload)
|
||||
|
||||
archived := a.context.Prune(cleanInput, a.maxContextSize-1, a.docStore)
|
||||
archived := a.pruneOnInput(evt, cleanInput)
|
||||
if archived > 0 {
|
||||
log.Printf("[agent] pruned %d low-relevance events to document memory", archived)
|
||||
}
|
||||
@ -522,3 +522,66 @@ func (a *Agent) drainInterrupts() []string {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// pruneOnInput 按声明的上下文策略裁剪上下文,返回归档的事件数。
|
||||
//
|
||||
// 默认**不裁剪**:ContextPolicy 必须在注入点(payload 的 context_policy)
|
||||
// 或通道定义(ChannelDef.ContextPolicy)上显式声明为 prune 才会裁剪。
|
||||
//
|
||||
// 为什么把无条件裁剪改成需声明:裁剪会把低相关事件归档到文档记忆并从上下文里
|
||||
// 移走,是破坏性的。此前每条输入都裁一次,于是「谁把上下文裁了」在排查时无从
|
||||
// 得知;而插件注入的内容也会被不相关的内容挤掉。按来源/注入点声明后,触发条件
|
||||
// 是可枚举、可审计的。
|
||||
//
|
||||
// 查询向量取**清洗后**的输入(通道 Cleaner 的输出),与工具侧同一套语义:
|
||||
// 原始输入里的 ANSI/base64/JSON 包装会把相关性打分带偏,裁掉本该保留的事件。
|
||||
func (a *Agent) pruneOnInput(evt *agentIO.InputEvent, cleanInput string) int {
|
||||
if a.context == nil || !a.pruneDeclared(evt) {
|
||||
return 0
|
||||
}
|
||||
topK := a.maxContextSize - 1
|
||||
if topK < 1 {
|
||||
topK = 1
|
||||
}
|
||||
return a.context.Prune(cleanInput, topK, a.docStore)
|
||||
}
|
||||
|
||||
// pruneDeclared 判定这次输入是否显式声明了裁剪。
|
||||
//
|
||||
// 优先级:注入点声明的(payload)> 通道声明的(ChannelDef)> 默认不裁剪。
|
||||
// 注入点是更窄的声明面,同一通道下的不同注入可以有不同意图。
|
||||
func (a *Agent) pruneDeclared(evt *agentIO.InputEvent) bool {
|
||||
if p, ok := evt.Payload["context_policy"].(string); ok && p != "" {
|
||||
return p == pubsdk.ContextPolicyPrune
|
||||
}
|
||||
if a.io != nil {
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok {
|
||||
return chDef.ContextPolicy == pubsdk.ContextPolicyPrune
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// cleanInputFor 解析这条输入在计算层应当使用的清洗文本。
|
||||
//
|
||||
// 优先级:注入点声明的 cleaner(payload.cleaner_name,引用某个已注册的通道
|
||||
// cleaner)> 按 source 查到的通道 cleaner > 原文。
|
||||
//
|
||||
// 声明的 cleaner 名字查不到时**记日志并回退**,而不是静默当没声明:
|
||||
// 注入是 fire-and-forget 的,插件那边看不到错误;至少要在内核日志里留下
|
||||
// 「你声明的清洗没生效」的痕迹,否则排查时只能看到「记忆里的内容很脏」。
|
||||
func (a *Agent) cleanInputFor(evt *agentIO.InputEvent, input string) string {
|
||||
if a.io == nil {
|
||||
return input
|
||||
}
|
||||
if name, ok := evt.Payload["cleaner_name"].(string); ok && name != "" {
|
||||
if chDef, ok := a.io.GetInputChannelDef(name); ok && chDef.Cleaner != nil {
|
||||
return chDef.Cleaner(input)
|
||||
}
|
||||
log.Printf("[agent] 注入声明了 cleaner_name=%q 但没有注册过该通道的 Cleaner,已回退", name)
|
||||
}
|
||||
if chDef, ok := a.io.GetInputChannelDef(evt.Source); ok && chDef.Cleaner != nil {
|
||||
return chDef.Cleaner(input)
|
||||
}
|
||||
return input
|
||||
}
|
||||
|
||||
119
internal/agent/core/prunepolicy_test.go
Normal file
119
internal/agent/core/prunepolicy_test.go
Normal file
@ -0,0 +1,119 @@
|
||||
package core
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
agentIO "gitcode.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// 这一组测试锁死「默认不裁剪」这条语义。
|
||||
//
|
||||
// 改动前:每条非中断输入都无条件 Prune 一次,没有任何声明能关掉它。
|
||||
// 这是破坏性行为(低相关事件被归档并从上下文移走),却无法从调用点看出
|
||||
// 「谁触发的裁剪」。改成需声明后,必须逐条验证默认值确实是不裁剪。
|
||||
func TestPruneDeclared_DefaultsToNoPrune(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
a := &Agent{io: m}
|
||||
|
||||
evt := &agentIO.InputEvent{Source: "unknown_source", Payload: map[string]interface{}{}}
|
||||
if a.pruneDeclared(evt) {
|
||||
t.Fatal("既没有通道声明也没有注入声明的输入,默认必须不裁剪")
|
||||
}
|
||||
|
||||
// 通道注册了、但策略是 none / 空:仍然不裁剪。
|
||||
m.RegisterInputChannel("quiet", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyNone})
|
||||
if a.pruneDeclared(&agentIO.InputEvent{Source: "quiet", Payload: map[string]interface{}{}}) {
|
||||
t.Fatal("ChannelDef.ContextPolicy=none 不应裁剪")
|
||||
}
|
||||
m.RegisterInputChannel("empty", pubsdk.ChannelDef{})
|
||||
if a.pruneDeclared(&agentIO.InputEvent{Source: "empty", Payload: map[string]interface{}{}}) {
|
||||
t.Fatal("ChannelDef 未设 ContextPolicy 不应裁剪")
|
||||
}
|
||||
}
|
||||
|
||||
// 通道显式声明 prune 才裁剪。
|
||||
func TestPruneDeclared_ChannelOptIn(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
m.RegisterInputChannel("noisy", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
|
||||
a := &Agent{io: m}
|
||||
|
||||
if !a.pruneDeclared(&agentIO.InputEvent{Source: "noisy", Payload: map[string]interface{}{}}) {
|
||||
t.Fatal("通道声明 prune 后应裁剪")
|
||||
}
|
||||
}
|
||||
|
||||
// 注入点声明的优先级高于通道定义:同一通道下的不同注入可以有不同意图。
|
||||
func TestPruneDeclared_InjectionOverridesChannel(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
a := &Agent{io: m}
|
||||
m.RegisterInputChannel("chan", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
|
||||
|
||||
// 注入点说 none → 即使通道说 prune 也不裁。
|
||||
evt := &agentIO.InputEvent{Source: "chan", Payload: map[string]interface{}{
|
||||
"context_policy": pubsdk.ContextPolicyNone,
|
||||
}}
|
||||
if a.pruneDeclared(evt) {
|
||||
t.Fatal("注入点声明 none 应覆盖通道的 prune")
|
||||
}
|
||||
|
||||
// 通道没说,注入点说 prune → 裁。
|
||||
m.RegisterInputChannel("plain", pubsdk.ChannelDef{})
|
||||
evt = &agentIO.InputEvent{Source: "plain", Payload: map[string]interface{}{
|
||||
"context_policy": pubsdk.ContextPolicyPrune,
|
||||
}}
|
||||
if !a.pruneDeclared(evt) {
|
||||
t.Fatal("注入点声明 prune 应生效")
|
||||
}
|
||||
}
|
||||
|
||||
// 没有 context 时不能 panic,也不该裁剪。
|
||||
func TestPruneOnInput_NilContextIsSafe(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
m.RegisterInputChannel("noisy", pubsdk.ChannelDef{ContextPolicy: pubsdk.ContextPolicyPrune})
|
||||
a := &Agent{io: m}
|
||||
if got := a.pruneOnInput(&agentIO.InputEvent{Source: "noisy", Payload: map[string]interface{}{}}, "x"); got != 0 {
|
||||
t.Fatalf("nil context 应返回 0,实际 %d", got)
|
||||
}
|
||||
}
|
||||
|
||||
// cleanInputFor 的优先级:注入点声明的 cleaner > 按 source 查的 cleaner > 原文。
|
||||
func TestCleanInputFor_Priority(t *testing.T) {
|
||||
m := agentIO.NewIOManager()
|
||||
m.RegisterInputChannel("src", pubsdk.ChannelDef{
|
||||
Cleaner: func(s string) string { return "by-source:" + s },
|
||||
})
|
||||
m.RegisterInputChannel("explicit", pubsdk.ChannelDef{
|
||||
Cleaner: func(s string) string { return "by-name:" + s },
|
||||
})
|
||||
a := &Agent{io: m}
|
||||
|
||||
// 无声明 → 用 source 的 cleaner
|
||||
evt := &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{}}
|
||||
if got := a.cleanInputFor(evt, "raw"); got != "by-source:raw" {
|
||||
t.Fatalf("应回退到 source 的 cleaner,实际 %q", got)
|
||||
}
|
||||
|
||||
// 注入点指定 cleaner_name → 覆盖 source 的
|
||||
evt = &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{"cleaner_name": "explicit"}}
|
||||
if got := a.cleanInputFor(evt, "raw"); got != "by-name:raw" {
|
||||
t.Fatalf("注入点声明的 cleaner 应优先,实际 %q", got)
|
||||
}
|
||||
|
||||
// 完全没有 cleaner → 原文
|
||||
evt = &agentIO.InputEvent{Source: "nobody", Payload: map[string]interface{}{}}
|
||||
if got := a.cleanInputFor(evt, "raw"); got != "raw" {
|
||||
t.Fatalf("没有 cleaner 时应返回原文,实际 %q", got)
|
||||
}
|
||||
|
||||
// 声明的名字查不到 → 回退到 source 的 cleaner(并记日志),不能 panic、不能丢内容
|
||||
evt = &agentIO.InputEvent{Source: "src", Payload: map[string]interface{}{"cleaner_name": "missing"}}
|
||||
if got := a.cleanInputFor(evt, "raw"); got != "by-source:raw" {
|
||||
t.Fatalf("未知 cleaner_name 应回退,实际 %q", got)
|
||||
}
|
||||
|
||||
// nil IOManager 不能 panic
|
||||
if got := (&Agent{}).cleanInputFor(evt, "raw"); got != "raw" {
|
||||
t.Fatalf("nil io 应返回原文,实际 %q", got)
|
||||
}
|
||||
}
|
||||
@ -25,11 +25,11 @@ const (
|
||||
type OutputCapability int
|
||||
|
||||
const (
|
||||
CapText OutputCapability = 1 << iota // 文本
|
||||
CapFile // 文件
|
||||
CapImage // 图片
|
||||
CapAudio // 音频
|
||||
CapStructured // 结构化数据(JSON/卡片)
|
||||
CapText OutputCapability = 1 << iota // 文本
|
||||
CapFile // 文件
|
||||
CapImage // 图片
|
||||
CapAudio // 音频
|
||||
CapStructured // 结构化数据(JSON/卡片)
|
||||
)
|
||||
|
||||
func (c OutputCapability) Supports(cap OutputCapability) bool {
|
||||
@ -242,6 +242,52 @@ func (m *IOManager) InjectInputSyncTo(source, outputChannel, eventType string, p
|
||||
return <-ch
|
||||
}
|
||||
|
||||
// InjectOptions 声明一次注入在记忆层与上下文层的表现。
|
||||
//
|
||||
// 零值 = 记入记忆 + 不裁剪上下文,与历史的三参数注入方法完全一致。
|
||||
// 别名到公共 SDK 而非另建一套:内置插件与外部插件必须用同一套结构,
|
||||
// 否则内核要认两种类型,而漏认会静默丢失标志位。
|
||||
type InjectOptions = pubsdk.InjectOptions
|
||||
|
||||
// applyInjectOpts 把注入标志位写进事件 payload。
|
||||
//
|
||||
// 只在非零时写:零值与旧 payload 逐字节一致,事件订阅方与旧内核
|
||||
// (不认识这两个键)都不会受影响。
|
||||
//
|
||||
// 为什么不把标志位当独立参数传到底:eventloop 与各注入路径都按 payload 取字段
|
||||
// (no_memory 本来就是这么走的),payload 是这里唯一已有的携带面。
|
||||
func applyInjectOpts(payload map[string]interface{}, opts InjectOptions) {
|
||||
if opts.NoMemory {
|
||||
payload["no_memory"] = true
|
||||
}
|
||||
if opts.ContextPolicy != "" {
|
||||
payload["context_policy"] = opts.ContextPolicy
|
||||
}
|
||||
if opts.CleanerName != "" {
|
||||
payload["cleaner_name"] = opts.CleanerName
|
||||
}
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInputOpts(source, eventType string, payload map[string]interface{}, opts InjectOptions) {
|
||||
applyInjectOpts(payload, opts)
|
||||
m.InjectInput(source, eventType, payload)
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInputToOpts(source, outputChannel, eventType string, payload map[string]interface{}, opts InjectOptions) {
|
||||
applyInjectOpts(payload, opts)
|
||||
m.InjectInputTo(source, outputChannel, eventType, payload)
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInputSyncToOpts(source, outputChannel, eventType string, payload map[string]interface{}, opts InjectOptions) *OutputEvent {
|
||||
applyInjectOpts(payload, opts)
|
||||
return m.InjectInputSyncTo(source, outputChannel, eventType, payload)
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInterruptOpts(source, channel string, payload map[string]interface{}, opts InjectOptions) {
|
||||
applyInjectOpts(payload, opts)
|
||||
m.InjectInterrupt(source, channel, payload)
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectText(source string, text string) {
|
||||
m.InjectInput(source, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
@ -293,10 +339,31 @@ func (m *IOManager) InjectInterrupt(source, channel string, payload map[string]i
|
||||
}
|
||||
|
||||
func (m *IOManager) InjectInterruptText(source, channel, text string) {
|
||||
m.InjectInterrupt(source, channel, map[string]interface{}{
|
||||
m.InjectInterruptTextOpts(source, channel, text, InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectInterruptTextOpts 注入中断文本,并声明本次注入的记忆/裁剪行为。
|
||||
//
|
||||
// 中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文。
|
||||
func (m *IOManager) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
|
||||
m.InjectInterruptOpts(source, channel, map[string]interface{}{
|
||||
"type": "text",
|
||||
"content": text,
|
||||
})
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// InjectTextOpts 注入排队文本,并声明本次注入的记忆/裁剪行为。
|
||||
func (m *IOManager) InjectTextOpts(source, channel, text string, opts InjectOptions) {
|
||||
m.InjectInputToOpts(source, channel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// InjectTextSyncOpts 同步注入文本并声明记忆/裁剪行为。
|
||||
func (m *IOManager) InjectTextSyncOpts(source, outputChannel, text string, opts InjectOptions) *OutputEvent {
|
||||
return m.InjectInputSyncToOpts(source, outputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (m *IOManager) InputInterruptChan() <-chan *InputEvent { return m.interruptCh }
|
||||
@ -308,6 +375,33 @@ func (m *IOManager) InjectTextSyncTo(source, outputChannel, text string) *Output
|
||||
})
|
||||
}
|
||||
|
||||
// ---- 带标志位的注入(记忆/裁剪行为由调用点声明)----
|
||||
|
||||
// InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
|
||||
func (m *IOManager) InjectInputMediaOpts(source, outputChannel, text string, blocks []pubsdk.ContentBlock, opts InjectOptions) {
|
||||
m.InjectInputToOpts(source, outputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
|
||||
func (m *IOManager) InjectInputMediaSyncOpts(source, outputChannel, text string, blocks []pubsdk.ContentBlock, opts InjectOptions) *OutputEvent {
|
||||
return m.InjectInputSyncToOpts(source, outputChannel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
}, opts)
|
||||
}
|
||||
|
||||
// InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
|
||||
func (m *IOManager) InjectInterruptMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts InjectOptions) {
|
||||
m.InjectInterruptOpts(source, channel, map[string]interface{}{
|
||||
"type": "text",
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
}, opts)
|
||||
}
|
||||
|
||||
func (m *IOManager) EmitOutput(target string, outputType string, payload map[string]interface{}) {
|
||||
m.outputCh <- &OutputEvent{
|
||||
RequestID: "",
|
||||
@ -343,7 +437,7 @@ func (m *IOManager) EmitTextTo(target, outputChannel, text string) {
|
||||
})
|
||||
}
|
||||
|
||||
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
|
||||
func (m *IOManager) InputChan() <-chan *InputEvent { return m.inputCh }
|
||||
func (m *IOManager) OutputChan() <-chan *OutputEvent { return m.outputCh }
|
||||
|
||||
// RegisterInputChannel 注册输入通道的记忆行为
|
||||
@ -463,12 +557,14 @@ func NewMicrophone(name string, sampleRate int, io *IOManager) *Microphone {
|
||||
return &Microphone{name: name, sampleRate: sampleRate, io: io}
|
||||
}
|
||||
|
||||
func (d *Microphone) Name() string { return d.name }
|
||||
func (d *Microphone) Type() DeviceType { return DeviceInput }
|
||||
func (d *Microphone) Name() string { return d.name }
|
||||
func (d *Microphone) Type() DeviceType { return DeviceInput }
|
||||
func (d *Microphone) OutputCapabilities() OutputCapability { return 0 } // 纯输入
|
||||
func (d *Microphone) Description() string { return fmt.Sprintf("麦克风 (%s, %dHz)", d.name, d.sampleRate) }
|
||||
func (d *Microphone) Start() error { return nil }
|
||||
func (d *Microphone) Stop() error { return nil }
|
||||
func (d *Microphone) Description() string {
|
||||
return fmt.Sprintf("麦克风 (%s, %dHz)", d.name, d.sampleRate)
|
||||
}
|
||||
func (d *Microphone) Start() error { return nil }
|
||||
func (d *Microphone) Stop() error { return nil }
|
||||
func (d *Microphone) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
|
||||
func (d *Microphone) Tools() []ToolDef {
|
||||
@ -498,13 +594,13 @@ func NewSpeaker(name string, io *IOManager) *Speaker {
|
||||
return &Speaker{name: name, io: io}
|
||||
}
|
||||
|
||||
func (d *Speaker) Name() string { return d.name }
|
||||
func (d *Speaker) Type() DeviceType { return DeviceOutput }
|
||||
func (d *Speaker) Name() string { return d.name }
|
||||
func (d *Speaker) Type() DeviceType { return DeviceOutput }
|
||||
func (d *Speaker) OutputCapabilities() OutputCapability { return CapText | CapAudio }
|
||||
func (d *Speaker) Description() string { return fmt.Sprintf("扬声器 (%s)", d.name) }
|
||||
func (d *Speaker) Start() error { return nil }
|
||||
func (d *Speaker) Stop() error { return nil }
|
||||
func (d *Speaker) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
func (d *Speaker) Description() string { return fmt.Sprintf("扬声器 (%s)", d.name) }
|
||||
func (d *Speaker) Start() error { return nil }
|
||||
func (d *Speaker) Stop() error { return nil }
|
||||
func (d *Speaker) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
|
||||
func (d *Speaker) Tools() []ToolDef {
|
||||
return []ToolDef{{
|
||||
@ -535,13 +631,13 @@ func NewCamera(name string, io *IOManager) *Camera {
|
||||
return &Camera{name: name, io: io}
|
||||
}
|
||||
|
||||
func (d *Camera) Name() string { return d.name }
|
||||
func (d *Camera) Type() DeviceType { return DeviceInput }
|
||||
func (d *Camera) Name() string { return d.name }
|
||||
func (d *Camera) Type() DeviceType { return DeviceInput }
|
||||
func (d *Camera) OutputCapabilities() OutputCapability { return CapImage } // 可返回图片
|
||||
func (d *Camera) Description() string { return fmt.Sprintf("摄像头 (%s)", d.name) }
|
||||
func (d *Camera) Start() error { return nil }
|
||||
func (d *Camera) Stop() error { return nil }
|
||||
func (d *Camera) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
func (d *Camera) Description() string { return fmt.Sprintf("摄像头 (%s)", d.name) }
|
||||
func (d *Camera) Start() error { return nil }
|
||||
func (d *Camera) Stop() error { return nil }
|
||||
func (d *Camera) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
|
||||
func (d *Camera) Tools() []ToolDef {
|
||||
return []ToolDef{
|
||||
@ -583,13 +679,13 @@ func NewRobotArm(name string, io *IOManager) *RobotArm {
|
||||
return &RobotArm{name: name, io: io}
|
||||
}
|
||||
|
||||
func (d *RobotArm) Name() string { return d.name }
|
||||
func (d *RobotArm) Type() DeviceType { return DeviceIO }
|
||||
func (d *RobotArm) Name() string { return d.name }
|
||||
func (d *RobotArm) Type() DeviceType { return DeviceIO }
|
||||
func (d *RobotArm) OutputCapabilities() OutputCapability { return CapStructured }
|
||||
func (d *RobotArm) Description() string { return fmt.Sprintf("机械臂 (%s)", d.name) }
|
||||
func (d *RobotArm) Start() error { return nil }
|
||||
func (d *RobotArm) Stop() error { return nil }
|
||||
func (d *RobotArm) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
func (d *RobotArm) Description() string { return fmt.Sprintf("机械臂 (%s)", d.name) }
|
||||
func (d *RobotArm) Start() error { return nil }
|
||||
func (d *RobotArm) Stop() error { return nil }
|
||||
func (d *RobotArm) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
|
||||
func (d *RobotArm) Tools() []ToolDef {
|
||||
return []ToolDef{
|
||||
@ -635,13 +731,13 @@ func NewGPIODevice(name string, pins []int, io *IOManager) *GPIODevice {
|
||||
return &GPIODevice{name: name, pins: pins, io: io}
|
||||
}
|
||||
|
||||
func (d *GPIODevice) Name() string { return d.name }
|
||||
func (d *GPIODevice) Type() DeviceType { return DeviceIO }
|
||||
func (d *GPIODevice) Name() string { return d.name }
|
||||
func (d *GPIODevice) Type() DeviceType { return DeviceIO }
|
||||
func (d *GPIODevice) OutputCapabilities() OutputCapability { return CapStructured }
|
||||
func (d *GPIODevice) Description() string { return "GPIO 通用引脚" }
|
||||
func (d *GPIODevice) Start() error { return nil }
|
||||
func (d *GPIODevice) Stop() error { return nil }
|
||||
func (d *GPIODevice) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
func (d *GPIODevice) Description() string { return "GPIO 通用引脚" }
|
||||
func (d *GPIODevice) Start() error { return nil }
|
||||
func (d *GPIODevice) Stop() error { return nil }
|
||||
func (d *GPIODevice) ChannelDef() ChannelDef { return ChannelDef{} }
|
||||
|
||||
func (d *GPIODevice) Tools() []ToolDef {
|
||||
return []ToolDef{
|
||||
|
||||
111
internal/agent/io/injectopts_test.go
Normal file
111
internal/agent/io/injectopts_test.go
Normal file
@ -0,0 +1,111 @@
|
||||
package io
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
pubsdk "gitcode.com/JianFeeeee/homeagent-sdk/sdk"
|
||||
)
|
||||
|
||||
// drainOne 取出一条注入事件;没有则 Fatal。
|
||||
func drainOne(t *testing.T, ch <-chan *InputEvent) *InputEvent {
|
||||
t.Helper()
|
||||
select {
|
||||
case evt := <-ch:
|
||||
return evt
|
||||
default:
|
||||
t.Fatal("没有拿到注入事件")
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
// 零值 InjectOptions 必须与历史的三参数注入产出**完全一致**的 payload。
|
||||
//
|
||||
// 这是兼容性底线:任何按 payload 取字段的下游(事件订阅方、旧内核、
|
||||
// 工具链测试)都不能因为这次改造而看到新键。
|
||||
func TestInjectTextOpts_ZeroValueMatchesLegacyPayload(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.InjectText("src", "hello")
|
||||
legacy := drainOne(t, m.InputChan())
|
||||
|
||||
m2 := NewIOManager()
|
||||
m2.InjectTextOpts("src", "chan", "hello", InjectOptions{})
|
||||
withOpts := drainOne(t, m2.InputChan())
|
||||
|
||||
if len(withOpts.Payload) != len(legacy.Payload) {
|
||||
t.Fatalf("零值注入多出了键:legacy=%v opts=%v", legacy.Payload, withOpts.Payload)
|
||||
}
|
||||
for k, v := range legacy.Payload {
|
||||
if withOpts.Payload[k] != v {
|
||||
t.Fatalf("键 %q 不一致:legacy=%v opts=%v", k, v, withOpts.Payload[k])
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 标志位必须出现在事件 payload 上——eventloop 就是从那里读的。
|
||||
func TestInjectTextOpts_CarriesFlags(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.InjectTextOpts("src", "chan", "hello", InjectOptions{
|
||||
NoMemory: true,
|
||||
ContextPolicy: "prune",
|
||||
CleanerName: "clean_me",
|
||||
})
|
||||
evt := drainOne(t, m.InputChan())
|
||||
|
||||
if evt.Payload["no_memory"] != true {
|
||||
t.Errorf("no_memory 未传递: %v", evt.Payload["no_memory"])
|
||||
}
|
||||
if evt.Payload["context_policy"] != "prune" {
|
||||
t.Errorf("context_policy 未传递: %v", evt.Payload["context_policy"])
|
||||
}
|
||||
if evt.Payload["cleaner_name"] != "clean_me" {
|
||||
t.Errorf("cleaner_name 未传递: %v", evt.Payload["cleaner_name"])
|
||||
}
|
||||
if evt.Payload["content"] != "hello" {
|
||||
t.Errorf("content 丢失: %v", evt.Payload["content"])
|
||||
}
|
||||
if evt.OutputChannel != "chan" {
|
||||
t.Errorf("输出通道 = %q,期望 chan", evt.OutputChannel)
|
||||
}
|
||||
}
|
||||
|
||||
// 中断注入走另一条队列,标志位同样要带上(用户已确认中断允许声明 prune)。
|
||||
func TestInjectInterruptTextOpts_CarriesFlags(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.InjectInterruptTextOpts("src", "chan", "alert", InjectOptions{ContextPolicy: "prune"})
|
||||
evt := drainOne(t, m.InputInterruptChan())
|
||||
|
||||
if evt.Payload["context_policy"] != "prune" {
|
||||
t.Errorf("中断注入的 context_policy 未传递: %v", evt.Payload)
|
||||
}
|
||||
if evt.Payload["type"] != "text" || evt.Payload["content"] != "alert" {
|
||||
t.Errorf("中断注入的基本字段不对: %v", evt.Payload)
|
||||
}
|
||||
if _, has := evt.Payload["no_memory"]; has {
|
||||
t.Errorf("未声明的 no_memory 不应出现: %v", evt.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 带媒体的注入同样要带标志位。
|
||||
func TestInjectInputMediaOpts_CarriesFlags(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
blocks := []pubsdk.ContentBlock{{Type: "image_url", ImageURL: &pubsdk.ImageURL{URL: "data:image/png;base64,AA"}}}
|
||||
m.InjectInputMediaOpts("src", "chan", "看图", blocks, InjectOptions{NoMemory: true})
|
||||
evt := drainOne(t, m.InputChan())
|
||||
|
||||
if evt.Payload["no_memory"] != true {
|
||||
t.Errorf("媒体的 no_memory 未传递: %v", evt.Payload)
|
||||
}
|
||||
if _, ok := evt.Payload["media_blocks"]; !ok {
|
||||
t.Errorf("媒体块丢失: %v", evt.Payload)
|
||||
}
|
||||
}
|
||||
|
||||
// 旧方法必须继续等价工作(它们是 Opts 变体的零值糖)。
|
||||
func TestLegacyNoMemoryMethodStillSetsFlag(t *testing.T) {
|
||||
m := NewIOManager()
|
||||
m.InjectTextNoMemoryTo("src", "chan", "quiet")
|
||||
evt := drainOne(t, m.InputChan())
|
||||
if evt.Payload["no_memory"] != true {
|
||||
t.Fatalf("旧 NoMemory 方法应置位: %v", evt.Payload)
|
||||
}
|
||||
}
|
||||
@ -53,7 +53,7 @@ func GetJieba() *gojieba.Jieba {
|
||||
}()
|
||||
d := jiebaDictDir()
|
||||
if d == "" {
|
||||
log.Printf("[jieba] no dictionary directory found, jieba disabled")
|
||||
log.Printf("[jieba] 未找到词库目录(内嵌落盘失败且模块缓存也不存在),jieba disabled")
|
||||
return
|
||||
}
|
||||
jiebaInst = gojieba.NewJieba(
|
||||
@ -68,9 +68,24 @@ func GetJieba() *gojieba.Jieba {
|
||||
}
|
||||
|
||||
func jiebaDictDir() string {
|
||||
// 首选内嵌词库:它是产物的一部分,与二进制同版本、不依赖宿主环境。
|
||||
//
|
||||
// 以前这里只猜 GOMODCACHE/GOPATH/~/go/pkg/mod,部署机上通常没有 Go 模块缓存,
|
||||
// 于是分词与关键词提取会**静默退回空列表**(详见 jieba_embed.go 的说明)。
|
||||
if dir, err := materializeJiebaDict(); err == nil && dir != "" {
|
||||
return dir
|
||||
}
|
||||
log.Printf("[jieba] 内嵌词库落盘失败,回退到模块缓存查找(内嵌失败通常意味着缓存目录不可写)")
|
||||
|
||||
// 回退:开发机上存在的模块缓存(仅作为兵底,不应依赖它)。
|
||||
//
|
||||
// GOMODCACHE is typically $GOPATH/pkg/mod. When set, Go writes modules
|
||||
// under <GOMODCACHE>/github.com/... . Look first at GOMODCACHE, then
|
||||
// derive from GOPATH, then try common locations.
|
||||
return jiebaDictDirFromModuleCache()
|
||||
}
|
||||
|
||||
func jiebaDictDirFromModuleCache() string {
|
||||
candidates := []string{
|
||||
os.Getenv("GOMODCACHE"),
|
||||
}
|
||||
|
||||
123
internal/memory/jieba_embed.go
Normal file
123
internal/memory/jieba_embed.go
Normal file
@ -0,0 +1,123 @@
|
||||
// Package memory 的 jieba 词库内嵌。
|
||||
//
|
||||
// 为什么要把词库嵌进二进制,而不是像以前那样去猜 Go 模块缓存路径:
|
||||
//
|
||||
// 原实现是 `jiebaDictDir()` 依次试 GOMODCACHE / GOPATH / ~/go/pkg/mod,去找
|
||||
// `github.com/yanyiwu/gojieba@v1.4.7/deps/cppjieba/dict`。部署机上通常**没有**
|
||||
// Go 模块缓存,于是返回 "",`GetJieba()` 返回 nil,四个分词/关键词函数
|
||||
// **一律静默返回空列表**(只在首次打一行「jieba disabled」)。
|
||||
//
|
||||
// 后果不是「少了个优化」而是**能力整体消失**:图记忆的关键词提取、文档
|
||||
// TF-IDF 分词、NLP 依存解析(进而 doc→graph 三元组抽取)全部退化为空。
|
||||
// 而本机之所以看起来正常,只是因为开发机与生产机重合、恰好有那份模块缓存。
|
||||
//
|
||||
// 内嵌后词库成为产物的一部分:与二进制同版本、随二进制分发、不依赖宿主环境。
|
||||
// 代价是包体大 ~11.6MB(jieba.dict.utf8 5.1M + idf.utf8 6.0M + hmm_model 0.5M + …),
|
||||
// 这是可接受的——它换来的是「装到哪都能用」。
|
||||
//
|
||||
// 关于 POS:gojieba 的 Tag() 不读 `pos_dict/` 目录,而是从主词典每行的
|
||||
// 词性列取 tag(cppjieba 的 PosTagger::LookupTag 走 dict->Find(...)->tag),
|
||||
// 取不到时用 SpecialRule 按字符类型兜底。所以这 5 个文件已足够同时支撑
|
||||
// Cut 与 Tag,无需再嵌 pos_dict/。
|
||||
package memory
|
||||
|
||||
import (
|
||||
"crypto/sha256"
|
||||
"embed"
|
||||
"encoding/hex"
|
||||
"fmt"
|
||||
"io/fs"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
)
|
||||
|
||||
//go:embed jiebadict/*
|
||||
var jiebaDictFS embed.FS
|
||||
|
||||
// jiebaDictFiles 是 gojieba.NewJieba 需要的 5 个文件,顺序与它的参数一致:
|
||||
// dict, hmm, user, idf, stop_words。
|
||||
var jiebaDictFiles = []string{
|
||||
"jieba.dict.utf8",
|
||||
"hmm_model.utf8",
|
||||
"user.dict.utf8",
|
||||
"idf.utf8",
|
||||
"stop_words.utf8",
|
||||
}
|
||||
|
||||
// materializeJiebaDict 把内嵌词库落盘,返回目录路径。
|
||||
//
|
||||
// gojieba 的 C++ API 只接受**文件路径**(NewJieba 会对每个路径 os.Stat,
|
||||
// 缺失就 panic),所以必须先落盘再传路径。
|
||||
//
|
||||
// 落盘位置与幂等性:
|
||||
// - 用内容哈希命名目录:词库升级后不会复用旧文件(否则会出现「新旧词库混用」
|
||||
// 这种最难查的一类问题——分词结果与版本对不上)。
|
||||
// - 已存在且大小一致就跳过写入:正常启动只做几次 stat。
|
||||
func materializeJiebaDict() (string, error) {
|
||||
sum, err := jiebaDictDigest()
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
base, err := os.UserCacheDir()
|
||||
if err != nil || base == "" {
|
||||
base = os.TempDir()
|
||||
}
|
||||
dir := filepath.Join(base, "homeagent", "jieba-"+sum)
|
||||
|
||||
if jiebaDictComplete(dir) {
|
||||
return dir, nil
|
||||
}
|
||||
if err := os.MkdirAll(dir, 0o755); err != nil {
|
||||
return "", fmt.Errorf("创建词库目录: %w", err)
|
||||
}
|
||||
for _, name := range jiebaDictFiles {
|
||||
data, err := jiebaDictFS.ReadFile("jiebadict/" + name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取内嵌词库 %s: %w", name, err)
|
||||
}
|
||||
path := filepath.Join(dir, name)
|
||||
// 先写临时文件再 rename:避免并发启动时读到写了一半的词库。
|
||||
tmp := path + ".tmp"
|
||||
if err := os.WriteFile(tmp, data, 0o644); err != nil {
|
||||
return "", fmt.Errorf("写出词库 %s: %w", name, err)
|
||||
}
|
||||
if err := os.Rename(tmp, path); err != nil {
|
||||
return "", fmt.Errorf("落位词库 %s: %w", name, err)
|
||||
}
|
||||
}
|
||||
return dir, nil
|
||||
}
|
||||
|
||||
// jiebaDictDigest 对全部内嵌词库内容求哈希,作为落盘目录名的一部分。
|
||||
func jiebaDictDigest() (string, error) {
|
||||
h := sha256.New()
|
||||
// 按固定顺序喂入:embed.FS 的遍历顺序不保证稳定,顺序变了哈希就变,
|
||||
// 会导致每次启动都重建一份词库。
|
||||
names := append([]string(nil), jiebaDictFiles...)
|
||||
sort.Strings(names)
|
||||
for _, name := range names {
|
||||
data, err := jiebaDictFS.ReadFile("jiebadict/" + name)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("读取内嵌词库 %s: %w", name, err)
|
||||
}
|
||||
fmt.Fprintf(h, "%s:%d:", name, len(data))
|
||||
h.Write(data)
|
||||
}
|
||||
return hex.EncodeToString(h.Sum(nil))[:16], nil
|
||||
}
|
||||
|
||||
// jiebaDictComplete 判断目录下 5 个词库是否齐全且大小与内嵌版本一致。
|
||||
func jiebaDictComplete(dir string) bool {
|
||||
for _, name := range jiebaDictFiles {
|
||||
want, err := fs.Stat(jiebaDictFS, "jiebadict/"+name)
|
||||
if err != nil {
|
||||
return false
|
||||
}
|
||||
got, err := os.Stat(filepath.Join(dir, name))
|
||||
if err != nil || got.Size() != want.Size() {
|
||||
return false
|
||||
}
|
||||
}
|
||||
return true
|
||||
}
|
||||
98
internal/memory/jieba_embed_test.go
Normal file
98
internal/memory/jieba_embed_test.go
Normal file
@ -0,0 +1,98 @@
|
||||
package memory
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sync"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 内嵌词库必须自足:把 GOMODCACHE/GOPATH/HOME 全部指向不存在的路径,
|
||||
// 分词与关键词提取仍然要工作。
|
||||
//
|
||||
// 这正是修复前的故障场景:原实现只去猜 Go 模块缓存,部署机上没有那份缓存时
|
||||
// GetJieba() 返回 nil,四个函数静默返回空列表——关键词提取、文档 TF-IDF 分词、
|
||||
// NLP 依存解析(进而 doc→graph 三元组抽取)一起失效,且只有一行日志。
|
||||
func TestEmbeddedJiebaDictIsSelfContained(t *testing.T) {
|
||||
t.Setenv("GOMODCACHE", filepath.Join(t.TempDir(), "nonexistent"))
|
||||
t.Setenv("GOPATH", filepath.Join(t.TempDir(), "nonexistent"))
|
||||
t.Setenv("HOME", filepath.Join(t.TempDir(), "nonexistent"))
|
||||
// 清掉可能已被其它测试初始化过的单例。
|
||||
jiebaOnce = sync.Once{}
|
||||
jiebaInst = nil
|
||||
t.Cleanup(func() {
|
||||
jiebaOnce = sync.Once{}
|
||||
jiebaInst = nil
|
||||
})
|
||||
|
||||
if x := GetJieba(); x == nil {
|
||||
t.Fatal("模块缓存不可见时 jieba 必须仍能初始化(词库应来自内嵌副本)")
|
||||
}
|
||||
|
||||
words := TokenizeWords("今天天气很好,我们去公园散步")
|
||||
if len(words) == 0 {
|
||||
t.Fatal("分词结果为空:内嵌词库没有真正生效")
|
||||
}
|
||||
t.Logf("分词结果: %v", words)
|
||||
|
||||
// 内容词(名词/动词/形容词)——依赖词典里的词性列,顺便验证 Tag 路径可用。
|
||||
content := TokenizeContentWords("北京是中国的首都,这里有很多历史建筑")
|
||||
if len(content) == 0 {
|
||||
t.Fatal("内容词为空:Tag(词性标注)路径失效")
|
||||
}
|
||||
t.Logf("内容词: %v", content)
|
||||
|
||||
if kw := ExtractKeywords("机器学习模型训练需要大量数据和算力"); len(kw) == 0 {
|
||||
t.Fatal("关键词为空")
|
||||
}
|
||||
}
|
||||
|
||||
// 落盘目录必须幂等:第二次调用不应重写文件(正常启动只做几次 stat)。
|
||||
func TestMaterializeJiebaDictIsIdempotent(t *testing.T) {
|
||||
dir, err := materializeJiebaDict()
|
||||
if err != nil {
|
||||
t.Fatalf("materializeJiebaDict: %v", err)
|
||||
}
|
||||
for _, name := range jiebaDictFiles {
|
||||
p := filepath.Join(dir, name)
|
||||
fi, err := os.Stat(p)
|
||||
if err != nil {
|
||||
t.Fatalf("缺少词库文件 %s: %v", name, err)
|
||||
}
|
||||
if fi.Size() == 0 {
|
||||
t.Fatalf("词库文件 %s 为空", name)
|
||||
}
|
||||
}
|
||||
|
||||
// 记下 mtime,再调一次,必须完全没动过。
|
||||
before, _ := os.Stat(filepath.Join(dir, "jieba.dict.utf8"))
|
||||
dir2, err := materializeJiebaDict()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if dir2 != dir {
|
||||
t.Fatalf("目录名不稳定:%s vs %s(会导致每次启动重建词库)", dir, dir2)
|
||||
}
|
||||
after, _ := os.Stat(filepath.Join(dir, "jieba.dict.utf8"))
|
||||
if !before.ModTime().Equal(after.ModTime()) {
|
||||
t.Fatal("已存在完整词库时不应重写文件")
|
||||
}
|
||||
}
|
||||
|
||||
// 词库内容哈希必须稳定:否则目录名每次都变,等于每次启动都重建。
|
||||
func TestJiebaDictDigestStable(t *testing.T) {
|
||||
a, err := jiebaDictDigest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
b, err := jiebaDictDigest()
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if a != b {
|
||||
t.Fatalf("哈希不稳定: %s vs %s", a, b)
|
||||
}
|
||||
if len(a) != 16 {
|
||||
t.Fatalf("哈希长度异常: %q", a)
|
||||
}
|
||||
}
|
||||
34
internal/memory/jiebadict/hmm_model.utf8
Normal file
34
internal/memory/jiebadict/hmm_model.utf8
Normal file
File diff suppressed because one or more lines are too long
258826
internal/memory/jiebadict/idf.utf8
Normal file
258826
internal/memory/jiebadict/idf.utf8
Normal file
File diff suppressed because it is too large
Load Diff
348982
internal/memory/jiebadict/jieba.dict.utf8
Normal file
348982
internal/memory/jiebadict/jieba.dict.utf8
Normal file
File diff suppressed because it is too large
Load Diff
1534
internal/memory/jiebadict/stop_words.utf8
Normal file
1534
internal/memory/jiebadict/stop_words.utf8
Normal file
File diff suppressed because it is too large
Load Diff
4
internal/memory/jiebadict/user.dict.utf8
Normal file
4
internal/memory/jiebadict/user.dict.utf8
Normal file
@ -0,0 +1,4 @@
|
||||
云计算
|
||||
韩玉鉴赏
|
||||
蓝翔 nz
|
||||
区块链 10 nz
|
||||
@ -15,6 +15,12 @@ var (
|
||||
// (doc.insertWithMedia、io.injectMedia / injectMediaSync /
|
||||
// injectInterruptMedia),并把 text/image/audio 三条输入路径归一成
|
||||
// 一条 processInput 主干。
|
||||
// 1.2.0:模型中立的多模态 provider SPI(pkg/embedding)——内核不再适配任何
|
||||
// 具体模型,Qwen 实现移到 providers/qwen3vl;插件运行协议升到 2
|
||||
// (统一共享内存区,fd3 布局改变,不支持滚动升级);并实现 SDK 1.2.0
|
||||
// 新增的注入行为标志位(InjectOptions:no_memory / context_policy)
|
||||
// 与 ChannelDef.ContextPolicy,使输入/排队注入/中断注入/同步注入都能
|
||||
// 声明「是否记入记忆」与「是否据此裁剪上下文」(默认都是否)。
|
||||
//
|
||||
// ❗main 上此值始终是**下一个未发布中版本**,不随 patch 发布变动
|
||||
//(见 docs/git-branching.md §2.1);已发布的版本号看对应的 release/vX.Y.x 与 tag。
|
||||
@ -32,9 +38,11 @@ var (
|
||||
// SDKCompatibleVersion 是此内核可兼容的最高 SDK 版本(semver)。
|
||||
//
|
||||
// 1.1.0:本内核实现了 SDK 1.1.0 的全部新增方法。
|
||||
// 用 SDK 1.0.0 编的存量插件照旧可用——新增方法由**插件调用、内核实现**,
|
||||
// 不调就不受影响,无需重编。
|
||||
SDKCompatibleVersion = "1.1.0"
|
||||
// 1.2.0:本内核实现了 SDK 1.2.0 的全部新增方法(IOInjector 的六个 *Opts
|
||||
// 注入变体、InjectOptions、ChannelDef.ContextPolicy),因此声明为
|
||||
// 1.2.0。用 SDK 1.0.0/1.1.0 编的存量插件照旧可用——新增方法由
|
||||
// **插件调用、内核实现**,不调就不受影响,无需重编。
|
||||
SDKCompatibleVersion = "1.2.0"
|
||||
)
|
||||
|
||||
// FullVersion 返回完整的版本字符串。
|
||||
|
||||
33
internal/plugin/proc/contextpolicy_test.go
Normal file
33
internal/plugin/proc/contextpolicy_test.go
Normal file
@ -0,0 +1,33 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
// 非法 context_policy 必须报错,而不是静默当成 none。
|
||||
//
|
||||
// 为什么这条值得单独测:把拼写错误降级成「不裁剪」不会有任何报错、日志或
|
||||
// 行为异常——调用方会一直以为自己声明的裁剪在生效,直到某天上下文被撑爆。
|
||||
// 这类静默降级是本次改造要消掉的东西,所以要钉住。
|
||||
func TestValidateContextPolicy(t *testing.T) {
|
||||
ok := []string{"", "none", "prune"}
|
||||
for _, policy := range ok {
|
||||
if err := validateContextPolicy("tool.register", policy); err != nil {
|
||||
t.Errorf("合法取值 %q 被拒绝: %v", policy, err)
|
||||
}
|
||||
}
|
||||
|
||||
bad := []string{"prune ", "PRUNE", "True", "None", "always", "裁剪"}
|
||||
for _, policy := range bad {
|
||||
err := validateContextPolicy("io.injectText", policy)
|
||||
if err == nil {
|
||||
t.Errorf("非法取值 %q 应被拒绝", policy)
|
||||
continue
|
||||
}
|
||||
// 报错要指出位置与实际值,否则排查时不知道是谁传错了。
|
||||
if !strings.Contains(err.Error(), "io.injectText") || !strings.Contains(err.Error(), policy) {
|
||||
t.Errorf("错误信息应包含位置与实际值,实际: %v", err)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -101,6 +101,15 @@ type CoreSDK interface {
|
||||
InjectInputMediaSync(source, channel, text string, blocks []pubsdk.ContentBlock) string
|
||||
InjectInterruptMedia(source, channel, text string, blocks []pubsdk.ContentBlock)
|
||||
|
||||
// 带标志位的注入:声明这一次注入是否记入记忆、是否据此裁剪上下文。
|
||||
// 上面的三参数方法是它们的零值糖。
|
||||
InjectTextOpts(source, channel, text string, opts pubsdk.InjectOptions)
|
||||
InjectInterruptTextOpts(source, channel, text string, opts pubsdk.InjectOptions)
|
||||
InjectInputSyncOpts(source, channel, text string, opts pubsdk.InjectOptions) string
|
||||
InjectInputMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions)
|
||||
InjectInputMediaSyncOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) string
|
||||
InjectInterruptMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions)
|
||||
|
||||
// SetToolBlocks 注入媒体块,内核在下一条 tool message 携带(§3.8)。
|
||||
SetToolBlocks(blocks []pubsdk.ContentBlock)
|
||||
|
||||
@ -150,50 +159,75 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("input.register: %w", err)
|
||||
}
|
||||
return nil, h.sdk.RegisterInputChannel(p.Name, pubsdk.ChannelDef{
|
||||
NoMemory: p.Def.NoMemory,
|
||||
Cleaner: cleaner,
|
||||
})
|
||||
if err := validateContextPolicy("input.register", p.Def.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 整体传 p.Def(只是把函数型的 Cleaner 换成代理),不要手写字段白名单:
|
||||
// 白名单会让新增字段静默丢失。
|
||||
def := p.Def
|
||||
def.Cleaner = cleaner
|
||||
return nil, h.sdk.RegisterInputChannel(p.Name, def)
|
||||
|
||||
// ---- IO 注入(原 case 5/6/7/47)----
|
||||
//
|
||||
// 注入标志位(no_memory / context_policy)由插件在调用点声明,默认
|
||||
// 记入记忆 + 不裁剪。策略值在入口校验:静默降级成 none 会让调用方
|
||||
// 以为自己声明的裁剪在生效。
|
||||
case MethodIOInjectText:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectText(p.Source, p.Channel, h.resolveText(p))
|
||||
if err := validateContextPolicy("io.injectText", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
return nil, nil
|
||||
case MethodIOInjectInterrupt:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptText(p.Source, p.Channel, h.resolveText(p))
|
||||
if err := validateContextPolicy("io.injectInterrupt", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
return nil, nil
|
||||
case MethodIOInjectTextNoMem:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectTextNoMemory(p.Source, p.Channel, h.resolveText(p))
|
||||
if err := validateContextPolicy("io.injectTextNoMem", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
// 旧 RPC 语义就是「不进记忆」,显式标志位只可能再叠上 context_policy。
|
||||
h.sdk.InjectTextOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(true, p.ContextPolicy, p.CleanerName))
|
||||
return nil, nil
|
||||
case MethodIOInjectSync:
|
||||
var p injectParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return map[string]interface{}{"reply": h.sdk.InjectInputSync(p.Source, p.Channel, h.resolveText(p))}, nil
|
||||
if err := validateContextPolicy("io.injectInputSync", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputSyncOpts(p.Source, p.Channel, h.resolveText(p), pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodIOInjectMedia:
|
||||
var p injectMediaParams
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectMedia", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInputMedia(p.Source, p.Channel, p.Text, blocks)
|
||||
h.sdk.InjectInputMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
return nil, nil
|
||||
|
||||
case MethodIOInjectMediaSync:
|
||||
@ -201,11 +235,14 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectMediaSync", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
reply := h.sdk.InjectInputMediaSync(p.Source, p.Channel, p.Text, blocks)
|
||||
reply := h.sdk.InjectInputMediaSyncOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
return map[string]interface{}{"reply": reply}, nil
|
||||
|
||||
case MethodIOInjectInterruptMedia:
|
||||
@ -213,11 +250,14 @@ func (h *coreHandler) Handle(method string, params json.RawMessage) (interface{}
|
||||
if err := unmarshal(params, &p); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if err := validateContextPolicy("io.injectInterruptMedia", p.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
blocks, err := h.resolveBlocks(p)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
h.sdk.InjectInterruptMedia(p.Source, p.Channel, p.Text, blocks)
|
||||
h.sdk.InjectInterruptMediaOpts(p.Source, p.Channel, p.Text, blocks, pubSdkInjectOpts(p.NoMemory, p.ContextPolicy, p.CleanerName))
|
||||
return nil, nil
|
||||
|
||||
// ---- 生命周期(原 case 8)----
|
||||
@ -648,10 +688,13 @@ func (h *coreHandler) resolveText(p injectParams) string {
|
||||
}
|
||||
|
||||
type injectParams struct {
|
||||
Source string `json:"source"`
|
||||
Channel string `json:"channel"`
|
||||
Text string `json:"text,omitempty"`
|
||||
TextRef SharedRef `json:"text_ref,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Channel string `json:"channel"`
|
||||
Text string `json:"text,omitempty"`
|
||||
TextRef SharedRef `json:"text_ref,omitempty"`
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
ContextPolicy string `json:"context_policy,omitempty"`
|
||||
CleanerName string `json:"cleaner_name,omitempty"`
|
||||
}
|
||||
|
||||
// injectMediaParams 是带媒体注入/工具块注入的参数。
|
||||
@ -664,11 +707,33 @@ type injectParams struct {
|
||||
//
|
||||
// 没有 BlocksRef 时(直连 RPC 测试、arena 不可用)回退内联 Blocks。
|
||||
type injectMediaParams struct {
|
||||
Source string `json:"source"`
|
||||
Channel string `json:"channel"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Blocks []pubsdk.ContentBlock `json:"blocks,omitempty"`
|
||||
BlocksRef SharedRef `json:"blocks_ref,omitempty"`
|
||||
Source string `json:"source"`
|
||||
Channel string `json:"channel"`
|
||||
Text string `json:"text,omitempty"`
|
||||
Blocks []pubsdk.ContentBlock `json:"blocks,omitempty"`
|
||||
BlocksRef SharedRef `json:"blocks_ref,omitempty"`
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
ContextPolicy string `json:"context_policy,omitempty"`
|
||||
CleanerName string `json:"cleaner_name,omitempty"`
|
||||
}
|
||||
|
||||
// pubSdkInjectOpts 把 RPC 报文里的三个字段转成公开 SDK 的 InjectOptions。
|
||||
//
|
||||
// 单独提一个转换函数是为了让「默认值」只有一个出处:零值即记入记忆 + 不裁剪,
|
||||
// 与旧三参数注入等价。
|
||||
func pubSdkInjectOpts(noMemory bool, policy, cleanerName string) pubsdk.InjectOptions {
|
||||
return pubsdk.InjectOptions{NoMemory: noMemory, ContextPolicy: policy, CleanerName: cleanerName}
|
||||
}
|
||||
|
||||
// validateContextPolicy 校验上下文策略取值,与 tool.register 同一套规则。
|
||||
//
|
||||
// 空串等价于 none(不裁剪)。非法值必须报错而不是当成 none:把拼写错误
|
||||
// 静默降级成「不裁剪」会让调用方以为自己声明的裁剪在生效。
|
||||
func validateContextPolicy(where, policy string) error {
|
||||
if !pubsdk.ValidContextPolicy(policy) {
|
||||
return fmt.Errorf("%s: context_policy 只允许 none/prune,实际 %q", where, policy)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// resolveJSONRef 若 ref 非零则从共享内存读取并 JSON 反序列化到 out;
|
||||
@ -812,10 +877,8 @@ func (h *coreHandler) toolRegister(params json.RawMessage) (interface{}, error)
|
||||
if p.Name == "" {
|
||||
return nil, fmt.Errorf("tool.register: 缺少 name")
|
||||
}
|
||||
switch p.Def.ContextPolicy {
|
||||
case "", "none", "prune":
|
||||
default:
|
||||
return nil, fmt.Errorf("tool.register: context_policy 只允许 none/prune,实际 %q", p.Def.ContextPolicy)
|
||||
if err := validateContextPolicy("tool.register", p.Def.ContextPolicy); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
p.Def.Plugin = h.name
|
||||
// 函数本身不进 JSON;has_cleaner 只声明其存在,实际执行回到插件进程。
|
||||
|
||||
@ -29,6 +29,8 @@ type fakeCoreSDK struct {
|
||||
|
||||
// injected 记录经 InjectText 注入的文本(验证跨进程共享槽路径)。
|
||||
injected []string
|
||||
// lastInjectOpts 记录最近一次带标志位注入的 opts(跨进程转发断言用)。
|
||||
lastInjectOpts pubsdk.InjectOptions
|
||||
// toolBlocks 累积 SetToolBlocks 收到的块(多模态注入通道)。
|
||||
toolBlocks []pubsdk.ContentBlock
|
||||
// 文档/知识:验证大正文经 doc_ref / content_ref 走共享内存。
|
||||
@ -64,6 +66,13 @@ func (f *fakeCoreSDK) InjectText(s, c, t string) {
|
||||
f.mu.Unlock()
|
||||
}
|
||||
|
||||
// lastInjectOpts 记录最近一次带标志位注入的 opts(跨进程转发断言用)。
|
||||
func (f *fakeCoreSDK) lastOpts() pubsdk.InjectOptions {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
return f.lastInjectOpts
|
||||
}
|
||||
|
||||
func (f *fakeCoreSDK) injectedTexts() []string {
|
||||
f.mu.Lock()
|
||||
defer f.mu.Unlock()
|
||||
@ -78,6 +87,33 @@ func (f *fakeCoreSDK) InjectInputMediaSync(s, c, t string, b []pubsdk.ContentBlo
|
||||
}
|
||||
func (f *fakeCoreSDK) InjectInterruptMedia(s, c, t string, b []pubsdk.ContentBlock) {}
|
||||
|
||||
// ---- 带 InjectOptions 的注入(1.2.0)----
|
||||
//
|
||||
// 转发到旧方法即可:本测试关心的是「注入了什么话」,标志位的转发在
|
||||
// corehandler 与 io 层的测试里覆盖。
|
||||
func (f *fakeCoreSDK) InjectTextOpts(s, c, t string, o pubsdk.InjectOptions) {
|
||||
// 记录 opts:跨进程测试要断言插件在调用点声明的标志位确实穿过了 RPC。
|
||||
f.mu.Lock()
|
||||
f.lastInjectOpts = o
|
||||
f.mu.Unlock()
|
||||
f.InjectText(s, c, t)
|
||||
}
|
||||
func (f *fakeCoreSDK) InjectInterruptTextOpts(s, c, t string, o pubsdk.InjectOptions) {
|
||||
f.InjectInterruptText(s, c, t)
|
||||
}
|
||||
func (f *fakeCoreSDK) InjectInputSyncOpts(s, c, t string, o pubsdk.InjectOptions) string {
|
||||
return f.InjectInputSync(s, c, t)
|
||||
}
|
||||
func (f *fakeCoreSDK) InjectInputMediaOpts(s, c, t string, b []pubsdk.ContentBlock, o pubsdk.InjectOptions) {
|
||||
f.InjectInputMedia(s, c, t, b)
|
||||
}
|
||||
func (f *fakeCoreSDK) InjectInputMediaSyncOpts(s, c, t string, b []pubsdk.ContentBlock, o pubsdk.InjectOptions) string {
|
||||
return f.InjectInputMediaSync(s, c, t, b)
|
||||
}
|
||||
func (f *fakeCoreSDK) InjectInterruptMediaOpts(s, c, t string, b []pubsdk.ContentBlock, o pubsdk.InjectOptions) {
|
||||
f.InjectInterruptMedia(s, c, t, b)
|
||||
}
|
||||
|
||||
// SetToolBlocks 记录收到的媒体块,供测试断言共享内存通道真的把内容带到了内核侧。
|
||||
func (f *fakeCoreSDK) SetToolBlocks(blocks []pubsdk.ContentBlock) {
|
||||
f.mu.Lock()
|
||||
@ -779,6 +815,20 @@ func TestPlugin_ArenaAllocFreeAcrossProcess(t *testing.T) {
|
||||
t.Fatalf("经共享槽读到的内容不一致:len(got)=%d len(want)=%d", len(got[0]), len(payload))
|
||||
}
|
||||
|
||||
// 注入标志位必须穿过 RPC 到达内核:插件在调用点声明「不进记忆 / 据此裁剪 /
|
||||
// 用哪个 cleaner」,内核得拿到才能照做。只测 SDK 侧记录不到这一点——
|
||||
// 字段在 JSON 与参数结构之间丢掉的失败模式是静默的。
|
||||
opts := core.lastOpts()
|
||||
if !opts.NoMemory {
|
||||
t.Errorf("no_memory 未穿过 RPC: %+v", opts)
|
||||
}
|
||||
if opts.ContextPolicy != "prune" {
|
||||
t.Errorf("context_policy 未穿过 RPC: %+v", opts)
|
||||
}
|
||||
if opts.CleanerName != "demo_cleaner" {
|
||||
t.Errorf("cleaner_name 未穿过 RPC: %+v", opts)
|
||||
}
|
||||
|
||||
// 插件已归还槽:池必须回到全空,否则说明 arena.free 没生效。
|
||||
if used, total := host.Arena().Stats(); used != 0 {
|
||||
t.Fatalf("插件归还后槽池应全空,实际 used=%d/%d", used, total)
|
||||
|
||||
@ -15,19 +15,19 @@ import (
|
||||
|
||||
// Windows 侧共享段:命名 FileMapping + 命名 Event。
|
||||
//
|
||||
// 与 Unix 的机制差异(不是能力差异):
|
||||
// Windows 没有 fd 继承语义——os/exec 的 ExtraFiles 在 Windows 实现里不被支持。
|
||||
// 等价机制是命名内核对象:父进程 CreateFileMappingW 建带名字的段,
|
||||
// 子进程 OpenFileMappingW 按同名打开,拿到同一份物理页。
|
||||
// ⚠️ 本文件**已不是可用路径**:homed 已放弃 Windows 原生支持
|
||||
// (见 cmd/homed/platform_windows.go)。原因:插件体系依赖「继承的 fd」与
|
||||
// 「统一共享内存区的段内偏移解引用」,而 Windows 既没有 fd 继承语义
|
||||
// (os/exec 的 ExtraFiles 在 Windows 不支持),本文件描述的也仍是**旧的**
|
||||
// 两段布局(StageContext 段 + 事件环段),跟不上 §13.1 的单块统一区域。
|
||||
//
|
||||
// **这是 §9.2 的正解**。C ABI 时代 Windows 是第三套独立 ABI 实现
|
||||
// (dynamic_dll_windows.go),stage 只下发 3 字段且完全没有写回,
|
||||
// sanitizer 这类改写型插件静默失效。三套 ABI 收敛为单一 RPC 后,
|
||||
// Windows 与 Unix 共用同一份 stage 逻辑与同一份共享段布局,
|
||||
// 平台差异只剩本文件的创建端 + 插件侧模板的打开端。
|
||||
// 保留本文件只为让 GOOS=windows 仍能编译:否则平台门根本跑不起来,
|
||||
// 用户看到的会是「产物缺失」而不是一句「请用 WSL」。
|
||||
// allocShm 因此在入口直接报错,不返回一个「看起来能用」的段——
|
||||
// 让它跑起来只会得到无法解释的握手失败,这比启动失败难查得多
|
||||
// (与 shmalloc_other.go 的处理方式一致)。
|
||||
//
|
||||
// 名字带 PID 与递增序号:多个 homed 实例并存时不能撞名,
|
||||
// 同一实例内 StageContext 段与事件环段也必须分开。
|
||||
// Windows 用户的正确路径:WSL2(在 WSL 里就是普通 linux/amd64)。
|
||||
var shmNameSeq atomic.Uint64
|
||||
|
||||
const (
|
||||
@ -60,20 +60,15 @@ var (
|
||||
shmHandlesMu sync.Mutex
|
||||
)
|
||||
|
||||
// allocShm 创建命名共享段并映射。
|
||||
// allocShm 在 Windows 上明确报错:homed 不支持 Windows 原生运行。
|
||||
//
|
||||
// 返回的 *os.File 为 nil:Windows 不经 fd 传递段,插件按名字打开。
|
||||
// 名字通过 procEnvForShm 注入子进程环境变量。
|
||||
// 不返回「能用的段」:本文件实现的是 §13.1 之前的**两段**布局,
|
||||
// 与当前内核的单块统一区域不兼容。静默返回只会在握手阶段变成一句
|
||||
// 无法解释的魔数不匹配。报错文案直接给出行动:用 WSL2。
|
||||
func allocShm(size int) (*os.File, []byte, error) {
|
||||
name := shmNameForMode()
|
||||
shm, data, err := createNamedMapping(name, size)
|
||||
if err != nil {
|
||||
return nil, nil, err
|
||||
}
|
||||
shmHandlesMu.Lock()
|
||||
shmHandles[uintptr(unsafe.Pointer(&data[0]))] = shm
|
||||
shmHandlesMu.Unlock()
|
||||
return nil, data, nil
|
||||
return nil, nil, fmt.Errorf("proc: homed 不支持 Windows 原生运行" +
|
||||
"(插件体系依赖 fd 继承与统一共享内存区的段内偏移解引用)——请使用 WSL2;" +
|
||||
"详见 cmd/homed/platform_windows.go")
|
||||
}
|
||||
|
||||
// shmNameForMode 按安全模式生成命名段名。
|
||||
@ -87,7 +82,7 @@ func shmNameForMode() string {
|
||||
return fmt.Sprintf("%s_%d_%d", shmNamePrefix, os.Getpid(), seq)
|
||||
default: // safe / debug
|
||||
var b [12]byte
|
||||
if _, err := cryptorand.Read(b[:]); err != nil {
|
||||
if _, err := rand.Read(b[:]); err != nil {
|
||||
// 退化为 PID+seq(极端情况,crypto rand 几乎不会失败)
|
||||
return fmt.Sprintf("%s_%d_%d", shmNamePrefix, os.Getpid(), seq)
|
||||
}
|
||||
|
||||
@ -3,24 +3,16 @@
|
||||
package proc
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
)
|
||||
|
||||
// procEnvForShm 返回子进程挂载共享段所需的环境变量(Windows)。
|
||||
// procEnvForShm 在 Windows 上返回空:这条路径已不可用。
|
||||
//
|
||||
// Windows 没有 fd 继承语义(os/exec 的 ExtraFiles 在 Windows 不被支持),
|
||||
// 故段与事件对象的**名字**经环境变量传给子进程,插件侧模板的
|
||||
// z_proc_shm_windows.go 按同名 OpenFileMappingW / OpenEventW 打开。
|
||||
//
|
||||
// 名字带 PID 与递增序号:多个 homed 实例并存时不能撞名。
|
||||
func (h *Host) procEnvForShm() []string {
|
||||
return []string{
|
||||
fmt.Sprintf("HOMEAGENT_SHM_STAGE=%s", shmNameOf(h.data)),
|
||||
fmt.Sprintf("HOMEAGENT_SHM_EVTRING=%s", shmNameOf(h.evtData)),
|
||||
fmt.Sprintf("HOMEAGENT_EVT_EVENT=%s", evtEventNameOf(h.evtNotifyFd)),
|
||||
}
|
||||
}
|
||||
// 原实现传的是旧的两段布局(SHM_STAGE + SHM_EVTRING)的两个名字,
|
||||
// 而 §13.1 之后内核只有一块统一区域;名字的数量本身就是错的。
|
||||
// 真正的失败发生在更早的 allocShm(那里给出明确的「请用 WSL2」),
|
||||
// 所以这里不再返回任何东西——返回半套名字只会让人以为「只是名字没更新」。
|
||||
func (h *Host) procEnvForShm() []string { return nil }
|
||||
|
||||
// procExtraFilesForShm 在 Windows 返回 nil:段不经 fd 传递。
|
||||
func (h *Host) procExtraFilesForShm() []*os.File { return nil }
|
||||
|
||||
5
internal/plugin/proc/testdata/stageplugin.go
vendored
5
internal/plugin/proc/testdata/stageplugin.go
vendored
@ -438,8 +438,13 @@ func main() {
|
||||
}
|
||||
copy(region[ref.Offset:ref.Offset+uint32(len(text))], text)
|
||||
ref.Length = uint32(len(text))
|
||||
// 注入标志位随参数一起过 RPC:内核据此决定这次注入是否
|
||||
// 记入记忆、是否据此裁剪上下文、用哪个已注册 cleaner 清洗。
|
||||
callKernel("io.injectText", map[string]interface{}{
|
||||
"source": "plugin", "channel": "demo", "text_ref": ref,
|
||||
"no_memory": true,
|
||||
"context_policy": "prune",
|
||||
"cleaner_name": "demo_cleaner",
|
||||
})
|
||||
arenaFree(ref)
|
||||
|
||||
|
||||
@ -126,6 +126,23 @@ func (c procCore) InjectTextNoMemory(source, channel, text string) {
|
||||
c.sdk.InjectTextNoMemory(source, channel, text)
|
||||
}
|
||||
|
||||
// ---- 带注入标志位(记忆/裁剪行为由插件在调用点声明)----
|
||||
|
||||
func (c procCore) InjectTextOpts(source, channel, text string, opts pubsdk.InjectOptions) {
|
||||
c.sdk.InjectTextOpts(source, channel, text, opts)
|
||||
}
|
||||
|
||||
func (c procCore) InjectInterruptTextOpts(source, channel, text string, opts pubsdk.InjectOptions) {
|
||||
c.sdk.InjectInterruptTextOpts(source, channel, text, opts)
|
||||
}
|
||||
|
||||
func (c procCore) InjectInputSyncOpts(source, channel, text string, opts pubsdk.InjectOptions) string {
|
||||
// 这里用公共 SDK 的三参数 + opts 形态(返回回复文本),
|
||||
// 不用内核内部那个 (eventType, payload) → *OutputEvent 的全量签名:
|
||||
// 它会把内核 IO 事件结构暴露给外部插件。
|
||||
return c.sdk.InjectInputSyncOpts(source, channel, text, opts)
|
||||
}
|
||||
|
||||
// InjectInputSync 收窄为公开 SDK 的三参数文本形态。
|
||||
//
|
||||
// internal/sdk.PluginSDK 的同名方法是 (source, channel, eventType, payload)
|
||||
@ -159,6 +176,18 @@ func (c procCore) InjectInterruptMedia(source, channel, text string, blocks []pu
|
||||
c.sdk.InjectInterruptMedia(source, channel, text, blocks)
|
||||
}
|
||||
|
||||
func (c procCore) InjectInputMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) {
|
||||
c.sdk.InjectInputMediaOpts(source, channel, text, blocks, opts)
|
||||
}
|
||||
|
||||
func (c procCore) InjectInputMediaSyncOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) string {
|
||||
return c.sdk.InjectInputMediaSyncOpts(source, channel, text, blocks, opts)
|
||||
}
|
||||
|
||||
func (c procCore) InjectInterruptMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) {
|
||||
c.sdk.InjectInterruptMediaOpts(source, channel, text, blocks, opts)
|
||||
}
|
||||
|
||||
// SetToolBlocks 转调 internal/sdk:插件工具注入的媒体块,内核在下一轮
|
||||
// tool message 携带。
|
||||
func (c procCore) SetToolBlocks(blocks []pubsdk.ContentBlock) {
|
||||
|
||||
@ -810,8 +810,11 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
flushTicker := time.NewTicker(200 * time.Millisecond)
|
||||
defer flushTicker.Stop()
|
||||
|
||||
// 立即发送首次"终端已启动"通知,让 agent 感知存在
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id))
|
||||
// 立即发送首次"终端已启动"通知,让 agent 感知存在。
|
||||
// 用 NoMemory:这是状态提示,不是对话内容。不关掉的话每开一个终端都会
|
||||
// 在记忆里留下一条"[终端 X 已启动]",把真实内容挤掉。
|
||||
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已启动]", t.id),
|
||||
sdk.InjectOptions{NoMemory: true})
|
||||
now := time.Now()
|
||||
t.mu.Lock()
|
||||
t.lastNotify = now
|
||||
@ -828,7 +831,8 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
for {
|
||||
if t.IsExpired() {
|
||||
log.Printf("[agentcli] terminal %s expired after %v", t.id, t.timeout)
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已超时关闭(%s)]", t.id, t.timeout))
|
||||
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 已超时关闭(%s)]", t.id, t.timeout),
|
||||
sdk.InjectOptions{NoMemory: true})
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, t.id)
|
||||
p.mu.Unlock()
|
||||
@ -837,9 +841,11 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
|
||||
if !terminalRunning(t) {
|
||||
if t.watch.onExit {
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的命令已执行结束]", t.id))
|
||||
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的命令已执行结束]", t.id),
|
||||
sdk.InjectOptions{NoMemory: true})
|
||||
} else {
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id))
|
||||
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 中的进程已退出]", t.id),
|
||||
sdk.InjectOptions{NoMemory: true})
|
||||
}
|
||||
p.mu.Lock()
|
||||
delete(p.sessions, t.id)
|
||||
@ -857,6 +863,8 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
preview := previewTail(t.buf.String(), 120)
|
||||
t.mu.Unlock()
|
||||
s.InjectText("agentcli", "agentcli",
|
||||
// 刻意**不**用 NoMemory:这条带上终端真实输出(preview),
|
||||
// 属于该记的内容。只有纯状态通知才关记忆。
|
||||
fmt.Sprintf("[终端 %s 定时反馈: 运行中, 期间新输出约 %d 字节]\n%s", t.id, unread, preview))
|
||||
continue
|
||||
}
|
||||
@ -885,7 +893,8 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
case r := <-readCh:
|
||||
if r.err != nil {
|
||||
// 读取错误/EOF → 立即通知(进程可能已结束)
|
||||
s.InjectText("agentcli", "agentcli", fmt.Sprintf("[终端 %s 读取结束: %v]", t.id, r.err))
|
||||
s.InjectTextOpts("agentcli", "agentcli", fmt.Sprintf("[终端 %s 读取结束: %v]", t.id, r.err),
|
||||
sdk.InjectOptions{NoMemory: true})
|
||||
return
|
||||
}
|
||||
if r.n > 0 {
|
||||
@ -927,6 +936,7 @@ func (p *Plugin) readLoop(t *TerminalSession, s *sdk.PluginSDK) {
|
||||
preview := previewTail(t.buf.String(), 200)
|
||||
t.mu.Unlock()
|
||||
s.InjectText("agentcli", "agentcli",
|
||||
// 同样刻意保留记忆:preview 是终端新输出,是真实内容。
|
||||
fmt.Sprintf("[终端 %s 有新输出]\n%s", t.id, preview))
|
||||
} else {
|
||||
t.mu.Unlock()
|
||||
|
||||
@ -440,6 +440,27 @@ func (c *injectCapture) InjectInterruptMedia(source, channel, text string, block
|
||||
c.mu.Unlock()
|
||||
}
|
||||
|
||||
// ---- 带 InjectOptions 的注入(1.2.0):同样只记文本 ----
|
||||
|
||||
func (c *injectCapture) InjectTextOpts(source, channel, text string, opts sdkpub.InjectOptions) {
|
||||
c.InjectText(source, channel, text)
|
||||
}
|
||||
func (c *injectCapture) InjectInterruptTextOpts(source, channel, text string, opts sdkpub.InjectOptions) {
|
||||
c.InjectInterruptText(source, channel, text)
|
||||
}
|
||||
func (c *injectCapture) InjectInputSyncOpts(source, channel, text string, opts sdkpub.InjectOptions) string {
|
||||
return ""
|
||||
}
|
||||
func (c *injectCapture) InjectInputMediaOpts(source, channel, text string, blocks []sdkpub.ContentBlock, opts sdkpub.InjectOptions) {
|
||||
c.InjectInputMedia(source, channel, text, blocks)
|
||||
}
|
||||
func (c *injectCapture) InjectInputMediaSyncOpts(source, channel, text string, blocks []sdkpub.ContentBlock, opts sdkpub.InjectOptions) string {
|
||||
return ""
|
||||
}
|
||||
func (c *injectCapture) InjectInterruptMediaOpts(source, channel, text string, blocks []sdkpub.ContentBlock, opts sdkpub.InjectOptions) {
|
||||
c.InjectInterruptMedia(source, channel, text, blocks)
|
||||
}
|
||||
|
||||
func (c *injectCapture) snapshot() []string {
|
||||
c.mu.Lock()
|
||||
defer c.mu.Unlock()
|
||||
|
||||
@ -107,7 +107,9 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||||
select {
|
||||
case <-time.After(dur):
|
||||
log.Printf("[timer] firing: %s (%s later)", message, dur)
|
||||
s.InjectInterruptText("timer", "timer", fmt.Sprintf("timer: %s", message))
|
||||
// NoMemory:定时提醒是系统通知,不是记忆内容。
|
||||
s.InjectInterruptTextOpts("timer", "timer", fmt.Sprintf("timer: %s", message),
|
||||
sdk.InjectOptions{NoMemory: true})
|
||||
case <-p.stopCh:
|
||||
log.Printf("[timer] cancelled: %s", message)
|
||||
}
|
||||
|
||||
@ -2253,7 +2253,11 @@ func (h *Handler) handleOpenAICompletions(w http.ResponseWriter, r *http.Request
|
||||
|
||||
respCh := make(chan *agentIO.OutputEvent, 1)
|
||||
go func() {
|
||||
respCh <- h.sdk.InjectTextSync("http", "http", lastMsg.Content)
|
||||
// NoMemory:本端点(OpenAI 兼容 /v1/chat/completions)的调用方是
|
||||
// IDE、工具与脚本,送来的是**固定的提示词模板**("请分析这段代码"之类),
|
||||
// 不是人类在对话。记进记忆会把真实对话挤掉,而且同一模板会反复刷屏。
|
||||
// 原文仍进上下文,模型照旧看得到;只是不参与向量化/关键词提取/蒸馏。
|
||||
respCh <- h.sdk.InjectTextSyncNoMemory("http", "http", lastMsg.Content)
|
||||
}()
|
||||
|
||||
var response *agentIO.OutputEvent
|
||||
|
||||
@ -53,6 +53,15 @@ type OutputChannelRegistrar = pubsdk.OutputChannelRegistrar
|
||||
type InputChannelRegistrar = pubsdk.InputChannelRegistrar
|
||||
type ChannelDef = pubsdk.ChannelDef
|
||||
|
||||
// InjectOptions / 上下文策略常量:内置插件与外部插件必须用同一套类型与取值,
|
||||
// 否则内核要认两份,而漏认会静默丢失标志位。
|
||||
type InjectOptions = pubsdk.InjectOptions
|
||||
|
||||
const (
|
||||
ContextPolicyNone = pubsdk.ContextPolicyNone
|
||||
ContextPolicyPrune = pubsdk.ContextPolicyPrune
|
||||
)
|
||||
|
||||
type DisabledPluginInfo struct {
|
||||
Name string `json:"name"`
|
||||
DisabledAt string `json:"disabled_at"`
|
||||
@ -163,19 +172,29 @@ func (s *PluginSDK) LLM() LLMAPI { return s.llm }
|
||||
type ioAdapter struct{ iom *agentIO.IOManager }
|
||||
|
||||
func (a ioAdapter) InjectInterruptText(source, channel, text string) {
|
||||
a.InjectInterruptTextOpts(source, channel, text, pubsdk.InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectInterruptTextOpts 注入可抢占当前处理的中断文本,并声明记忆/裁剪行为。
|
||||
func (a ioAdapter) InjectInterruptTextOpts(source, channel, text string, opts pubsdk.InjectOptions) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInterrupt(source, channel, map[string]interface{}{"type": "text", "content": text})
|
||||
a.iom.InjectInterruptTextOpts(source, channel, text, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputSync 同步注入输入并等待回复(阻塞直至 agent 处理完成),返回回复文本。
|
||||
func (a ioAdapter) InjectInputSync(source, channel, text string) string {
|
||||
return a.InjectInputSyncOpts(source, channel, text, pubsdk.InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectInputSyncOpts 同步注入输入并声明记忆/裁剪行为。
|
||||
func (a ioAdapter) InjectInputSyncOpts(source, channel, text string, opts pubsdk.InjectOptions) string {
|
||||
if a.iom == nil {
|
||||
return ""
|
||||
}
|
||||
out := a.iom.InjectInputSyncTo(source, channel, "text", map[string]interface{}{
|
||||
out := a.iom.InjectInputSyncToOpts(source, channel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
})
|
||||
}, opts)
|
||||
if out == nil {
|
||||
return ""
|
||||
}
|
||||
@ -184,15 +203,18 @@ func (a ioAdapter) InjectInputSync(source, channel, text string) string {
|
||||
}
|
||||
|
||||
func (a ioAdapter) InjectText(source, channel, text string) {
|
||||
a.InjectTextOpts(source, channel, text, pubsdk.InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectTextOpts 注入排队文本,并声明记忆/裁剪行为。
|
||||
func (a ioAdapter) InjectTextOpts(source, channel, text string, opts pubsdk.InjectOptions) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text})
|
||||
a.iom.InjectTextOpts(source, channel, text, opts)
|
||||
}
|
||||
}
|
||||
|
||||
func (a ioAdapter) InjectTextNoMemory(source, channel, text string) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{"content": text, "no_memory": true})
|
||||
}
|
||||
a.InjectTextOpts(source, channel, text, pubsdk.InjectOptions{NoMemory: true})
|
||||
}
|
||||
|
||||
// InjectInputMedia 注入带媒体内容块的输入。
|
||||
@ -202,23 +224,27 @@ func (a ioAdapter) InjectTextNoMemory(source, channel, text string) {
|
||||
// CAS 落盘与媒体记忆绑定。与 SetToolBlocks 的区别:后者只能在工具
|
||||
// 调用内部用,且媒体要等到下一条 tool message 才到模型手上。
|
||||
func (a ioAdapter) InjectInputMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
|
||||
a.InjectInputMediaOpts(source, channel, text, blocks, pubsdk.InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
|
||||
func (a ioAdapter) InjectInputMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInputTo(source, channel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
})
|
||||
a.iom.InjectInputMediaOpts(source, channel, text, blocks, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待回复。
|
||||
func (a ioAdapter) InjectInputMediaSync(source, channel, text string, blocks []pubsdk.ContentBlock) string {
|
||||
return a.InjectInputMediaSyncOpts(source, channel, text, blocks, pubsdk.InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
|
||||
func (a ioAdapter) InjectInputMediaSyncOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) string {
|
||||
if a.iom == nil {
|
||||
return ""
|
||||
}
|
||||
out := a.iom.InjectInputSyncTo(source, channel, "text", map[string]interface{}{
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
})
|
||||
out := a.iom.InjectInputMediaSyncOpts(source, channel, text, blocks, opts)
|
||||
if out == nil {
|
||||
return ""
|
||||
}
|
||||
@ -228,12 +254,13 @@ func (a ioAdapter) InjectInputMediaSync(source, channel, text string, blocks []p
|
||||
|
||||
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
|
||||
func (a ioAdapter) InjectInterruptMedia(source, channel, text string, blocks []pubsdk.ContentBlock) {
|
||||
a.InjectInterruptMediaOpts(source, channel, text, blocks, pubsdk.InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
|
||||
func (a ioAdapter) InjectInterruptMediaOpts(source, channel, text string, blocks []pubsdk.ContentBlock, opts pubsdk.InjectOptions) {
|
||||
if a.iom != nil {
|
||||
a.iom.InjectInterrupt(source, channel, map[string]interface{}{
|
||||
"type": "text",
|
||||
"content": text,
|
||||
"media_blocks": blocks,
|
||||
})
|
||||
a.iom.InjectInterruptMediaOpts(source, channel, text, blocks, opts)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -194,23 +194,23 @@ func (e *Embedder) EmbedImageDense(raw []byte, mime string) ([]float64, error) {
|
||||
// 帧数为奇数时只用得上前 2×floor(n/2) 帧,多余一帧被丢弃(不补重复帧:
|
||||
// 那会改变跨帧注意力看到的运动)。
|
||||
func (e *Embedder) EmbedVideoDense(frames [][]byte, mime string) ([]float64, error) {
|
||||
if err := checkVideoMime(mime); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if len(frames) < qwenTemporalPatch {
|
||||
return nil, fmt.Errorf("qwen: video needs at least %d frames, got %d", qwenTemporalPatch, len(frames))
|
||||
}
|
||||
groups := len(frames) / qwenTemporalPatch
|
||||
// 先确认这一档的视觉图确实已导出,再去做昂贵的预处理:
|
||||
// 不然一个未导出档位会先白算一遍(每组 2304×1536 浮点)才报错。
|
||||
if _, err := e.visionFor(groups); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
pixels, groups, err := preprocessVideoFrames(frames)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
return e.embedVision(pixels, groups)
|
||||
// 暂时硬失败,而不是算出一个「看起来正常但语义错」的向量。
|
||||
//
|
||||
// 原因:本 provider 的 Go 侧视频提示词模板与 HuggingFace processor 不等价
|
||||
// ——processor 会按时间组插入字面时间戳文本
|
||||
//(`<0.0 seconds>` / `<1.0 seconds>`),而 Go 侧只拼
|
||||
// `<|vision_start|>{G×576 pads}<|vision_end|>`。实测同一输入 Python seq=1190
|
||||
//(1152 视觉 + 38 文本)、Go 只有 22 个文本 token;而这些时间戳文本也会
|
||||
// 占用 M-RoPE 位置。
|
||||
//
|
||||
// 差异的后果是向量整体偏移,**不报错**:检索出来的相似度没有任何意义,
|
||||
// 而且看不出是谁的错。视觉图本身(Vision_g2/g3/g4)已逐档对过 PyTorch
|
||||
//(cos≥0.999999),差的只是模板。
|
||||
//
|
||||
// 修复后删掉这个硬失败,并让 TestEmbedderVideoMatchesONNXReference 不再跳过。
|
||||
return nil, fmt.Errorf("%w: Qwen3-VL 视频路径尚未完成(Go 模板缺 processor 的分组时间戳文本,"+
|
||||
"会静默产生语义错的向量);详见 docs/zh/multimodal-space.md 与 providers/qwen3vl/embedder_onnx_test.go",
|
||||
embedding.ErrUnsupportedModality)
|
||||
}
|
||||
|
||||
// checkImageMime 把「不在本空间覆盖范围内」与「参数用错」分开报。
|
||||
|
||||
@ -520,7 +520,8 @@ func (p *Plugin) checkReminders() {
|
||||
p.mu.Unlock()
|
||||
|
||||
for _, msg := range injectMsgs {
|
||||
p.sdk.InjectInterruptText("calendar", "calendar", msg)
|
||||
// NoMemory:日程到点提醒,不是记忆内容。
|
||||
p.sdk.InjectInterruptTextOpts("calendar", "calendar", msg, sdk.InjectOptions{NoMemory: true})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -292,8 +292,9 @@ func (p *Plugin) periodicCheck() {
|
||||
continue
|
||||
}
|
||||
if p.sdk != nil {
|
||||
p.sdk.InjectInterruptText(p.name, p.name,
|
||||
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n))
|
||||
// NoMemory:这是定时提醒,不是记忆内容。
|
||||
p.sdk.InjectInterruptTextOpts(p.name, p.name,
|
||||
fmt.Sprintf("注意,你还有%d条待办未完成,请检查", n), sdk.InjectOptions{NoMemory: true})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -300,7 +300,10 @@ func (p *Plugin) checkFeed(sub FeedSub) {
|
||||
lines = append(lines, line)
|
||||
}
|
||||
|
||||
p.sdk.InjectInterruptText("rss", "rss", strings.Join(lines, "\n"))
|
||||
// 中断注入是「系统通知」,NoMemory 写明意图:这类提醒不参与记忆计算,
|
||||
// 原文仍进上下文(模型当轮看得到)。
|
||||
p.sdk.InjectInterruptTextOpts("rss", "rss", strings.Join(lines, "\n"),
|
||||
sdk.InjectOptions{NoMemory: true})
|
||||
p.saveData()
|
||||
}
|
||||
|
||||
|
||||
14
third_party/homeagent-sdk/meta/meta.go
vendored
14
third_party/homeagent-sdk/meta/meta.go
vendored
@ -28,6 +28,16 @@ var (
|
||||
// 存量插件不需要改一行也不需要重编:新增方法由**插件调用、内核实现**,
|
||||
// 不调就不受影响。想用新字段的插件重编即可。
|
||||
//
|
||||
// 1.2.0:注入行为的记忆/裁剪标志位。**全部是新增,无签名变更**:
|
||||
// - InjectOptions{NoMemory, ContextPolicy}
|
||||
// - IOInjector 的六个 *Opts 变体(排队/中断/同步/带媒体各一对)
|
||||
// - ChannelDef.ContextPolicy(顺带给 ChannelDef 补上 JSON tag:
|
||||
// 它要跨进程传给内核,而 Cleaner 是函数必须忽略;无 tag 时只能
|
||||
// 手写字段白名单,新增字段会被静默丢掉)
|
||||
// 语义:零值 InjectOptions 与旧的三参数方法完全等价(记入记忆 +
|
||||
// 不裁剪),因此存量插件不需要改一行也不需要重编。
|
||||
// 裁剪(ContextPolicy=prune)必须显式声明——它会归档丢弃低相关事件。
|
||||
//
|
||||
// ❗main 分支上此值是**下一个未发布中版本**;已发布的值看对应的
|
||||
// release/vX.Y.x 分支与 tag(见 核心仓 docs/git-branching.md §2.1 与 §七.1)。
|
||||
//
|
||||
@ -56,6 +66,10 @@ var (
|
||||
// doc.insertWithMedia / io.injectMedia* 这些 RPC,调用会返回 unknown method)。
|
||||
// 这里仍写 1.0.0,因为它是「SDK 能在其上运行」的下限;
|
||||
// 媒体接口是可选能力,不用就不受影响。
|
||||
//
|
||||
// ⚠️ 1.2.0 新增的注入标志位同理需要核心 **1.2.0+**:内核在 1.2.0 之前会
|
||||
// 忽略注入参数里的 no_memory/context_policy 字段(不会报错,但不生效)。
|
||||
// 想用这些标志位的插件应当要求核心 1.2.0+;不用就不受影响。
|
||||
CoreVersion = "1.0.0"
|
||||
)
|
||||
|
||||
|
||||
156
third_party/homeagent-sdk/sdk/plugin.go
vendored
156
third_party/homeagent-sdk/sdk/plugin.go
vendored
@ -35,12 +35,63 @@ const (
|
||||
StageAfterOutput Stage = "after_output"
|
||||
)
|
||||
|
||||
// 上下文策略:决定一次工具调用/输入/注入是否依据其内容裁剪上下文。
|
||||
//
|
||||
// 默认(空串或 ContextPolicyNone)**不裁剪**:裁剪会归档丢弃低相关事件,
|
||||
// 必须由工具/通道/注入点显式声明才发生——否则一个只想往上下文里塞内容的
|
||||
// 插件会在背后把别人的内容挤掉,且看不出是谁干的。
|
||||
const (
|
||||
ContextPolicyNone = "none"
|
||||
ContextPolicyPrune = "prune"
|
||||
)
|
||||
|
||||
// ValidContextPolicy 校验策略取值;空串等价于 ContextPolicyNone。
|
||||
func ValidContextPolicy(policy string) bool {
|
||||
switch policy {
|
||||
case "", ContextPolicyNone, ContextPolicyPrune:
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
// InjectOptions 声明一次注入行为在记忆层与上下文层的表现。
|
||||
//
|
||||
// 零值 = 记入记忆 + 不裁剪上下文,与历史行为(三参数注入方法)完全一致,
|
||||
// 因此调用方只有在确实需要改变行为时才需要填它。
|
||||
//
|
||||
// 为什么注入也要这两个标志:注入的内容来源千差万别——轮询到的频道消息
|
||||
// 属于真实对话(该记),而“任务还在跑”“连接已重连”这类提醒不该污染记忆,
|
||||
// 也不该把上下文按它的内容裁一遍。按调用点声明比按通道一刀切准确。
|
||||
//
|
||||
// NoMemory: 此次注入不参与记忆计算(向量化/关键词提取/蒸馏),原文仍留在上下文
|
||||
// ContextPolicy: 此次注入后是否依据(清洗后的)内容裁剪上下文;默认不裁剪。
|
||||
//
|
||||
// 中断注入也允许声明 prune——它同样会携带内容进入上下文。
|
||||
//
|
||||
// CleanerName: 此次注入的内容用哪个**已注册的通道 cleaner** 清洗。
|
||||
//
|
||||
// 空串 = 按注入的 source 查通道定义(既有行为)。
|
||||
// 为什么要能显式指定:注入的 source 未必是注册过的输入通道名,
|
||||
// 而注入内容往往带 ANSI/JSON 包装,需要清洗后才是有效内容;
|
||||
// 不指定就只能退到「按 source 查不到就不清洗」。
|
||||
type InjectOptions struct {
|
||||
NoMemory bool
|
||||
ContextPolicy string
|
||||
CleanerName string
|
||||
}
|
||||
|
||||
// ChannelDef 描述通道在记忆计算层的行为,与 ToolDef.NoMemory/Cleaner 语义一致。
|
||||
// NoMemory: 此通道输入/输出不参与记忆计算(向量化/关键词提取/蒸馏),但原文保留在上下文中
|
||||
// Cleaner: 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏/存档提取关键词时调用
|
||||
// ContextPolicy: 此通道的输入到达后是否据此裁剪上下文,默认 none(不裁剪)
|
||||
//
|
||||
// JSON tag 是必需的:通道定义要跨进程传给内核,而 Cleaner 是函数(必须忽略)。
|
||||
// 没有 tag 时既无法整体 marshal(func 不支持),又会诱使调用方手写字段白名单——
|
||||
// 那样新增字段会被静默丢掉。
|
||||
type ChannelDef struct {
|
||||
NoMemory bool
|
||||
Cleaner func(string) string
|
||||
NoMemory bool `json:"no_memory,omitempty"`
|
||||
Cleaner func(string) string `json:"-"`
|
||||
ContextPolicy string `json:"context_policy,omitempty"`
|
||||
}
|
||||
|
||||
// StageContext provides context for stage handlers.
|
||||
@ -100,13 +151,13 @@ type ToolResult struct {
|
||||
|
||||
// ToolDef describes a tool that the plugin exposes.
|
||||
type ToolDef struct {
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
||||
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
||||
ContextPolicy string `json:"context_policy,omitempty"` // 工具上下文策略:"none"(默认) / "prune"
|
||||
Name string `json:"name"`
|
||||
Plugin string `json:"plugin,omitempty"`
|
||||
Description string `json:"description"`
|
||||
Parameters map[string]interface{} `json:"parameters"`
|
||||
NoMemory bool `json:"no_memory,omitempty"` // 此工具输出不参与记忆计算,但原文保留
|
||||
Cleaner func(string) string `json:"-"` // 计算层过滤函数,不改原文;仅在向量化/jieba/蒸馏时调用
|
||||
ContextPolicy string `json:"context_policy,omitempty"` // 上下文策略:""(默认,不裁剪) / ContextPolicyNone / ContextPolicyPrune
|
||||
}
|
||||
|
||||
// IOInjector provides methods for injecting input and interrupts into the agent pipeline.
|
||||
@ -125,6 +176,17 @@ type IOInjector interface {
|
||||
InjectInputMedia(source, channel, text string, blocks []ContentBlock)
|
||||
InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string
|
||||
InjectInterruptMedia(source, channel, text string, blocks []ContentBlock)
|
||||
|
||||
// 以下 Opts 变体让调用点在**这一次注入**上声明记忆与裁剪行为。
|
||||
//
|
||||
// 上面那些不带 opts 的方法等价于传零值 InjectOptions(记入记忆 + 不裁剪),
|
||||
// 保留它们是为了不破坏已有插件;新代码应当用 Opts 变体把意图写清楚。
|
||||
InjectTextOpts(source, channel, text string, opts InjectOptions)
|
||||
InjectInterruptTextOpts(source, channel, text string, opts InjectOptions)
|
||||
InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string
|
||||
InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||
InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string
|
||||
InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions)
|
||||
}
|
||||
|
||||
// EventType identifies the kind of system event.
|
||||
@ -505,54 +567,94 @@ func (s *PluginSDK) injector() IOInjector {
|
||||
}
|
||||
|
||||
// InjectInterruptText injects a text interrupt that can preempt current LLM processing.
|
||||
// 等价于 InjectInterruptTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||
func (s *PluginSDK) InjectInterruptText(source, channel, text string) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectInterruptText(source, channel, text)
|
||||
}
|
||||
s.InjectInterruptTextOpts(source, channel, text, InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectText injects a text message into the agent pipeline.
|
||||
// 等价于 InjectTextOpts(..., InjectOptions{}):记入记忆、不裁剪。
|
||||
func (s *PluginSDK) InjectText(source, channel, text string) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectText(source, channel, text)
|
||||
}
|
||||
s.InjectTextOpts(source, channel, text, InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectTextNoMemory injects a text message without generating memory.
|
||||
// 等价于 InjectTextOpts(..., InjectOptions{NoMemory: true})。
|
||||
func (s *PluginSDK) InjectTextNoMemory(source, channel, text string) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectTextNoMemory(source, channel, text)
|
||||
}
|
||||
s.InjectTextOpts(source, channel, text, InjectOptions{NoMemory: true})
|
||||
}
|
||||
|
||||
// InjectInputSync injects a text message and synchronously waits for the agent reply,
|
||||
// returning the reply text (empty string if none). Replies must be dispatched back
|
||||
// to the source channel by the caller.
|
||||
func (s *PluginSDK) InjectInputSync(source, channel, text string) string {
|
||||
io := s.injector()
|
||||
if io == nil {
|
||||
return ""
|
||||
}
|
||||
return io.InjectInputSync(source, channel, text)
|
||||
return s.InjectInputSyncOpts(source, channel, text, InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectInputMedia 注入带媒体内容块(image_url/audio_url)的输入。
|
||||
// blocks 会落进媒体存储被记忆引用捕获,同时作为当前轮 content 数组
|
||||
// 发给 LLM,让模型在「本轮」就看到图/听到音频——区别于 SetToolBlocks
|
||||
// 的「下一轮 tool message」语义。
|
||||
// 等价于 InjectInputMediaOpts(..., InjectOptions{})。
|
||||
func (s *PluginSDK) InjectInputMedia(source, channel, text string, blocks []ContentBlock) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectInputMedia(source, channel, text, blocks)
|
||||
}
|
||||
s.InjectInputMediaOpts(source, channel, text, blocks, InjectOptions{})
|
||||
}
|
||||
|
||||
// InjectInputMediaSync 注入带媒体内容块的输入并同步等待 agent 回复。
|
||||
// 等价于 InjectInputMediaSyncOpts(..., InjectOptions{})。
|
||||
func (s *PluginSDK) InjectInputMediaSync(source, channel, text string, blocks []ContentBlock) string {
|
||||
return s.InjectInputMediaSyncOpts(source, channel, text, blocks, InjectOptions{})
|
||||
}
|
||||
|
||||
// ---- 带 InjectOptions 的注入(声明记忆/裁剪行为)----
|
||||
|
||||
// InjectTextOpts 注入文本到 agent,并在这一次注入上声明记忆与裁剪行为。
|
||||
func (s *PluginSDK) InjectTextOpts(source, channel, text string, opts InjectOptions) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectTextOpts(source, channel, text, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInterruptTextOpts 注入可抢占当前处理的中断文本。
|
||||
//
|
||||
// 中断也允许声明 ContextPolicyPrune:中断同样携带内容进入上下文,
|
||||
// 是否需要据此裁剪由调用方决定(默认不裁剪)。
|
||||
func (s *PluginSDK) InjectInterruptTextOpts(source, channel, text string, opts InjectOptions) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectInterruptTextOpts(source, channel, text, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputSyncOpts 注入输入并同步等待回复,同时在这次注入上声明记忆/裁剪行为。
|
||||
func (s *PluginSDK) InjectInputSyncOpts(source, channel, text string, opts InjectOptions) string {
|
||||
io := s.injector()
|
||||
if io == nil {
|
||||
return ""
|
||||
}
|
||||
return io.InjectInputMediaSync(source, channel, text, blocks)
|
||||
return io.InjectInputSyncOpts(source, channel, text, opts)
|
||||
}
|
||||
|
||||
// InjectInputMediaOpts 注入带媒体块的输入,并声明记忆/裁剪行为。
|
||||
func (s *PluginSDK) InjectInputMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectInputMediaOpts(source, channel, text, blocks, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInputMediaSyncOpts 注入带媒体块的输入并同步等待回复,同时声明记忆/裁剪行为。
|
||||
func (s *PluginSDK) InjectInputMediaSyncOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) string {
|
||||
io := s.injector()
|
||||
if io == nil {
|
||||
return ""
|
||||
}
|
||||
return io.InjectInputMediaSyncOpts(source, channel, text, blocks, opts)
|
||||
}
|
||||
|
||||
// InjectInterruptMediaOpts 注入带媒体块的中断,并声明记忆/裁剪行为。
|
||||
func (s *PluginSDK) InjectInterruptMediaOpts(source, channel, text string, blocks []ContentBlock, opts InjectOptions) {
|
||||
if io := s.injector(); io != nil {
|
||||
io.InjectInterruptMediaOpts(source, channel, text, blocks, opts)
|
||||
}
|
||||
}
|
||||
|
||||
// InjectInterruptMedia 注入带媒体内容块的中断,可抢占当前 LLM 处理。
|
||||
|
||||
47
third_party/homeagent-sdk/sdk/stress_test.go
vendored
47
third_party/homeagent-sdk/sdk/stress_test.go
vendored
@ -43,6 +43,7 @@ type injectCall struct {
|
||||
channel string
|
||||
text string
|
||||
blocks []ContentBlock
|
||||
opts InjectOptions // 调用点声明的记忆/裁剪行为
|
||||
}
|
||||
|
||||
func (r *recordingInjector) record(c injectCall) {
|
||||
@ -93,6 +94,41 @@ func (r *recordingInjector) InjectInterruptMedia(s, c, t string, b []ContentBloc
|
||||
r.record(injectCall{kind: "interruptMedia", source: s, channel: c, text: t, blocks: b})
|
||||
}
|
||||
|
||||
// ---- 带 InjectOptions 的注入:记录 opts 以便测试断言标志位确实传到了内核 ----
|
||||
|
||||
func (r *recordingInjector) InjectTextOpts(s, c, t string, o InjectOptions) {
|
||||
r.nText.Add(1)
|
||||
r.record(injectCall{kind: "textOpts", source: s, channel: c, text: t, opts: o})
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectInterruptTextOpts(s, c, t string, o InjectOptions) {
|
||||
r.nInterrupt.Add(1)
|
||||
r.record(injectCall{kind: "interruptTextOpts", source: s, channel: c, text: t, opts: o})
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectInputSyncOpts(s, c, t string, o InjectOptions) string {
|
||||
r.nSync.Add(1)
|
||||
r.record(injectCall{kind: "syncOpts", source: s, channel: c, text: t, opts: o})
|
||||
return "reply:" + t
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectInputMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
|
||||
r.nMedia.Add(1)
|
||||
r.record(injectCall{kind: "mediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectInputMediaSyncOpts(s, c, t string, b []ContentBlock, o InjectOptions) string {
|
||||
r.nMedia.Add(1)
|
||||
r.nSync.Add(1)
|
||||
r.record(injectCall{kind: "mediaSyncOpts", source: s, channel: c, text: t, blocks: b, opts: o})
|
||||
return "reply:" + t
|
||||
}
|
||||
|
||||
func (r *recordingInjector) InjectInterruptMediaOpts(s, c, t string, b []ContentBlock, o InjectOptions) {
|
||||
r.nMedia.Add(1)
|
||||
r.record(injectCall{kind: "interruptMediaOpts", source: s, channel: c, text: t, blocks: b, opts: o})
|
||||
}
|
||||
|
||||
func (r *recordingInjector) snapshot() []injectCall {
|
||||
r.mu.Lock()
|
||||
defer r.mu.Unlock()
|
||||
@ -463,11 +499,10 @@ func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
|
||||
data[i] = byte(i * 7 % 256)
|
||||
}
|
||||
att := MediaAttachment{
|
||||
Digest: strings.Repeat("a", 64),
|
||||
MIME: "image/png",
|
||||
Data: data,
|
||||
Name: "图片-名字 with space & 符号.png",
|
||||
Description: "一张紫蓝红三色带图,含 emoji 🎨 与换行\n第二行",
|
||||
Digest: strings.Repeat("a", 64),
|
||||
MIME: "image/png",
|
||||
Data: data,
|
||||
Name: "图片-名字 with space & 符号.png",
|
||||
}
|
||||
b, err := json.Marshal(att)
|
||||
if err != nil {
|
||||
@ -485,7 +520,7 @@ func TestStress_MediaTypesJSONRoundTripAtScale(t *testing.T) {
|
||||
t.Fatalf("size=%d 第 %d 字节损坏: %02x != %02x", n, i, back.Data[i], data[i])
|
||||
}
|
||||
}
|
||||
if back.Name != att.Name || back.Description != att.Description || back.MIME != att.MIME || back.Digest != att.Digest {
|
||||
if back.Name != att.Name || back.MIME != att.MIME || back.Digest != att.Digest {
|
||||
t.Fatalf("size=%d 元数据往返不一致: %+v", n, back)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user