From 392f391f68f6b54968ebaecb082efaa2242efeae Mon Sep 17 00:00:00 2001 From: JianFeeeee Date: Sat, 15 Aug 2026 15:52:10 +0800 Subject: [PATCH] =?UTF-8?q?v0.9.0:=20C=20ABI=20v2=20stage=20=E5=86=99?= =?UTF-8?q?=E5=9B=9E=20+=20ABI=20=E7=89=88=E6=9C=AC=E5=AF=B9=E9=BD=90?= =?UTF-8?q?=E6=A0=B8=E5=BF=83=E7=89=88=E6=9C=AC=E5=8F=B7?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - meta: ABI 标识版本改为字符串 semver(ABIVersion=CoreVersion="0.9.0"), C 层协商用派生整数 CABINum=900(major*100+minor),不再用独立数字编码 - plugindev 模板: invoke_stage 增加 result out 参数(stage 写回), 插件在 OnInput/AfterToolcall/PostAction 修改 StageContext 后回传内核 - cmd_init: CABIVersion 改用 CABINum - example/sanitizer: 增强为全链路清洗(坏 UTF-8/U+FFFD/ANSI 转义), 挂载 OnInput/AfterToolcall/PostAction 三阶段(依赖 stage 写回能力) --- example/sanitizer/plugin.go | 136 +++++++++++++++++++++++++--- example/sanitizer/sanitizer_test.go | 27 +++++- meta/meta.go | 22 ++++- tools/plugindev/cmd_init.go | 2 +- tools/plugindev/templates.go | 86 +++++++++++++----- 5 files changed, 228 insertions(+), 45 deletions(-) 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); }