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:
JianFeeeee
2026-09-11 20:31:50 +08:00
parent a37bc7333e
commit d1959cbe80
39 changed files with 611001 additions and 204 deletions

View File

@ -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
}

View 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)
}
}

View File

@ -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{

View 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)
}
}

View File

@ -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"),
}

View 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
}

View 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)
}
}

File diff suppressed because one or more lines are too long

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

File diff suppressed because it is too large Load Diff

View File

@ -0,0 +1,4 @@
云计算
韩玉鉴赏
蓝翔 nz
区块链 10 nz

View File

@ -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 返回完整的版本字符串。

View 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)
}
}
}

View File

@ -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 只声明其存在,实际执行回到插件进程。

View File

@ -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)

View File

@ -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)
}

View File

@ -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 }

View File

@ -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)

View File

@ -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) {

View File

@ -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()

View File

@ -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()

View File

@ -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)
}

View File

@ -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

View File

@ -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)
}
}