mirror of
https://gitcode.com/JianFeeeee/homeagent-sdk.git
synced 2026-10-15 12:15:11 +00:00
SDK 仓已迁到 GitHub 作为权威源,go module 路径同步: `gitcode.com/JianFeeeee/homeagent-sdk` → `github.com/JianFeeeee/homeagentsdk` ## 范围 74 个文件:71 个 Go 源码 + go.mod/go.sum + hmapdev 模板 + CI 与打包脚本。 主仓(HomeAgent)侧对应改动是 6d38a164。本仓这些文件当时**不在工作树上** (见下),所以那次的批量替换扫不到它们——这里是补偿。 ## 为何这批文件当时不在工作树上(事故与恢复) 10-08 07:58 主仓做 checkout/cherry-pick 序列时,把本仓工作树的 20 个文件 误清理了(`example/*/plugin.go` + `plg.json`,10 个示例)。 根因是**结构误判**:本仓是「一目录两仓库」结构下主仓 `third_party/` 里的 **独立 git 仓库**,而主仓 .gitignore 忽略了 `example/`,于是它在主仓 `git status` 里不可见 —— 看着像「不受跟踪的构建产物」,实际是本仓受跟踪的源码。 已全部恢复(`git checkout -- <D 文件>`,只恢复删除、不动迁移改动), 20 个文件与 HEAD 逐字节一致。 **破坏的连锁后果**:恢复出来的 `plugin.go` 仍写着旧 module 路径, 因为迁移时它们已不在工作树上。所以这次补偿是必须的,不是可选优化。 ## 保留 gitcode 引用 以下位置的 `gitcode` **有意保留**,不要"顺手清掉": - README.md / docs 里「二进制实际分发地址目前是 gitcode」的说明 - mkdocs.yml 的 gitcode 镜像链接(国内直连更快) - `tools/hmapdev/templates.go` 里「SDK 已从 gitcode 迁到 GitHub」的注释 gitcode 仍是**只读镜像**(源码同步;release 附件暂时仍在那边)。 ## 验证 - 本仓根 module:`go build ./...` + `go test ./...` 全通过 - 全部 20 个 example:`go vet` 通过(补偿前 10 个因旧 module 路径失败) - example/qq:`go test` 通过,且能从源码 build 出可用 plugin 产物
218 lines
6.7 KiB
Go
218 lines
6.7 KiB
Go
// Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin)。
|
||
// 目标:在 Agent 全链路清洗文本,防止乱码(坏 UTF-8 / U+FFFD / ANSI 转义)污染上下文并被 LLM 复读,
|
||
// 同时保留原有"工具调用残留(思维泄漏)"清理。
|
||
//
|
||
// 挂载阶段:
|
||
// - StageOnInput : 清洗用户输入(RawMessage)
|
||
// - StageAfterToolcall : 清洗工具执行结果(ToolResults),坏字节不进 LLM 上下文
|
||
// - StagePostAction : 清洗 LLM 输出(LLMText),保留原有思维泄漏清理
|
||
//
|
||
// 依赖 ABI v2 的 stage 写回能力:插件对 StageContext 的修改会同步回内核。
|
||
//
|
||
// 编译:
|
||
//
|
||
// go build -buildmode=plugin -o sanitizer.so .
|
||
//
|
||
// 安装到 HomeAgent 插件目录(如 plugins/sanitizer/plugin.so),
|
||
// HomeAgent 自动通过 tryLoadSO 加载。
|
||
package main
|
||
|
||
import (
|
||
"log"
|
||
"regexp"
|
||
"strings"
|
||
"unicode/utf8"
|
||
|
||
"github.com/JianFeeeee/homeagentsdk/sdk"
|
||
)
|
||
|
||
var (
|
||
toolCallTagRE = regexp.MustCompile(`(?s)<tool_call[^>]*>.*?</tool_call>`)
|
||
invokeTagRE = regexp.MustCompile(`(?s)<invoke[^>]*>.*?</invoke>`)
|
||
toolTagRE = regexp.MustCompile(`(?s)<tool[^>]*>.*?</tool>`)
|
||
functionTagRE = regexp.MustCompile(`(?s)<function[^>]*>.*?</function>`)
|
||
toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool_call[^>]*>.*?</tool_call>\\s*```")
|
||
invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<invoke[^>]*>.*?</invoke>\\s*```")
|
||
toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*<tool[^>]*>.*?</tool>\\s*```")
|
||
chineseMarkerRE = regexp.MustCompile(`(?s)【tool_call】.*?【/tool_call】`)
|
||
multiNewlineRE = regexp.MustCompile(`\n{3,}`)
|
||
toolNameRE = regexp.MustCompile(`^(cmd_run|terminal_create|terminal_write|memory_|knowledge_|doc_|social_|output_set_channel|output_send|llm_|plgreload|spawn_child|child_result|describe_image|transcribe_audio|ocr_image|timer_set|plugin_install|plugin_remove|qq_|a2a_|mcp_|healthcheck|files_|web_)`)
|
||
placeholderRE = regexp.MustCompile(`(?i)\{\{\s*tool\s*[::][^}]*\}\}`)
|
||
atToolRE = regexp.MustCompile(`(?i)^@\s*tool\b`)
|
||
)
|
||
|
||
type Plugin struct{}
|
||
|
||
func (p *Plugin) Name() string { return "sanitizer" }
|
||
|
||
func (p *Plugin) Start(s *sdk.PluginSDK) error {
|
||
s.SetAutoRestart(true)
|
||
|
||
// 1) 输入清洗
|
||
s.RegisterStage(sdk.StageOnInput, func(ctx *sdk.StageContext) error {
|
||
ctx.Lock()
|
||
before := ctx.RawMessage
|
||
ctx.RawMessage = cleanText(ctx.RawMessage)
|
||
if before != ctx.RawMessage {
|
||
log.Printf("[sanitizer] StageOnInput: cleaned %d bytes", len(before)-len(ctx.RawMessage))
|
||
}
|
||
ctx.Unlock()
|
||
return nil
|
||
})
|
||
|
||
// 2) 工具结果清洗(坏字节/ANSI 不得进 LLM 上下文)
|
||
s.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
|
||
ctx.Lock()
|
||
defer ctx.Unlock()
|
||
for i, tr := range ctx.ToolResults {
|
||
if s, ok := tr.Result.(string); ok {
|
||
clean := cleanText(s)
|
||
if clean != s {
|
||
ctx.ToolResults[i].Result = clean
|
||
log.Printf("[sanitizer] StageAfterToolcall: tool=%s cleaned %d bytes", tr.Name, len(s)-len(clean))
|
||
}
|
||
}
|
||
}
|
||
return nil
|
||
})
|
||
|
||
// 3) LLM 输出清洗(保留原有思维泄漏清理 + 新增乱码清洗)
|
||
s.RegisterStage(sdk.StagePostAction, func(ctx *sdk.StageContext) error {
|
||
ctx.Lock()
|
||
before := len(ctx.LLMText)
|
||
ctx.LLMText = cleanToolCallLeakage(ctx.LLMText)
|
||
ctx.LLMText = cleanText(ctx.LLMText)
|
||
after := len(ctx.LLMText)
|
||
ctx.Unlock()
|
||
if before != after {
|
||
log.Printf("[sanitizer] cleaned %d bytes (before=%d after=%d)", before-after, before, after)
|
||
}
|
||
return nil
|
||
})
|
||
log.Printf("[sanitizer] stage OnInput/AfterToolcall/PostAction registered")
|
||
return nil
|
||
}
|
||
|
||
func (p *Plugin) Stop() error { return nil }
|
||
|
||
func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, error) {
|
||
return &Plugin{}, nil
|
||
}
|
||
|
||
// cleanToolCallLeakage 清洗 LLM 输出中的工具调用残留(思维泄漏)。
|
||
func cleanToolCallLeakage(content string) string {
|
||
if content == "" {
|
||
return content
|
||
}
|
||
|
||
before := len(content)
|
||
|
||
content = toolCodeBlockRE.ReplaceAllString(content, "")
|
||
content = invokeCodeBlockRE.ReplaceAllString(content, "")
|
||
content = toolCodeBlockRE2.ReplaceAllString(content, "")
|
||
|
||
content = toolCallTagRE.ReplaceAllString(content, "")
|
||
content = invokeTagRE.ReplaceAllString(content, "")
|
||
content = toolTagRE.ReplaceAllString(content, "")
|
||
content = functionTagRE.ReplaceAllString(content, "")
|
||
|
||
content = chineseMarkerRE.ReplaceAllString(content, "")
|
||
|
||
lines := strings.Split(content, "\n")
|
||
var cleaned []string
|
||
for _, line := range lines {
|
||
trimmed := strings.TrimSpace(line)
|
||
if trimmed == "" {
|
||
cleaned = append(cleaned, line)
|
||
continue
|
||
}
|
||
if placeholderRE.MatchString(trimmed) || atToolRE.MatchString(trimmed) {
|
||
continue
|
||
}
|
||
if m := toolNameRE.FindStringIndex(trimmed); m != nil {
|
||
rest := trimmed[m[1]:]
|
||
if strings.HasPrefix(rest, "(") && strings.Contains(rest, ")") {
|
||
continue
|
||
}
|
||
}
|
||
cleaned = append(cleaned, line)
|
||
}
|
||
content = strings.Join(cleaned, "\n")
|
||
|
||
content = multiNewlineRE.ReplaceAllString(content, "\n\n")
|
||
content = strings.TrimSpace(content)
|
||
|
||
if len(content) != before {
|
||
log.Printf("[sanitizer] cleanToolCallLeakage: %d bytes removed", before-len(content))
|
||
}
|
||
return content
|
||
}
|
||
|
||
// cleanText 清洗可能污染 LLM 上下文/输出的文本:
|
||
// 1. 剥离 ANSI 转义序列(\x1b[...m 等,源自终端输出)
|
||
// 2. 剔除无效 UTF-8 字节(strings.ToValidUTF8 语义)与已解码的 U+FFFD 替换符,
|
||
// 避免模型复读坏字节/替换符造成乱码(把坏段落整体丢弃比留残字更干净)
|
||
func cleanText(s string) string {
|
||
if s == "" {
|
||
return s
|
||
}
|
||
// 先剥离 ANSI 转义:ESC [ 参数 m / ESC ] 标题 / 其他 CSI 序列
|
||
if strings.ContainsRune(s, 0x1b) {
|
||
var sb strings.Builder
|
||
sb.Grow(len(s))
|
||
i := 0
|
||
for i < len(s) {
|
||
c := s[i]
|
||
if c == 0x1b {
|
||
// 跳过完整转义序列
|
||
j := i + 1
|
||
if j < len(s) {
|
||
switch s[j] {
|
||
case '[': // CSI: ESC [ <params> <letter>
|
||
j++
|
||
for j < len(s) && !(s[j] >= 0x40 && s[j] <= 0x7e) {
|
||
j++
|
||
}
|
||
if j < len(s) {
|
||
j++
|
||
}
|
||
i = j
|
||
continue
|
||
case ']': // OSC: ESC ] ... BEL / ST
|
||
i = j + 1
|
||
for i < len(s) && s[i] != 0x07 {
|
||
i++
|
||
}
|
||
i++ // skip BEL
|
||
continue
|
||
default: // 单字符转义(ESC c ESC 7 等)
|
||
i = j + 1
|
||
continue
|
||
}
|
||
}
|
||
i++
|
||
continue
|
||
}
|
||
sb.WriteByte(c)
|
||
i++
|
||
}
|
||
s = sb.String()
|
||
}
|
||
|
||
// 剔除无效 UTF-8 与 U+FFFD 替换符
|
||
if !utf8.ValidString(s) {
|
||
s = strings.ToValidUTF8(s, "")
|
||
}
|
||
if strings.ContainsRune(s, utf8.RuneError) {
|
||
// 连 U+FFFD 也不留给模型复述
|
||
var b strings.Builder
|
||
b.Grow(len(s))
|
||
for _, r := range s {
|
||
if r != utf8.RuneError {
|
||
b.WriteRune(r)
|
||
}
|
||
}
|
||
s = b.String()
|
||
}
|
||
return s
|
||
} |