v0.9.0: C ABI v2 stage 写回 + ABI 版本对齐核心版本号

- 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 写回能力)
This commit is contained in:
JianFeeeee
2026-08-15 15:52:10 +08:00
parent cca9fdce9c
commit 392f391f68
5 changed files with 228 additions and 45 deletions

View File

@ -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)<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*```")
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`)
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{}
@ -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 [ <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
}

View File

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

View File

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

View File

@ -113,7 +113,7 @@ func cmdInit(args []string) {
Targets: targets,
},
IsLua: isLua,
CABIVersion: meta.ABIVersion,
CABIVersion: meta.CABINum,
CABIHeader: tmplCABIHeader,
}

View File

@ -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 <stdlib.h>
#include <string.h>
#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); }