diff --git a/example/sanitizer/plugin.go b/example/sanitizer/plugin.go
index 000323b..666f705 100644
--- a/example/sanitizer/plugin.go
+++ b/example/sanitizer/plugin.go
@@ -1,5 +1,13 @@
// Package main 是一个外部插件示例(编译为 .so 通过 -buildmode=plugin)。
-// 在 StagePostAction 阶段清洗 LLM 输出中的工具调用残留(思维泄漏)。
+// 目标:在 Agent 全链路清洗文本,防止乱码(坏 UTF-8 / U+FFFD / ANSI 转义)污染上下文并被 LLM 复读,
+// 同时保留原有"工具调用残留(思维泄漏)"清理。
+//
+// 挂载阶段:
+// - StageOnInput : 清洗用户输入(RawMessage)
+// - StageAfterToolcall : 清洗工具执行结果(ToolResults),坏字节不进 LLM 上下文
+// - StagePostAction : 清洗 LLM 输出(LLMText),保留原有思维泄漏清理
+//
+// 依赖 ABI v2 的 stage 写回能力:插件对 StageContext 的修改会同步回内核。
//
// 编译:
//
@@ -13,23 +21,24 @@ import (
"log"
"regexp"
"strings"
+ "unicode/utf8"
"gitcode.com/JianFeeeee/homeagent-sdk/sdk"
)
var (
- toolCallTagRE = regexp.MustCompile(`(?s)]*>.*?`)
- invokeTagRE = regexp.MustCompile(`(?s)]*>.*?`)
- toolTagRE = regexp.MustCompile(`(?s)]*>.*?`)
- functionTagRE = regexp.MustCompile(`(?s)]*>.*?`)
- toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*]*>.*?\\s*```")
+ toolCallTagRE = regexp.MustCompile(`(?s)]*>.*?`)
+ invokeTagRE = regexp.MustCompile(`(?s)]*>.*?`)
+ toolTagRE = regexp.MustCompile(`(?s)]*>.*?`)
+ functionTagRE = regexp.MustCompile(`(?s)]*>.*?`)
+ toolCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*]*>.*?\\s*```")
invokeCodeBlockRE = regexp.MustCompile("(?s)```(?:xml|json)?\\s*]*>.*?\\s*```")
- toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*]*>.*?\\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`)
+ toolCodeBlockRE2 = regexp.MustCompile("(?s)```(?:xml|json)?\\s*]*>.*?\\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{}
@@ -38,10 +47,41 @@ 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 {
@@ -49,7 +89,7 @@ func (p *Plugin) Start(s *sdk.PluginSDK) error {
}
return nil
})
- log.Printf("[sanitizer] stage PostAction registered")
+ log.Printf("[sanitizer] stage OnInput/AfterToolcall/PostAction registered")
return nil
}
@@ -59,6 +99,7 @@ func NewPluginFactory(name string, config map[string]interface{}) (sdk.Plugin, e
return &Plugin{}, nil
}
+// cleanToolCallLeakage 清洗 LLM 输出中的工具调用残留(思维泄漏)。
func cleanToolCallLeakage(content string) string {
if content == "" {
return content
@@ -106,3 +147,72 @@ func cleanToolCallLeakage(content string) string {
}
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 [
+ 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
+}
\ No newline at end of file
diff --git a/example/sanitizer/sanitizer_test.go b/example/sanitizer/sanitizer_test.go
index e74c935..7f2e682 100644
--- a/example/sanitizer/sanitizer_test.go
+++ b/example/sanitizer/sanitizer_test.go
@@ -2,6 +2,31 @@ package main
import "testing"
+func TestCleanText(t *testing.T) {
+ tests := []struct {
+ name, input, want string
+ }{
+ {"empty", "", ""},
+ {"clean", "你好世界 hello", "你好世界 hello"},
+ {"invalid_utf8", "a\xff\xfe b", "a b"},
+ {"ufffd", "有乱码\ufffd字符", "有乱码字符"},
+ {"multiple_ufffd", "a\ufffd\ufffdb\ufffdc", "abc"},
+ {"ansi_color", "\x1b[31m红色\x1b[0m结束", "红色结束"},
+ {"ansi_cursor", "a\x1b[2K\r\nb", "a\r\nb"},
+ {"ansi_osc", "\x1b]0;title\x07文本", "文本"},
+ {"an_and_ufffd", "\x1b[31m\ufffd中文\x1b[0m", "中文"},
+ {"emoji_kept", "颜文字(・ω・´)和🍎", "颜文字(・ω・´)和🍎"},
+ }
+ for _, tt := range tests {
+ t.Run(tt.name, func(t *testing.T) {
+ got := cleanText(tt.input)
+ if got != tt.want {
+ t.Errorf("got %q, want %q", got, tt.want)
+ }
+ })
+ }
+}
+
func TestCleanToolCallLeakage(t *testing.T) {
tests := []struct {
name, input, want string
@@ -28,4 +53,4 @@ func TestCleanToolCallLeakage(t *testing.T) {
}
})
}
-}
+}
\ No newline at end of file
diff --git a/meta/meta.go b/meta/meta.go
index d683eca..6025258 100644
--- a/meta/meta.go
+++ b/meta/meta.go
@@ -6,7 +6,7 @@ package meta
var (
// Version 是 HomeAgent SDK 版本号。
// 通过 `-ldflags="-X gitcode.com/JianFeeeee/homeagent-sdk/meta.Version=vX.Y.Z"` 注入。
- Version = "0.8.0"
+ Version = "0.9.0"
// Commit 是构建时的 Git commit hash。
Commit = "unknown"
@@ -21,7 +21,7 @@ var (
CoreModule = "gitcode.com/JianFeeeee/HomeAgent"
// CoreVersion 是此 SDK 所兼容的最低核心版本。
- CoreVersion = "0.8.0"
+ CoreVersion = "0.9.0"
)
// FullVersion 返回完整的版本字符串。
@@ -30,11 +30,23 @@ func FullVersion() string {
}
// ---- ABI 版本(与核心仓 internal/meta/meta.go 同步) ----
-// 修改时需确保核心仓与 SDK 仓的值一致。
+// ABI 标识版本直接取内核版本号字符串(semver),与核心 Version 保持一致,不使用独立数字编码。
+// 协商层(C 结构体 int version 字段)使用 CABINum:由版本字符串派生的整数(major*100 + minor)。
+// 映射:v0.8.x → CABINum=800;v0.9.x → CABINum=900(invoke_stage 写回)。
+// 小版本(patch)演进不影响 ABI,CABINum 不变。version_min 保证旧 ABI 插件仍可加载。
+
+var (
+ // ABIVersion 是 ABI 标识版本(字符串 semver,与 SDK CoreVersion 对齐)。
+ ABIVersion = CoreVersion
+ // ABIVersionMin 是兼容的最低 ABI 标识版本。
+ ABIVersionMin = "0.8.0"
+)
const (
- ABIVersion = 1
- ABIVersionMin = 1
+ // CABINum 是 C 层协商用的整数版本(major*100 + minor),随 ABIVersion 派生。
+ CABINum = 900
+ // CABINumMin 是 C 层兼容的最低整数版本。
+ CABINumMin = 800
)
// ---- Dispatch Method IDs(与核心仓 internal/meta/meta.go 同步) ----
diff --git a/tools/plugindev/cmd_init.go b/tools/plugindev/cmd_init.go
index 6fbae64..921b97b 100644
--- a/tools/plugindev/cmd_init.go
+++ b/tools/plugindev/cmd_init.go
@@ -113,7 +113,7 @@ func cmdInit(args []string) {
Targets: targets,
},
IsLua: isLua,
- CABIVersion: meta.ABIVersion,
+ CABIVersion: meta.CABINum,
CABIHeader: tmplCABIHeader,
}
diff --git a/tools/plugindev/templates.go b/tools/plugindev/templates.go
index b6d667a..8c7920f 100644
--- a/tools/plugindev/templates.go
+++ b/tools/plugindev/templates.go
@@ -330,8 +330,8 @@ func main() {}
const tmplCABIHeader = `
#ifndef HOMEAGENT_CABI_H
#define HOMEAGENT_CABI_H
-// HOMEAGENT_ABI_VERSION 与 sdk/meta/meta.go ABIVersion 同步
-#define HOMEAGENT_ABI_VERSION 1
+// HOMEAGENT_ABI_VERSION 与 sdk/meta/meta.go CABINum 同步(major*100+minor,v0.9.x→900)
+#define HOMEAGENT_ABI_VERSION 900
#ifdef __cplusplus
extern "C" {
#endif
@@ -343,7 +343,7 @@ typedef struct {
int (*start_plugin)(void*, int, char**);
int (*stop_plugin)(char**);
int (*invoke_tool)(char*, char*, char**, char**);
- int (*invoke_stage)(char*, char*, char**);
+ int (*invoke_stage)(char*, char*, char**, char**);
int (*invoke_output)(char*, char*, char*, char**);
void (*free_string)(char*);
} PluginAPI;
@@ -684,8 +684,53 @@ func go_invoke_tool(name *C.char, argsJSON *C.char, resultOut **C.char, errorOut
return 0
}
+// fillStageContext 将内核传来的 ctx JSON 填充到插件侧 StageContext。
+func fillStageContext(sc *sdk.StageContext, ctxJSON string) {
+ var m map[string]interface{}
+ if err := json.Unmarshal([]byte(ctxJSON), &m); err != nil {
+ return
+ }
+ if v, _ := m["raw_message"].(string); v != "" { sc.RawMessage = v }
+ if v, _ := m["user_id"].(string); v != "" { sc.UserID = v }
+ if v, _ := m["group_id"].(string); v != "" { sc.GroupID = v }
+ if v, _ := m["phase"].(string); v != "" { sc.Phase = sdk.Stage(v) }
+ if v, _ := m["llm_text"].(string); v != "" { sc.LLMText = v }
+ if v, _ := m["final_text"].(string); v != "" { sc.FinalText = v }
+ if v, _ := m["no_memory"].(bool); v { sc.NoMemory = true }
+ if v, _ := m["response"].(string); v != "" { sc.Response = &v }
+ if v, _ := m["tool_calls"].([]interface{}); len(v) > 0 {
+ b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolCalls)
+ }
+ if v, _ := m["tool_results"].([]interface{}); len(v) > 0 {
+ b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolResults)
+ }
+}
+
+// stageContextWritable 提取插件可写且内核会同步回去的字段。
+func stageContextWritable(sc *sdk.StageContext) map[string]interface{} {
+ m := map[string]interface{}{
+ "raw_message": sc.RawMessage,
+ "user_id": sc.UserID,
+ "group_id": sc.GroupID,
+ "phase": string(sc.Phase),
+ "llm_text": sc.LLMText,
+ "final_text": sc.FinalText,
+ "no_memory": sc.NoMemory,
+ }
+ if sc.Response != nil {
+ m["response"] = *sc.Response
+ }
+ if len(sc.ToolCalls) > 0 {
+ m["tool_calls"] = sc.ToolCalls
+ }
+ if len(sc.ToolResults) > 0 {
+ m["tool_results"] = sc.ToolResults
+ }
+ return m
+}
+
//export go_invoke_stage
-func go_invoke_stage(stage *C.char, ctxJSON *C.char, errorOut **C.char) C.int {
+func go_invoke_stage(stage *C.char, ctxJSON *C.char, resultOut **C.char, errorOut **C.char) C.int {
goStage := C.GoString(stage)
handlerMu.RLock()
h, ok := stageHandlers[goStage]
@@ -693,25 +738,15 @@ func go_invoke_stage(stage *C.char, ctxJSON *C.char, errorOut **C.char) C.int {
if !ok { return 0 }
sc := &sdk.StageContext{}
if ctxJSON != nil {
- var m map[string]interface{}
- if err := json.Unmarshal([]byte(C.GoString(ctxJSON)), &m); err == nil {
- if v, _ := m["raw_message"].(string); v != "" { sc.RawMessage = v }
- if v, _ := m["user_id"].(string); v != "" { sc.UserID = v }
- if v, _ := m["group_id"].(string); v != "" { sc.GroupID = v }
- if v, _ := m["phase"].(string); v != "" { sc.Phase = sdk.Stage(v) }
- if v, _ := m["llm_text"].(string); v != "" { sc.LLMText = v }
- if v, _ := m["final_text"].(string); v != "" { sc.FinalText = v }
- if v, _ := m["no_memory"].(bool); v { sc.NoMemory = true }
- if v, _ := m["response"].(string); v != "" { sc.Response = &v }
- if v, _ := m["tool_calls"].([]interface{}); len(v) > 0 {
- b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolCalls)
- }
- if v, _ := m["tool_results"].([]interface{}); len(v) > 0 {
- b, _ := json.Marshal(v); json.Unmarshal(b, &sc.ToolResults)
- }
- }
+ fillStageContext(sc, C.GoString(ctxJSON))
}
if err := h(sc); err != nil { *errorOut = C.CString(err.Error()); return 1 }
+ // ABI v2: 回传插件修改后的上下文(若调用方要求)
+ if resultOut != nil {
+ if b, err := json.Marshal(stageContextWritable(sc)); err == nil {
+ *resultOut = C.CString(string(b))
+ }
+ }
return 0
}
@@ -742,7 +777,8 @@ func main() {}
const tmplPluginInitC = `#include
#include
-#define HOMEAGENT_ABI_VERSION 1
+// HOMEAGENT_ABI_VERSION 与 sdk/meta/meta.go CABINum 同步(major*100+minor,v0.9.x→900)
+#define HOMEAGENT_ABI_VERSION 900
typedef struct {
int version; int version_min;
@@ -750,7 +786,7 @@ typedef struct {
int (*start_plugin)(void*, int, char**);
int (*stop_plugin)(char**);
int (*invoke_tool)(char*, char*, char**, char**);
- int (*invoke_stage)(char*, char*, char**);
+ int (*invoke_stage)(char*, char*, char**, char**);
int (*invoke_output)(char*, char*, char*, char**);
void (*free_string)(char*);
} PluginAPI;
@@ -765,7 +801,7 @@ extern int go_init_plugin(char*, char*, char**);
extern int go_start_plugin(void*, int, char**);
extern int go_stop_plugin(char**);
extern int go_invoke_tool(char*, char*, char**, char**);
-extern int go_invoke_stage(char*, char*, char**);
+extern int go_invoke_stage(char*, char*, char**, char**);
extern int go_invoke_output(char*, char*, char*, char**);
extern void go_free_string(char*);
@@ -773,7 +809,7 @@ int c_init_plugin(char* n, char* c, char** e) { return go_init_plugin(n, c, e);
int c_start_plugin(void* a, int v, char** e) { return go_start_plugin(a, v, e); }
int c_stop_plugin(char** e) { return go_stop_plugin(e); }
int c_invoke_tool(char* n, char* a, char** r, char** e) { return go_invoke_tool(n, a, r, e); }
-int c_invoke_stage(char* s, char* c, char** e) { return go_invoke_stage(s, c, e); }
+int c_invoke_stage(char* s, char* c, char** r, char** e) { return go_invoke_stage(s, c, r, e); }
int c_invoke_output(char* c, char* m, char* p, char** e) { return go_invoke_output(c, m, p, e); }
void c_free_string(char* p) { go_free_string(p); }