mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-04 00:03:59 +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:
@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user