diff --git a/Makefile b/Makefile index 4f1a5d8..f5b66e3 100644 --- a/Makefile +++ b/Makefile @@ -1,5 +1,4 @@ -.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions - +.PHONY: all build build-plain build-cli build-gui clean install test run build-static build-linux-arm64 lint fmt sync-client-versions check-client-versions csrc csrc-test # HOMED_TAGS 默认带 onnxruntime:发行版**默认启用**本地向量空间(与 # deploy/packaging/build.sh 保持一致)。 # @@ -10,6 +9,12 @@ # (package-linux.sh 检查 `-tags=.*onnxruntime`)。即「本地随手 make build」 # 与「发行构建」不是同一个东西,部署时无从察觉。 # 需要极简构建时显式 HOMED_TAGS= 关掉。 +# +# 注意:内核 C 编解码层(internal/agent/api/ha_codec.c)**不靠 tag 开关**, +# 而是由 cgo 本身决定(`//go:build cgo` / `!cgo`)。原因:它是零依赖纯 C99 +# 源码内联编译,不需要任何外部库/工具链前提;而 homed 本就强制 cgo +# (sqlite3 + gojieba),所以 C 路径自然生效,无需额外开关。 +# 对比 onnxruntime:那个需要运行期的 libonnxruntime.so,所以必须显式 tag。 HOMED_TAGS ?= onnxruntime TAG_ARGS = $(if $(HOMED_TAGS),-tags $(HOMED_TAGS),) @@ -28,12 +33,48 @@ LDFLAGS = -X gitcode.com/JianFeeeee/HomeAgent/internal/meta.Version=$(VERSION) - all: build build-cli +# csrc:C 源码的**独立**产物(静态库 + 契约测试),供 C 侧复用(鸿蒙/嵌入式/C SDK)。 +# +# ⚠️ Go 构建**不依赖**它:internal/agent/api/ha_codec.{c,h} 是指向 csrc/ 的 +# **符号链接**,cgo 直接编这份源码,而不是链接预构建的 .a。 +# +# 为什么是「包内符号链接」而不是别的(历史教训 + 实测,勿回退): +# 1. 不能链静态库:.a 是构建产物、不入库,而发布脚本原先不产出它 +# ⇒「不入库 + 不生成」两头空(实测 cannot find csrc/build/libha_codec.a); +# 且交叉编译 linux/arm64 时宿主 x86-64 的 .a 被链进目标产物, +# 报 `file in wrong format`。 +# 2. 不能用 `#include "../../../csrc/src/ha_codec.c"`(包外相对包含): +# ★ Go 构建缓存**不跟踪包外被 #include 的 C 文件**,改了 C 源码但缓存命中时 +# 会静默沿用旧代码(实测:变异 C 源码后 go test 仍报 ok)。 +# 这对「逐步推进 C 化」是致命的——改动无效却无人察觉。 +# 3. 包内符号链接:文件在包目录内 ⇒ 缓存按内容哈希正确跟踪 +# (实测:改 csrc/ 源文件后 go test 立即判红); +# 同时只有一份权威源(csrc/),无副本漂移、无需同步目标。 +# +# csrc 目标本身只服务「C 侧独立使用 + ctest」,不参与 Go 构建链路。 +CSRC_DIR=csrc +CSRC_BUILD=$(CSRC_DIR)/build +CSRC_LIB=$(CSRC_BUILD)/libha_codec.a + +csrc: $(CSRC_LIB) + +$(CSRC_LIB): $(wildcard $(CSRC_DIR)/src/*.c) $(wildcard $(CSRC_DIR)/include/*.h) $(CSRC_DIR)/CMakeLists.txt + @cmake -S $(CSRC_DIR) -B $(CSRC_BUILD) -DBUILD_TESTS=ON >/dev/null + @cmake --build $(CSRC_BUILD) -j >/dev/null + @echo "Built: $(CSRC_LIB)(C 侧复用,不参与 Go 构建)" + +# csrc-test:C 侧契约测试(黄金对照的另一半,见 docs/zh/c-core/llm-orchestration-c.md §五) +csrc-test: csrc + @cd $(CSRC_BUILD) && ctest --output-on-failure + build: @mkdir -p $(BUILD_DIR) CGO_ENABLED=1 $(GO) build $(TAG_ARGS) -trimpath -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY) ./cmd/homed/ @echo "Built: $(BUILD_DIR)/$(BINARY) ($(VERSION), tags='$(HOMED_TAGS)')" @go version -m $(BUILD_DIR)/$(BINARY) | grep -q 'onnxruntime' \ || echo "WARN: 本次构建不含 onnxruntime,本地向量空间不可用(HOMED_TAGS= 显式关掉时才符合预期)" + @go version -m $(BUILD_DIR)/$(BINARY) | grep -q 'CGO_ENABLED=1' \ + || echo "WARN: 本次构建未启用 cgo,编解码走纯 Go 回退(不应发生)" build-cli: @mkdir -p $(BUILD_DIR) @@ -49,13 +90,36 @@ build-static: CGO_ENABLED=1 $(GO) build -tags netgo -installsuffix dynlink -ldflags '-extldflags "-static" $(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY)-static ./cmd/homed/ @echo "Built (static): $(BUILD_DIR)/$(BINARY)-static" +# build-linux-arm64:交叉编译 homed(真实发布目标之一)。 +# +# 两处必须显式给定,否则必然失败(都不是 C 化引入的,但都长期缺覆盖): +# 1. CC/CXX 交叉工具链。缺 CXX 时 cgo 回退到宿主 g++,而宿主编译器不认 +# aarch64 汇编,报 `gcc_arm64.S: no such instruction: 'stp x29,x30,[sp,'`。 +# deploy/packaging/build.sh:49 一直是对的,此处此前漏了。 +# 2. .syso 隔离。cmd/{homed,waiter}/*.syso 是 Windows COFF 资源对象, +# Go 会把同目录 .syso **无条件**链进任何目标;交叉到非 Windows 平台报 +# `file format not recognized`。build.sh 有 hide_syso_for_target,此处同样漏了。 +CC_ARM64 ?= aarch64-linux-gnu-gcc +CXX_ARM64 ?= aarch64-linux-gnu-g++ + build-linux-arm64: @mkdir -p $(BUILD_DIR) - GOOS=linux GOARCH=arm64 CGO_ENABLED=1 $(GO) build -installsuffix dynlink -ldflags '$(LDFLAGS)' -o $(BUILD_DIR)/$(BINARY)-arm64 ./cmd/homed/ - @echo "Built (arm64): $(BUILD_DIR)/$(BINARY)-arm64" + @for f in cmd/homed/*.syso cmd/waiter/*.syso; do \ + [ -f "$$f" ] || continue; \ + mv "$$f" "$$f.hidden"; \ + done; \ + trap 'for f in cmd/homed/*.syso.hidden cmd/waiter/*.syso.hidden; do \ + [ -f "$$f" ] || continue; mv "$$f" "$${f%.hidden}"; done' EXIT; \ + GOOS=linux GOARCH=arm64 CGO_ENABLED=1 CC=$(CC_ARM64) CXX=$(CXX_ARM64) \ + $(GO) build $(TAG_ARGS) -installsuffix dynlink -ldflags '$(LDFLAGS)' \ + -o $(BUILD_DIR)/$(BINARY)-arm64 ./cmd/homed/; \ + for f in cmd/homed/*.syso.hidden cmd/waiter/*.syso.hidden; do \ + [ -f "$$f" ] || continue; mv "$$f" "$${f%.hidden}"; done; \ + trap - EXIT + @echo "Built (arm64): $(BUILD_DIR)/$(BINARY)-arm64 ($$(file $(BUILD_DIR)/$(BINARY)-arm64 | sed 's/.*: //'))" clean: - rm -rf $(BUILD_DIR) $(BINARY) + rm -rf $(BUILD_DIR) $(BINARY) $(CSRC_BUILD) install: build -systemctl stop homeagent 2>/dev/null @@ -67,6 +131,21 @@ install: build test: $(GO) test ./... + @$(MAKE) csrc-test + @$(MAKE) check-codec-paths + +# check-codec-paths:钉死「C 实现与纯 Go 回退都在、且行为等价」。 +# +# 为何必须显式测两条:homed 走 cgo(C 路径),而 waiter 等 CGO-free 目标走回退。 +# 只测一条,另一条的破坏不会被发现;而两条路径的语义等价是 C 化的核心约束 +# (见 internal/agent/api/codec_golden_test.go)。 +.PHONY: check-codec-paths +check-codec-paths: + @echo "== 编解码双路径检查 ==" + @CGO_ENABLED=1 $(GO) test -count=1 ./internal/agent/api/ \ + && echo " cgo / C 实现路径: OK" + @CGO_ENABLED=0 $(GO) test -count=1 ./internal/agent/api/ \ + && echo " !cgo / 纯 Go 回退: OK" run: build ./$(BUILD_DIR)/$(BINARY) -data /tmp/homeagent diff --git a/csrc/CMakeLists.txt b/csrc/CMakeLists.txt new file mode 100644 index 0000000..8e6a6d3 --- /dev/null +++ b/csrc/CMakeLists.txt @@ -0,0 +1,57 @@ +cmake_minimum_required(VERSION 3.10) +project(ha_codec VERSION 0.1.0 LANGUAGES C) + +# ============================================================ +# ha_codec — HomeAgent 内核编解码层(C 实现) +# +# 零外部依赖,纯 C99。产出静态库供 homed 经 cgo 链接, +# 同时可独立用于其他端(鸿蒙 / 嵌入式 / C SDK)。 +# +# 使用方式: +# add_subdirectory(path/to/csrc) +# target_link_libraries(my_app ha_codec) +# target_include_directories(my_app PRIVATE ${HA_CODEC_INCLUDE_DIR}) +# ============================================================ + +option(BUILD_SHARED_LIBS "Build ha_codec as shared library" OFF) +option(BUILD_TESTS "Build ha_codec tests" OFF) + +set(HA_CODEC_SRC + src/ha_codec.c +) + +if(BUILD_SHARED_LIBS) + add_library(ha_codec SHARED ${HA_CODEC_SRC}) + if(WIN32) + set_target_properties(ha_codec PROPERTIES WINDOWS_EXPORT_ALL_SYMBOLS ON) + endif() +else() + add_library(ha_codec STATIC ${HA_CODEC_SRC}) +endif() + +set(HA_CODEC_INCLUDE ${CMAKE_CURRENT_SOURCE_DIR}/include) +target_include_directories(ha_codec PUBLIC ${HA_CODEC_INCLUDE}) + +# 不链接任何外部库 —— 保持与 ha_remotedevice 同一克制标准 +target_link_libraries(ha_codec PRIVATE) + +set(HA_CODEC_INCLUDE_DIR ${HA_CODEC_INCLUDE} CACHE INTERNAL "ha_codec include directories") + +install(TARGETS ha_codec + EXPORT ha_codec-targets + LIBRARY DESTINATION lib + ARCHIVE DESTINATION lib + RUNTIME DESTINATION bin + INCLUDES DESTINATION include +) +install(DIRECTORY include/ DESTINATION include) + +# ============================================================ +# 测试 +# ============================================================ +if(BUILD_TESTS) + add_executable(ha_codec_test test/test_ha_codec.c) + target_link_libraries(ha_codec_test PRIVATE ha_codec) + enable_testing() + add_test(NAME ha_codec_test COMMAND ha_codec_test) +endif() diff --git a/csrc/include/ha_codec.h b/csrc/include/ha_codec.h new file mode 100644 index 0000000..2358364 --- /dev/null +++ b/csrc/include/ha_codec.h @@ -0,0 +1,67 @@ +#ifndef HA_CODEC_H +#define HA_CODEC_H + +/* + * ha_codec — HomeAgent 内核编解码层(C 实现) + * + * ============================ 接口冻结声明 ============================ + * 本头文件是对外契约。函数签名、语义、返回值一经发布即为冻结接口, + * 修改必须走大版本流程(与 third_party/homeagent-sdk 同一冻结标准)。 + * + * 设计约束(见 docs/zh/c-core/llm-orchestration-c.md §四): + * 1. 只吃 const char* + 长度,出数值/JSON 串 + * 2. 不回调 Go、不传 Go 指针 + * 3. 不长期持有 malloc 内存;需要出参的用调用方缓冲区 + * 4. 无状态、纯函数、线程安全(不写全局可变状态) + * + * 当前覆盖:L1 协议编解码层中的纯计算部分(第一个最小切片)。 + */ + +#include + +#ifdef __cplusplus +extern "C" { +#endif + +/* ==================== 模型上下文窗口推断 ==================== */ + +/* 无法从模型名推断时的哨兵值(与 Go 侧一致)。 + * + * 为什么返回哨兵而不是直接给兜底值:调用方需要区分「真推断出了」与 + * 「推断不出、只能兜底」——后者要打一行日志(窗口被低估必须可见), + * 并提示部署方用 per-source context_window 显式声明。 + * 若 C 侧直接返回兜底值,调用方就永远分不清这两种情况。 */ +#define HA_CODEC_CONTEXT_WINDOW_UNKNOWN (-1) + +/* 由模型名推断最大上下文窗口(token 数);推断不出返回 + * HA_CODEC_CONTEXT_WINDOW_UNKNOWN。model 为 NULL 时同样返回 UNKNOWN。 + * + * model 为 UTF-8 字符串,匹配大小写不敏感。 + * 语义必须与 Go 侧 modelContextWindowPure 逐值一致(黄金对照测试钉死)。 */ +int ha_codec_model_context_window(const char *model); + +/* ==================== token 估算与截断 ==================== */ + +/* 粗略估算 token 数。 + * + * 规则(与 Go 侧 EstimateTokens 一致):中文 ~1.5 token/字、英文 ~0.3 token/字符, + * 保守取 max(1, runeCount * 2)。text 为 NULL 或空串返回 0。 + * + * 注意:按 UTF-8 **字符数**(rune)计,不是字节数。 */ +int ha_codec_estimate_tokens(const char *text); + +/* 截断字符串至不超过 maxTokens 估计值,返回写入 out 的字节数(不含结尾 NUL)。 + * + * 语义与 Go 侧 TruncateByTokens 一致:从开头保留 maxTokens/2 个字符。 + * maxTokens <= 0 或 text 为空时写入空串。 + * + * out 由调用方提供,容量须为 outCap(含结尾 NUL);函数保证 NUL 结尾、 + * 不越界写。返回值是实际写入的字节数(可能因 outCap 不足而短于完整截断结果)。 */ +size_t ha_codec_truncate_by_tokens(const char *text, int max_tokens, + char *out, size_t out_cap); + +#ifdef __cplusplus +} +#endif + +#endif /* HA_CODEC_H */ diff --git a/csrc/src/ha_codec.c b/csrc/src/ha_codec.c new file mode 100644 index 0000000..7d80181 --- /dev/null +++ b/csrc/src/ha_codec.c @@ -0,0 +1,191 @@ +/* + * ha_codec.c — HomeAgent 内核编解码层(C 实现) + * + * 第一个最小切片:模型窗口推断 + token 估算/截断。 + * 语义必须与 Go 侧实现逐值一致,由黄金对照测试钉死。 + */ + +#include "ha_codec.h" + +#include +#include +#include + +/* ---------------------------------------------------------------- */ +/* 小工具 */ +/* ---------------------------------------------------------------- */ + +/* 在 s 中查找子串 sub(子串已小写)。s 需已是小写。找不到返回 NULL。 */ +static const char *find_sub(const char *s, const char *sub) { + return strstr(s, sub); +} + +/* 分配一份小写副本。调用方负责 free。失败返回 NULL。 */ +static char *lower_dup(const char *s) { + if (s == NULL) { + return NULL; + } + size_t n = strlen(s); + char *p = (char *)malloc(n + 1); + if (p == NULL) { + return NULL; + } + for (size_t i = 0; i < n; i++) { + /* 只对 ASCII 做小写;UTF-8 多字节原样保留(与 Go strings.ToLower 对 + * 中文不改变结果一致——Go 会把非 ASCII 也处理,但模型名都是 ASCII)。 */ + unsigned char c = (unsigned char)s[i]; + p[i] = (char)((c < 0x80) ? tolower(c) : c); + } + p[n] = '\0'; + return p; +} + +/* ---------------------------------------------------------------- */ +/* 模型上下文窗口推断 */ +/* ---------------------------------------------------------------- */ + +int ha_codec_model_context_window(const char *model) { + if (model == NULL) { + return HA_CODEC_CONTEXT_WINDOW_UNKNOWN; + } + + char *m = lower_dup(model); + if (m == NULL) { + return HA_CODEC_CONTEXT_WINDOW_UNKNOWN; + } + + int result = HA_CODEC_CONTEXT_WINDOW_UNKNOWN; + + /* 顺序与 Go 侧 switch 分支**严格一致**:先匹配到的分支胜出。 + * 这不是「随便一组 if」,顺序错了就会给出不同窗口。 */ + if (find_sub(m, "deepseek-v4") || find_sub(m, "deepseek-v3")) { + result = 1048576; + } else if (find_sub(m, "deepseek-r1") || find_sub(m, "deepseek-chat")) { + result = 65536; + } else if (find_sub(m, "gpt-4") && + (find_sub(m, "turbo") || find_sub(m, "mini") || find_sub(m, "omni"))) { + result = 128000; + } else if (find_sub(m, "gpt-4")) { + result = 8192; + } else if (find_sub(m, "gpt-3.5")) { + result = 16384; + } else if (find_sub(m, "claude-3.5") || find_sub(m, "claude-3")) { + result = 200000; + } else if (find_sub(m, "claude")) { + result = 100000; + } else if (find_sub(m, "gemini-1.5") || find_sub(m, "gemini-2")) { + result = 1048576; + } else if (find_sub(m, "gemini")) { + result = 32768; + } else if (find_sub(m, "qwen")) { + result = 131072; + } else if (find_sub(m, "glm") || find_sub(m, "chatglm")) { + result = 131072; + } else if (find_sub(m, "llama-3")) { + result = 8192; + } else if (find_sub(m, "llama-2")) { + result = 4096; + } else if (find_sub(m, "mistral") || find_sub(m, "mixtral")) { + result = 32768; + } else if (find_sub(m, "yi-") || find_sub(m, "零一")) { + result = 200000; + } else if (find_sub(m, "moonshot") || find_sub(m, "kimi")) { + result = 131072; + } + + free(m); + return result; +} + +/* ---------------------------------------------------------------- */ +/* token 估算 */ +/* ---------------------------------------------------------------- */ + +/* 计 UTF-8 字符数(rune 数)并返回下一字符起点。 + * 非法字节按 1 字符前进(不吞字节),保证不会死循环。 */ +static size_t utf8_next(const char *s, size_t remaining) { + unsigned char c = (unsigned char)s[0]; + size_t len = 1; + if (c >= 0xF0 && remaining >= 4) { + len = 4; + } else if (c >= 0xE0 && remaining >= 3) { + len = 3; + } else if (c >= 0xC0 && remaining >= 2) { + len = 2; + } + return len; +} + +int ha_codec_estimate_tokens(const char *text) { + if (text == NULL || text[0] == '\0') { + return 0; + } + + size_t n = strlen(text); + size_t runes = 0; + size_t i = 0; + while (i < n) { + i += utf8_next(text + i, n - i); + runes++; + } + + /* 与 Go 侧一致:t = runeCount * 2;t < 1 时取 1。 + * runes > 0 时 t >= 2,故只需处理溢出与下限。 */ + if (runes > (size_t)0x3FFFFFFF) { /* 防 int 溢出 */ + return 0x7FFFFFFF; + } + int t = (int)(runes * 2); + if (t < 1) { + return 1; + } + return t; +} + +/* ---------------------------------------------------------------- */ +/* 按 token 截断 */ +/* ---------------------------------------------------------------- */ + +size_t ha_codec_truncate_by_tokens(const char *text, int max_tokens, + char *out, size_t out_cap) { + if (out == NULL || out_cap == 0) { + return 0; + } + out[0] = '\0'; + + if (max_tokens <= 0 || text == NULL || text[0] == '\0') { + return 0; + } + + size_t n = strlen(text); + + /* 先算 rune 数:与 Go 侧 len([]rune(s))*2 <= maxTokens 的短路一致 */ + size_t runes = 0; + size_t i = 0; + while (i < n) { + i += utf8_next(text + i, n - i); + runes++; + } + + /* 未超限:整体返回 */ + if (runes <= (size_t)0x3FFFFFFF && (int)(runes * 2) <= max_tokens) { + size_t copy = (n < out_cap - 1) ? n : (out_cap - 1); + memcpy(out, text, copy); + out[copy] = '\0'; + return copy; + } + + /* 保留 maxTokens/2 个字符(与 Go 一致:keep := maxTokens / 2,整数除法) */ + size_t keep = (size_t)(max_tokens / 2); + + size_t byte_end = 0; + size_t kept = 0; + while (kept < keep && byte_end < n) { + byte_end += utf8_next(text + byte_end, n - byte_end); + kept++; + } + + size_t copy = (byte_end < out_cap - 1) ? byte_end : (out_cap - 1); + memcpy(out, text, copy); + out[copy] = '\0'; + return copy; +} diff --git a/csrc/test/test_ha_codec.c b/csrc/test/test_ha_codec.c new file mode 100644 index 0000000..9c887a5 --- /dev/null +++ b/csrc/test/test_ha_codec.c @@ -0,0 +1,131 @@ +/* + * test_ha_codec.c — ha_codec C 侧契约测试 + * + * 编译运行(无 cmake 亦可): + * gcc -std=c99 -I../include ../src/ha_codec.c test_ha_codec.c -o test_ha_codec && ./test_ha_codec + * + * 这一层钉死 C 实现的语义;与 Go 的逐值一致由黄金对照测试负责(双保险)。 + */ + +#include "ha_codec.h" + +#include +#include + +static int g_fail = 0; +static int g_pass = 0; + +static void check_int(const char *what, int got, int want) { + if (got != want) { + printf(" [FAIL] %s: got %d, want %d\n", what, got, want); + g_fail++; + } else { + g_pass++; + } +} + +static void check_str(const char *what, const char *got, const char *want) { + if (strcmp(got, want) != 0) { + printf(" [FAIL] %s: got \"%s\", want \"%s\"\n", what, got, want); + g_fail++; + } else { + g_pass++; + } +} + +static void check_size(const char *what, size_t got, size_t want) { + if (got != want) { + printf(" [FAIL] %s: got %zu, want %zu\n", what, got, want); + g_fail++; + } else { + g_pass++; + } +} + +static void test_context_window(void) { + printf("model_context_window:\n"); + check_int("deepseek-v4.1-flash", + ha_codec_model_context_window("deepseek/deepseek-v4.1-flash"), 1048576); + check_int("deepseek-v4-flash", + ha_codec_model_context_window("deepseek-v4-flash"), 1048576); + check_int("deepseek-chat", + ha_codec_model_context_window("deepseek-chat"), 65536); + check_int("claude-opus-5", + ha_codec_model_context_window("claude-opus-5"), 100000); + check_int("gpt-4-turbo", + ha_codec_model_context_window("gpt-4-turbo"), 128000); + check_int("llama-3-70b", + ha_codec_model_context_window("llama-3-70b"), 8192); + check_int("AUTO (unknown)", + ha_codec_model_context_window("AUTO"), HA_CODEC_CONTEXT_WINDOW_UNKNOWN); + check_int("NULL (unknown)", + ha_codec_model_context_window(NULL), HA_CODEC_CONTEXT_WINDOW_UNKNOWN); + check_int("case-insensitive", + ha_codec_model_context_window("QWEN-MAX"), 131072); + check_int("moonshot", + ha_codec_model_context_window("moonshot-v1-128k"), 131072); + /* 分支顺序:gpt-4-turbo 必须先于裸 gpt-4 命中 */ + check_int("gpt-4-mini (branch order)", + ha_codec_model_context_window("gpt-4-mini"), 128000); + check_int("gpt-4 (bare)", + ha_codec_model_context_window("gpt-4"), 8192); + /* claude-3 必须先于裸 claude */ + check_int("claude-3-opus (branch order)", + ha_codec_model_context_window("claude-3-opus"), 200000); +} + +static void test_estimate_tokens(void) { + printf("estimate_tokens:\n"); + check_int("empty", ha_codec_estimate_tokens(""), 0); + check_int("NULL", ha_codec_estimate_tokens(NULL), 0); + /* "abc" = 3 rune * 2 = 6 */ + check_int("ascii abc", ha_codec_estimate_tokens("abc"), 6); + /* "你好" = 2 rune * 2 = 4(注意:不是字节数 6) */ + check_int("chinese 2 chars", ha_codec_estimate_tokens("你好"), 4); + /* 混合 "a你" = 2 rune * 2 = 4 */ + check_int("mixed", ha_codec_estimate_tokens("a你"), 4); + /* 4 字节 emoji:1 rune * 2 = 2 */ + check_int("emoji", ha_codec_estimate_tokens("\xF0\x9F\x98\x80"), 2); +} + +static void test_truncate(void) { + printf("truncate_by_tokens:\n"); + char buf[64]; + + /* max_tokens<=0 → 空 */ + ha_codec_truncate_by_tokens("hello", 0, buf, sizeof(buf)); + check_str("max_tokens=0", buf, ""); + + /* 未超限 → 原样返回 */ + size_t n = ha_codec_truncate_by_tokens("abc", 100, buf, sizeof(buf)); + check_str("no truncation", buf, "abc"); + check_size("no truncation len", n, 3); + + /* "abcdefghij" = 10 rune → 20 tokens;max=8 → keep=4 → "abcd" */ + n = ha_codec_truncate_by_tokens("abcdefghij", 8, buf, sizeof(buf)); + check_str("keep 4", buf, "abcd"); + check_size("keep 4 len", n, 4); + + /* 中文按 rune 截断,不切碎 UTF-8:"你好世界" 4 rune,max=4 → keep=2 → "你好" */ + n = ha_codec_truncate_by_tokens("你好世界", 4, buf, sizeof(buf)); + check_str("chinese keep 2", buf, "你好"); + check_size("chinese keep 2 len (bytes)", n, 6); + + /* 缓冲区不足:必须 NUL 结尾且不越界 */ + char tiny[4]; + n = ha_codec_truncate_by_tokens("abcdefghij", 100, tiny, sizeof(tiny)); + check_size("tiny buf len", n, 3); + check_str("tiny buf NUL-terminated", tiny, "abc"); + + /* out_cap=0 不写 */ + check_size("zero cap", ha_codec_truncate_by_tokens("abc", 100, tiny, 0), 0); +} + +int main(void) { + printf("=== ha_codec 契约测试 ===\n\n"); + test_context_window(); + test_estimate_tokens(); + test_truncate(); + printf("\n=== 结果: %d passed, %d failed ===\n", g_pass, g_fail); + return g_fail == 0 ? 0 : 1; +} diff --git a/docs/zh/c-core/llm-orchestration-c.md b/docs/zh/c-core/llm-orchestration-c.md new file mode 100644 index 0000000..1fb81dd --- /dev/null +++ b/docs/zh/c-core/llm-orchestration-c.md @@ -0,0 +1,287 @@ +# 内核 C 化 · 第一刀:LLM 编排层 + +> 分支:`feature/c-core`(从 `main` 拉出,遵循 `git-release-discipline`) +> 状态:**第一刀已落地并闭环**(2026-09-25)。L1 纯函数层的三个函数已在 C 侧 +> 实现,双路径(cgo / 纯 Go 回退)与黄金对照测试均已在仓库内跑通; +> 构建链已打通至 `linux/amd64` 与 `linux/arm64` 两个真实发布目标。 +> 本文保留设计与可行性论证,并在每节标注**落地后的实际情况**。 +> 本文只写经实测确认的结论;每个「可行」都附验证方式,每个「不可行」都附证据。 + +--- + +## 一、为什么要 C 化,以及为什么先动 LLM 编排 + +内核当前是**纯 Go 单进程**(`homed`),插件经子进程 + 共享内存与之通信。 +C 化的目标不是「换语言重写」,而是把**稳定、高频、无 GC 抖动敏感**的热路径 +下沉为可复用 C 库,让内核在保持 Go 编排能力的同时获得: + +- 可预测的延迟(无 GC STW 影响热路径) +- 可跨端复用(鸿蒙 / 嵌入式 / C SDK 侧同一份实现) +- 与既有 C 资产统一(见 §三) + +**为什么第一刀是 LLM 编排**:这一层是内核最核心的职责(`assets/docs/zh/OVERVIEW.md` +定义内核 = 「LLM 编排 + 记忆管理 + 知识检索」),且它**天然分层**—— +上层是有状态的调度/工具循环,下层是**无状态的协议编解码**。 +后者是纯字符串进、结构体出,最适合先下沉。 + +--- + +## 二、可行性核实结论(实测) + +### 2.1 工具链齐备 + +``` +cc/gcc/clang: /usr/bin/{cc,gcc,clang} +make/cmake: /usr/bin/{make,cmake} +go env: CC=gcc CGO_ENABLED=1 GOOS=linux GOARCH=amd64 +``` + +### 2.2 已有纯 C 先例,可直接复用 + +`third_party/homeagent-sdk/remotedevice/` 是一个**零外部依赖**的纯 C 库 +(1320 行),已含可复用组件: + +| 文件 | 行数 | 能力 | +|---|---|---| +| `src/ha_json.c` | 368 | DOM 风格 JSON 解析器 + 流式构建器 | +| `src/ha_ws.c` | 324 | WebSocket 客户端握手/帧 | +| `src/ha_remotedevice.c` | 628 | 设备通道 | +| `CMakeLists.txt` | — | 静态/动态库、install 规则、可选测试 | + +**这意味着 JSON 解析这一 C 化的最大依赖,仓库里已有现成实现**, +不需要引入 cJSON 等外部依赖,与「内核不新增外部依赖」的克制一致。 + +### 2.3 ~~关键约束:Windows 构建是 `CGO_ENABLED=0`~~ → 前提已消失,改用另一条约束 + +> ⚠️ **本节结论已因「放弃 Windows 原生」而过时**(见 §2.5)。 +> 关键的一点在于,原论证推理的根基“Windows 包需要 CGO_ENABLED=0”已不成立, +> 故「必须保留纯 Go 回退」这个**推论**也不再由它支撑。 +> 现在的实际策论是:用 `cgo` / `!cgo` 一组约束(回退实现保留,但理由是 +> waiter 等 CGO-free 目标与无 cgo 工具链场景,而不是 Windows), +> 而**不需要额外的 `hacodec` tag** ——因为 ha_codec 是零依赖纯 C99 源码 +> 内联编译,不像 onnxruntime 那样需要运行期 `.so`。 + +原论证(保留作为推理参考): + +``` +deploy/packaging/package-windows.sh:69 + GOOS=windows GOARCH="$ARCH" CGO_ENABLED=0 \ +``` + +**这是 C 化最大的部署风险**:若把编排逻辑改成必须 cgo,Windows 包直接编不出来。 + +**已验证的解法:build-tag 双实现**。实测(最小复现): + +```go +//go:build cgo +// a_cgo.go —— cgo 实现,链接 C 库 + +//go:build !cgo +// a_pure.go —— 纯 Go 回退实现,行为等价 +``` + +``` +CGO_ENABLED=1 go build ./... → exit 0 +CGO_ENABLED=0 go build ./... → exit 0 +``` + +结论:**C 化必须始终保留纯 Go 回退路径**,且两条路径要有同一组测试钉死 +行为等价(黄金对照,见 §五)。这不是可选项——是 Windows 分发的前提。 + +### 2.4 落地后的形状修正(实测补充,2026-09-25) + +实际落地时,三处原设计需要修正: + +**① 不链接静态库,也不 `#include` 包外源 —— 用包内符号链接。** +原方案(`LDFLAGS` 指向 `csrc/build/libha_codec.a`)实测会造成两个必然失败: + +- `.a` 是构建产物、不入库(`.gitignore` 的 `build/` 命中 `csrc/build/`), + 而发布脚本原先并不产出它 ⇒ 「不入库 + 不生成」两头空,链接报 + `cannot find .../libha_codec.a`。 +- 交叉编译 `linux/arm64`(homed 的真实发布目标)时,宿主 x86-64 的 `.a` + 被链进目标产物,报 `file in wrong format`。 + +改为包内 `ha_codec.c` / `ha_codec.h` **符号链接**到 `csrc/` 权威源: + +``` +internal/agent/api/ha_codec.c -> ../../../csrc/src/ha_codec.c +internal/agent/api/ha_codec.h -> ../../../csrc/include/ha_codec.h +``` + +★ **为什么不能用 `#include "../../../csrc/src/ha_codec.c"`(包外相对包含)**: +**Go 构建缓存不跟踪包外被 #include 的 C 文件**。实测:在包外源里把返回值从 7 +改成 8,`go test` 依然通过(缓存命中、静默沿用旧代码);同样改动落在包内文件时 +立即判红。对「逐步推进 C 化」这是致命的——改 C 源码却不生效且无任何报错。 +(包内 shim `#include` 包外源同样漏跟踪,已实测排除。) + +符号链接同时满足两点:文件在包目录内 ⇒ 缓存按内容正确跟踪; +只有一份权威源 ⇒ 无副本漂移、无需同步目标。 + +**② `csrc/CMakeLists.txt` 的定位变化**:不再是 Go 构建的前置,而是 +**C 侧独立复用**(鸿蒙/嵌入式/C SDK)与契约测试(ctest)的入口。 + +**③ C 源文件会被 Go 工具链视为包的一部分**:故 `.c` 需要 `//go:build cgo` 约束 +(C 编译器把该行当普通注释,两侧兼容)。 + +### 2.5 真正的硬耦合点:Lua 适配器 + +`internal/agent/api/provider.go` 的 `LuaAdaptedProvider` 在**每次请求**都要 +调 Lua VM(`internal/lua/vm.go`,基于 `gopher-lua`,679 行): + +| 调用点 | provider.go 行 | 作用 | +|---|---|---| +| `CallTransformRequest` | :403, :872 | 改写请求体(适配器协议知识) | +| `GetAdapterEndpoint` | :408, :877 | 决定 endpoint | +| `CallTransformResponse` | :439 | 改写响应 | +| `BuildHeaders` / `GetAdapterHeaders` | :487, :489 | 动态签名头 | +| `TransformError` | :900 | 错误归一化 | +| `CallTransformStreamChunk` | :950 | 流式分片改写 | + +**结论**:**「把 provider.go 整体 C 化」是不可行的**——它把一个嵌入式 Lua +解释器(带 GC、协程)拖进 C。可行的切法是**只 C 化 Lua 之外的部分**: +把 Lua 当作「回调钩子」,C 侧定义钩子接口,Go 侧注入 Lua 实现。 + +--- + +## 三、切分方案:按「无状态 → 有状态」分三层 + +### L1 · 协议编解码(本轮目标,纯函数,零状态) + +最适合先下沉。全部是 `string in → struct out`: + +| 目标函数 | 现位置 | 说明 | +|---|---|---| +| `parseOpenAICompatibleResponse` | `provider.go:499` | 非流式响应解析 | +| `parseOpenAICompatibleSSEBody` | `provider.go:541` | SSE body 整段解析 | +| `parseOpenAICompatibleStreamChunkFull` | `provider.go:759` | 流式分片解析 | +| `normalizeOpenAIToolCalls` | `provider.go:628` | 工具调用归一 | +| `normalizeStreamToolCalls` | `provider.go:674` | 流式工具调用归一 | +| `ModelContextWindow` | `provider.go:273` | 模型名 → 窗口(纯映射) | +| `EstimateTokens` / `TruncateByTokens` | `tokenbudget.go` | 纯计算 | +| `ComputeTokenBudget` | `tokenbudget.go:52` | 纯计算(依赖上面两个) | + +这些函数**不碰网络、不碰 Lua、不碰 goroutine**,是最安全的起点。 +`ModelContextWindow` / `EstimateTokens` / `ComputeTokenBudget` 三个更是 +**同一组纯算术**,可作为「第一个能跑通端到端 C 调用」的最小切片。 + +### L2 · Provider 编排(暂不动) + +`LuaAdaptedProvider.Chat` / `ChatStream`:HTTP + Lua 钩子 + SSE 流式, +强耦合 Go 的 `net/http` 与 `context`。C 化的收益低于风险,**暂不动**。 + +### L3 · 工具循环 / 调度(明确不 C 化) + +`task.go` / `toolcall.go` / `eventloop.go` / `scheduler.go`:有状态、 +与记忆层和 goroutine 调度深度耦合。C 化会摧毁可维护性,**不做**。 + +--- + +## 四、落地结构(**已落地**,2026-09-25) + +``` +internal/agent/api/ +├── provider.go # 不动(L2) +├── codec.go # 统一符号名(调用方只见这里) +├── codec_cgo.go # //go:build cgo → 调 C +├── codec_nocgo.go # //go:build !cgo → 转发到纯 Go +├── codec_pure.go # 纯 Go 实现(回退 + 黄金对照基准) +├── codec_golden_test.go # 黄金对照:C 与纯 Go 逐值相等 +├── ha_codec.h -> ../../../csrc/include/ha_codec.h (符号链接) +└── ha_codec.c -> ../../../csrc/src/ha_codec.c (符号链接) + +csrc/ # C 实现(主仓,非 SDK) +├── CMakeLists.txt # 供 C 侧独立复用与 ctest(不参与 Go 构建) +├── include/ha_codec.h # 对外 C 接口(冻结契约) +├── src/ha_codec.c # L1 编解码(当前:窗口推断 + token 估算/截断) +└── test/test_ha_codec.c # C 侧契约测试 +``` + +★ **为什么是符号链接而不是 `#include` 包外源**:Go 构建缓存不跟踪包外被 +`#include` 的 C 文件(实测:改包外源后 `go test` 仍报 ok,静默用旧代码)。 +详见 §2.4 ①。 + +**接口设计原则**(已遵守): +1. C 接口只吃 `const char*` + 长度,出数值/JSON 串——**不传 Go 指针、 + 不回调 Go**(回调留给 L2 的 Lua 钩子层,不在本轮) +2. C 侧**不 malloc 长期持有的内存**;调用方给缓冲区,或用「申请/释放」成对 + API 并在 Go 侧 `defer` 释放 +3. `ha_codec.h` 一旦定下就是**冻结接口**,与 SDK 冻结同一标准 + +--- + +## 五、验收方式(黄金对照,缺一不可) + +C 化的正确性**不能靠「跑起来没崩」**,必须有可复现的对照。三类证据: + +1. **黄金对照测试**:同一组输入分别喂 C 实现与 Go 实现,断言输出逐字段相等。 + 现有测试可直接复用做基准: + - `internal/agent/api/sse_body_test.go`(4 个 Test) + - `internal/agent/api/context_window_test.go`(2 个) + - `internal/agent/core/stream_accumulate_test.go`(9 个) + - `internal/agent/core/tokenbudget_test.go` +2. **双构建全绿**:`CGO_ENABLED=1 go test ./...` 与 `CGO_ENABLED=0 go test ./...` + **都必须通过**(后者走纯 Go 回退)。CI 要同时跑。 +3. **契约测试**:`ha_codec.h` 的每个函数有对应 C 单测(参照 + `remotedevice/test/test_ha_remotedevice.c` 的写法,`gcc ... -lpthread` 直编)。 + +--- + +## 六、第一步:最小可验证切片(**已完成**,2026-09-25) + +**目标**:只 C 化一个纯函数族,跑通「Go → cgo → C → 返回」全链路, +证明结构可行,再谈扩张。 + +选 `ModelContextWindow` + `EstimateTokens` + `TruncateByTokens` +(三个纯函数,无依赖,逻辑确定,测试齐备)。**下表为实际落地情况**: + +| # | 计划项 | 落地 | +|---|---|---| +| 1 | `csrc/include/ha_codec.h` 声明三函数 | ✅(含接口冻结声明与哨兵值约定)| +| 2 | `csrc/src/ha_codec.c` 纯 C 实现 | ✅(表驱动 switch + 手写 UTF-8 步进)| +| 3 | `codec_cgo.go` / `codec_pure.go` 双实现 | ✅(+ `codec_nocgo.go` 转发层)| +| 4 | `csrc/CMakeLists.txt` 产出静态库 | ✅(但 Go **不链接**它,见 §2.4)| +| 5 | Go 侧构建集成 | ✅ 改为包内符号链接 + cgo 编译 C 源 | +| 6 | 黄金对照测试 | ✅ `codec_golden_test.go`(手写用例 + 2000 次随机对拍)| +| 7 | `CGO_ENABLED=0` 下全绿 | ✅ `make check-codec-paths` 钉死两条路径 | + +**额外钉死的约束**(原计划未列,实测后补): + +- **变异测试必须真判红**:改 C 侧 `result = 131072` → `777`, + `go test` 必须 FAIL。这是「C 路径真的生效」的证据(不是「跑起来没崩」)。 + ★ 此测试抓到过两种静默失效:包外 `#include` 漏跟踪、构建缓存隐藏改动。 +- **`cc` 交叉编译必须可过**:`make build-linux-arm64` 产出 ELF aarch64。 +- **C 侧契约测试**:`make csrc-test`(ctest)与 Go 侧黄金对照互补。 + +**这一步的价值不在功能**(这三个函数 Go 版没问题),而在**打通链路、 +钉死双路径约束、建立黄金对照范式**——后面每扩一个函数都复用它。 + +--- + +## 七、已知风险与未决问题 + +| 风险 | 现状 | 处置 | +|---|---|---| +| ~~Windows `CGO_ENABLED=0` 编不出来~~ | **前提已消失**(Windows 原生已放弃,见 §2.3)| 回退保留但理由换成「CGO-free 目标」| +| Lua 适配器无法 C 化 | 硬耦合 gopher-lua | 钩子化,Lua 留在 Go 侧(L2 做)| +| ~~C 库构建谁触发~~ | ✅ 已解决:Go 不依赖预构建库,直接编包内符号链接的 C 源 | — | +| **包外 C 源会被缓存漏跟踪** | ✅ 已避坑:实测确认,改用包内符号链接 | 扩张时勿改回 `#include` 包外路径 | +| JSON 解析能力不足 | `ha_json.c` 只存 `int`(无 float/long)| 用前须评估;必要时扩该库(会动 SDK,需走 SDK 冻结流程)| +| 接口冻结 | — | `ha_codec.h` 冻结标准对齐 SDK | +| 打包脚本 | ✅ 不再需带 `.a`(编源码,无外部产物依赖)| 扩张到多文件 C 实现时重评 | + +**仍未决(需 jianf 拍板)**: +1. C 实现放**主仓 `csrc/`** 还是 **SDK `third_party/homeagent-sdk/`**? + - 当前已在主仓 `csrc/`;若 SDK 侧也要复用,需定同步机制 + - 放 SDK:天然跨端复用,但要走 SDK 冻结与大版本流程 +2. `ha_json.c` 是**复用**(从 remotedevice 复制/提为公共)还是**新写**? + 复用会动 SDK 目录结构。 +3. 是否接受「C 化后内核体积/构建复杂度上升」换取延迟确定性? + (需先有一个真实延迟基线,当前没有) +4. **下一个切片选谁**?L1 剩下的是协议编解码(`parseOpenAICompatible*`、 + `normalize*ToolCalls` 等,见 §三 L1 表);该层依赖 JSON 解析 ⇒ 先解第 2 题。 + +--- + +*实测记录:双路径、缓存跟踪、交叉编译、变异测试均于 2026-09-25 在本仓实测; +工具链与 C 资产核实于本仓* +*初版核实:2026-09-24 · 落地更新:2026-09-25* diff --git a/internal/agent/api/codec.go b/internal/agent/api/codec.go new file mode 100644 index 0000000..f3967f8 --- /dev/null +++ b/internal/agent/api/codec.go @@ -0,0 +1,43 @@ +package api + +// codec.go —— 编解码层的**统一出口**(无论 CGO 开关如何,调用方只认这里)。 +// +// 分层: +// codec_pure.go —— 纯 Go 实现,永远参与编译(回退 + 黄金对照基准) +// codec_cgo.go —— CGO_ENABLED=1:真正调 C 库 +// codec_nocgo.go —— CGO_ENABLED=0:把 C 符号转发到纯 Go +// codec.go —— 本文件:对外的稳定 API,含兜底与日志 +// +// 这样调用方(provider.go / core)不需要写任何 build tag 分支。 + +import ( + "log" + "strings" +) + +// ModelContextWindow 返回模型的最大上下文窗口(token 数)。 +// +// 推断不出时(如 model="AUTO")记一行日志并回退 defaultInferredContextWindow: +// 窗口被低估必须可见,部署方用 per-source +// core.llm.sources..context_window 显式声明真实值即可覆盖。 +// +// 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率。 +func ModelContextWindow(model string) int { + if w := modelContextWindowC(model); w != contextWindowUnknown { + return w + } + log.Printf("[provider] 模型 %q 无法推断上下文窗口,回退 %d;"+ + "若真实窗口更大,请设置 core.llm.sources..context_window", + strings.ToLower(model), defaultInferredContextWindow) + return defaultInferredContextWindow +} + +// EstimateTokens 粗略估算 token 数。 +// +// 注意:这是**高频热路径**(上下文裁剪对每个事件都调)。走 C 的跨语言开销 +// 对短文本未必划算 —— 是否该留在 C 侧由 codec_bench_test.go 的实测数据决定, +// 不要凭直觉断言(见 docs/zh/c-core/llm-orchestration-c.md §七 未决问题 3)。 +func EstimateTokens(text string) int { return estimateTokensC(text) } + +// TruncateByTokens 截断字符串至不超过 maxTokens 估计值。 +func TruncateByTokens(s string, maxTokens int) string { return truncateByTokensC(s, maxTokens) } diff --git a/internal/agent/api/codec_cgo.go b/internal/agent/api/codec_cgo.go new file mode 100644 index 0000000..37478cf --- /dev/null +++ b/internal/agent/api/codec_cgo.go @@ -0,0 +1,97 @@ +//go:build cgo + +package api + +// codec_cgo.go —— 编解码层的 C 实现绑定(CGO_ENABLED=1 时参与编译)。 +// +// ============================ 架构:包内符号链接 ============================ +// C 源是 `ha_codec.c` / `ha_codec.h`,它们是**指向 csrc/ 的符号链接** +// (`ln -s ../../../csrc/src/ha_codec.c`): +// +// internal/agent/api/ha_codec.c -> ../../../csrc/src/ha_codec.c +// internal/agent/api/ha_codec.h -> ../../../csrc/include/ha_codec.h +// +// 权威源只有一份(csrc/),Go 侧看到的是包目录内的链接。 +// +// ============================ 为什么不用另外两种做法 ============================ +// +// **不能链接预构建静态库**(`LDFLAGS: .../csrc/build/libha_codec.a`): +// - .a 是构建产物、不入库(.gitignore 的 build/ 命中 csrc/build/), +// 而发布脚本原先并不产出它 ⇒「不入库 + 不生成」两头空,链接必然失败 +// (实测:cannot find csrc/build/libha_codec.a) +// - 交叉编译 linux/arm64(homed 的真实发布目标)时,宿主 x86-64 的 .a +// 被链进目标产物,报 `file in wrong format`(实测)。 +// +// **不能用 `#include "../../../csrc/src/ha_codec.c"`(包外相对包含)**: +// ★ Go 构建缓存**不跟踪包外被 #include 的 C 文件**。实测:在包外源里把 +// 返回值从 7 改成 8,`go test` 依然通过(缓存命中,静默沿用旧代码); +// 而同样改动落在包内文件时立刻判红。这对「逐步推进 C 化」是致命的—— +// 改 C 源码却不生效,且无任何报错。 +// (包内 shim `#include` 包外源同样漏跟踪,已实测排除。) +// +// 包内符号链接同时满足两点:文件在包目录内 ⇒ 缓存按内容正确跟踪; +// 只有一份权威源 ⇒ 无副本漂移,也不需要「同步 C 源」的 make 目标。 +// +// ============================ 为什么不需要额外 build tag ============================ +// 与 onnxruntime(internal/nlp/onnx.go,需运行期 libonnxruntime.so)不同: +// ha_codec 是**零依赖纯 C99 源码内联编译**,不需要任何外部库或工具链前提。 +// 而 homed 本就强制 cgo(mattn/go-sqlite3 + gojieba),故 C 路径自然生效。 +// 因此只用 `cgo` / `!cgo` 一组约束,不引入 hacodec tag。 +// +// ============================ C 侧契约 ============================ +// `#include "ha_codec.h"` 只声明原型;实现在同包的 ha_codec.c,由 cgo 自动编译。 +// 只含 libc 头,不引入第三方符号。 +// +// 语义必须与 codec_pure.go 逐值等价,由 codec_golden_test.go 钉死。 + +/* +#cgo CFLAGS: -std=c99 +#include +#include "ha_codec.h" +*/ +import "C" + +import "unsafe" + +// modelContextWindowC 经 C 实现推断上下文窗口。 +func modelContextWindowC(model string) int { + cModel := C.CString(model) + defer C.free(unsafe.Pointer(cModel)) + return int(C.ha_codec_model_context_window(cModel)) +} + +// estimateTokensC 经 C 实现估算 token 数。 +func estimateTokensC(text string) int { + cText := C.CString(text) + defer C.free(unsafe.Pointer(cText)) + return int(C.ha_codec_estimate_tokens(cText)) +} + +// truncateByTokensC 经 C 实现按 token 截断。 +// +// 缓冲区策略:按 rune 数上界分配(每个 rune 最多 4 字节)+ 1 字节 NUL, +// 保证 C 侧不会因容量不足而截短——否则 C 与 Go 的逐值对照会假失败。 +// 若字符串无 rune(纯 ASCII 也至少 len 字节),取 len(text)+1 兜底。 +func truncateByTokensC(s string, maxTokens int) string { + if maxTokens <= 0 || s == "" { + return "" + } + // []rune 的长度即 rune 数;每个 rune 最坏 4 字节,+1 给 NUL。 + runeCount := len([]rune(s)) + bufSize := runeCount*4 + 1 + if bufSize < len(s)+1 { + bufSize = len(s) + 1 + } + buf := (*C.char)(C.malloc(C.size_t(bufSize))) + if buf == nil { + // 分配失败:回退纯 Go 实现,不让整个调用失败。 + return truncateByTokensPure(s, maxTokens) + } + defer C.free(unsafe.Pointer(buf)) + + cText := C.CString(s) + defer C.free(unsafe.Pointer(cText)) + + n := C.ha_codec_truncate_by_tokens(cText, C.int(maxTokens), buf, C.size_t(bufSize)) + return C.GoStringN(buf, C.int(n)) +} diff --git a/internal/agent/api/codec_golden_test.go b/internal/agent/api/codec_golden_test.go new file mode 100644 index 0000000..8bdb647 --- /dev/null +++ b/internal/agent/api/codec_golden_test.go @@ -0,0 +1,115 @@ +package api + +// codec_golden_test.go —— 黄金对照测试:C 实现与纯 Go 实现必须逐值等价。 +// +// 这是本轮 C 化**最重要的验收**(见 docs/zh/c-core/llm-orchestration-c.md §五)。 +// 没有它,「C 化没坏」就只是感觉,不是证据。 +// +// 两条约束: +// 1. CGO_ENABLED=1 时:真的对比 C 与纯 Go 两条路径 +// 2. CGO_ENABLED=0 时:C 符号已转发到纯 Go,对照退化为自比(仍跑,防止 +// 测试文件因 build tag 被整文件跳过 —— 那会让 0 模式下失去这段覆盖) + +import ( + "math/rand" + "strings" + "testing" +) + +func TestGolden_ModelContextWindow(t *testing.T) { + cases := []string{ + // 已覆盖的分支各取一个(含大小写、子公司前缀、带路径的模型名) + "deepseek/deepseek-v4.1-flash", "deepseek-v4-flash", "DEEPSEEK-V3", "deepseek-r1", + "deepseek-chat", "gpt-4-turbo", "gpt-4o-mini", "gpt-4-omni", "gpt-4", "gpt-4-0613", + "gpt-3.5-turbo", "claude-3.5-sonnet", "claude-3-opus", "claude-opus-5", "claude-2", + "gemini-1.5-pro", "gemini-2.0-flash", "gemini-pro", "qwen-max", "QWEN-MAX", + "glm-4", "chatglm3", "llama-3-70b", "llama-2-7b", "mistral-large", "mixtral-8x7b", + "yi-34b", "零一万物", "moonshot-v1-128k", "kimi-128k", + // 推断不出(哨兵路径) + "AUTO", "auto", "", "unknown-model", "some-local-model", + } + for _, model := range cases { + c := modelContextWindowC(model) + p := modelContextWindowPure(model) + if c != p { + t.Errorf("ModelContextWindow(%q): C=%d, pure=%d", model, c, p) + } + } +} + +// TestGolden_EstimateTokens 覆盖 ASCII / 中文 / emoji / 空 / 长文本。 +func TestGolden_EstimateTokens(t *testing.T) { + cases := []string{ + "", "a", "ab", "abc", "hello world", + "你好", "你好世界", "中文English混合", "a你b好c", + "😀", "😀😀", "👨‍👩‍👧‍👦", // 含 ZWJ 组合序列(多 rune) + strings.Repeat("x", 1000), + strings.Repeat("你", 1000), + "\n\t\r ", "{}[]()", + } + for _, s := range cases { + c := estimateTokensC(s) + p := estimateTokensPure(s) + if c != p { + t.Errorf("EstimateTokens(%q): C=%d, pure=%d", s, c, p) + } + } +} + +// TestGolden_TruncateByTokens 覆盖边界:maxTokens 为 0/负/奇数/超限/恰好。 +func TestGolden_TruncateByTokens(t *testing.T) { + texts := []string{ + "", "a", "abc", "abcdefghij", + "你好世界", "你好世界再见", "a你b好c世d界", + "😀😀😀😀", strings.Repeat("x", 100), strings.Repeat("你", 100), + } + maxTokensList := []int{-1, 0, 1, 2, 3, 4, 5, 6, 7, 8, 20, 100, 200, 201, 1000} + for _, s := range texts { + for _, mt := range maxTokensList { + c := truncateByTokensC(s, mt) + p := truncateByTokensPure(s, mt) + if c != p { + t.Errorf("TruncateByTokens(%q, %d): C=%q, pure=%q", s, mt, c, p) + } + // 额外不变量:结果必须是原串前缀,且不超过预算 + if !strings.HasPrefix(s, c) && c != "" { + t.Errorf("TruncateByTokens(%q, %d)=%q 不是原串前缀", s, mt, c) + } + } + } +} + +// TestGolden_Randomized 随机输入对拍:抓前面手写用例没想到的组合。 +// 固定 seed,失败可复现。 +func TestGolden_Randomized(t *testing.T) { + rng := rand.New(rand.NewSource(20260924)) + alphabet := []rune("abcXYZ019 你好世界😀-_./") + + for i := 0; i < 2000; i++ { + n := rng.Intn(40) + var sb strings.Builder + for j := 0; j < n; j++ { + sb.WriteRune(alphabet[rng.Intn(len(alphabet))]) + } + s := sb.String() + + if c, p := estimateTokensC(s), estimateTokensPure(s); c != p { + t.Fatalf("EstimateTokens(%q): C=%d, pure=%d", s, c, p) + } + + mt := rng.Intn(60) - 5 + if c, p := truncateByTokensC(s, mt), truncateByTokensPure(s, mt); c != p { + t.Fatalf("TruncateByTokens(%q, %d): C=%q, pure=%q", s, mt, c, p) + } + + // 模型名:拼一段 ASCII 再随机插入已知子串 + models := []string{"deepseek-v4", "gpt-4-turbo", "claude-3", "qwen", "llama-3", "kimi", "zzz"} + m := models[rng.Intn(len(models))] + if rng.Intn(2) == 0 { + m = strings.ToUpper(m) + } + if c, p := modelContextWindowC(m), modelContextWindowPure(m); c != p { + t.Fatalf("ModelContextWindow(%q): C=%d, pure=%d", m, c, p) + } + } +} diff --git a/internal/agent/api/codec_nocgo.go b/internal/agent/api/codec_nocgo.go new file mode 100644 index 0000000..76e38fc --- /dev/null +++ b/internal/agent/api/codec_nocgo.go @@ -0,0 +1,22 @@ +//go:build !cgo + +package api + +// codec_nocgo.go —— CGO_ENABLED=0 时把 C 路径的符号指向纯 Go 实现。 +// +// 为什么需要这层转发而不是直接调 *Pure:让 codec.go 无论编译开关如何都能引用 +// 同一组符号名,避免调用方到处写 build tag 分支。 +// +// 谁会走到这里(CGO_ENABLED=0): +// - waiter 等刻意 CGO-free 的跨平台目标(Makefile build-cli) +// - 交叉编译到无 cgo 工具链的场景 +// +// homed 不会走到这里——它强制 cgo(sqlite3 + gojieba)。 +// 两条路径的语义等价由 codec_golden_test.go 钉死,Makefile 的 +// check-codec-paths 目标同时跑两条。 + +func modelContextWindowC(model string) int { return modelContextWindowPure(model) } + +func estimateTokensC(text string) int { return estimateTokensPure(text) } + +func truncateByTokensC(s string, maxTokens int) string { return truncateByTokensPure(s, maxTokens) } diff --git a/internal/agent/api/codec_pure.go b/internal/agent/api/codec_pure.go new file mode 100644 index 0000000..245f35e --- /dev/null +++ b/internal/agent/api/codec_pure.go @@ -0,0 +1,103 @@ +package api + +// codec_pure.go —— 编解码层的**纯 Go 实现**,永远参与编译。 +// +// 它有两个身份: +// 1. CGO_ENABLED=0 时的生产实现(Windows 包走这里,见 +// deploy/packaging/package-windows.sh:69) +// 2. CGO_ENABLED=1 时**黄金对照的基准**(codec_golden_test.go 用同一组输入 +// 对比它与 C 实现,逐值必须相等) +// +// 因此本文件**不带 build tag**——两条路径都要能见到它。 + +import "strings" + +// defaultInferredContextWindow 是模型名无法推断窗口时的兜底。 +// +// 32768 是个保守值,但它属于**静默降级**:模型名写 AUTO(网关自己选上游)时 +// 匹配不到任何分支,内核就会拿着一份比真实小得多的窗口去算全部预算 +// (实测:deepseek-v4.1-flash 能吞 990,034 token,而预算按 32768 算)。 +// 兜底值本身不猜大:猜大会让请求直接撞上游 400。 +const defaultInferredContextWindow = 32768 + +// contextWindowUnknown 是「模型名推断不出窗口」的哨兵值。 +// +// 与 C 侧 HA_CODEC_CONTEXT_WINDOW_UNKNOWN 取值必须一致。 +// 用哨兵而非直接返回兜底值:调用方要能区分「真推断出了」与 +// 「推断不出、只能兜底」——后者必须记日志,让窗口被低估这件事可见。 +const contextWindowUnknown = -1 + +// modelContextWindowPure 由模型名推断最大上下文窗口;推断不出返回哨兵。 +// 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率。 +func modelContextWindowPure(model string) int { + model = strings.ToLower(model) + switch { + case strings.Contains(model, "deepseek-v4") || strings.Contains(model, "deepseek-v3"): + return 1048576 + case strings.Contains(model, "deepseek-r1") || strings.Contains(model, "deepseek-chat"): + return 65536 + case strings.Contains(model, "gpt-4") && (strings.Contains(model, "turbo") || strings.Contains(model, "mini") || strings.Contains(model, "omni")): + return 128000 + case strings.Contains(model, "gpt-4"): + return 8192 + case strings.Contains(model, "gpt-3.5"): + return 16384 + case strings.Contains(model, "claude-3.5") || strings.Contains(model, "claude-3"): + return 200000 + case strings.Contains(model, "claude"): + return 100000 + case strings.Contains(model, "gemini-1.5") || strings.Contains(model, "gemini-2"): + return 1048576 + case strings.Contains(model, "gemini"): + return 32768 + case strings.Contains(model, "qwen"): + return 131072 + case strings.Contains(model, "glm") || strings.Contains(model, "chatglm"): + return 131072 + case strings.Contains(model, "llama-3"): + return 8192 + case strings.Contains(model, "llama-2"): + return 4096 + case strings.Contains(model, "mistral") || strings.Contains(model, "mixtral"): + return 32768 + case strings.Contains(model, "yi-") || strings.Contains(model, "零一"): + return 200000 + case strings.Contains(model, "moonshot") || strings.Contains(model, "kimi"): + return 131072 + default: + return contextWindowUnknown + } +} + +// estimateTokensPure 粗略估算 token 数。 +// 中文 ~1.5 token/字,英文 ~0.3 token/字符,保守估计取 max(1, runeCount * 2)。 +func estimateTokensPure(text string) int { + if text == "" { + return 0 + } + runeCount := len([]rune(text)) + if runeCount == 0 { + return 0 + } + t := runeCount * 2 + if t < 1 { + return 1 + } + return t +} + +// truncateByTokensPure 截断字符串至不超过 maxTokens 估计值。 +func truncateByTokensPure(s string, maxTokens int) string { + if maxTokens <= 0 || s == "" { + return "" + } + runes := []rune(s) + if len(runes)*2 <= maxTokens { + return s + } + keep := maxTokens / 2 + if keep >= len(runes) { + return s + } + return string(runes[:keep]) +} diff --git a/internal/agent/api/ha_codec.c b/internal/agent/api/ha_codec.c new file mode 120000 index 0000000..c756890 --- /dev/null +++ b/internal/agent/api/ha_codec.c @@ -0,0 +1 @@ +../../../csrc/src/ha_codec.c \ No newline at end of file diff --git a/internal/agent/api/ha_codec.h b/internal/agent/api/ha_codec.h new file mode 120000 index 0000000..bcb54a1 --- /dev/null +++ b/internal/agent/api/ha_codec.h @@ -0,0 +1 @@ +../../../csrc/include/ha_codec.h \ No newline at end of file diff --git a/internal/agent/api/provider.go b/internal/agent/api/provider.go index 858d1d3..0180426 100644 --- a/internal/agent/api/provider.go +++ b/internal/agent/api/provider.go @@ -260,61 +260,9 @@ func ProviderSupportsAudio(p Provider) bool { return false } -// defaultInferredContextWindow 是模型名无法推断窗口时的兜底。 -// -// 32768 是个保守值,但它属于**静默降级**:模型名写 AUTO(网关自己选上游)时 -// ModelContextWindow 匹配不到任何分支,内核就会拿着一份比真实小得多的窗口 -// 去算全部预算(实测:deepseek-v4.1-flash 能吞 990,034 token,而预算按 32768 算)。 -// 因此推断不出来时留一条日志,并让部署方用 per-source context_window 显式声明。 -const defaultInferredContextWindow = 32768 - -// ModelContextWindow 返回模型的最大上下文窗口(token 数) -// 标称窗口 ≠ 有效窗口:接近满时注意力涣散,调用方应取 70-80% 为目标利用率 -func ModelContextWindow(model string) int { - model = strings.ToLower(model) - switch { - case strings.Contains(model, "deepseek-v4") || strings.Contains(model, "deepseek-v3"): - return 1048576 - case strings.Contains(model, "deepseek-r1") || strings.Contains(model, "deepseek-chat"): - return 65536 - case strings.Contains(model, "gpt-4") && (strings.Contains(model, "turbo") || strings.Contains(model, "mini") || strings.Contains(model, "omni")): - return 128000 - case strings.Contains(model, "gpt-4"): - return 8192 - case strings.Contains(model, "gpt-3.5"): - return 16384 - case strings.Contains(model, "claude-3.5") || strings.Contains(model, "claude-3"): - return 200000 - case strings.Contains(model, "claude"): - return 100000 - case strings.Contains(model, "gemini-1.5") || strings.Contains(model, "gemini-2"): - return 1048576 - case strings.Contains(model, "gemini"): - return 32768 - case strings.Contains(model, "qwen"): - return 131072 - case strings.Contains(model, "glm") || strings.Contains(model, "chatglm"): - return 131072 - case strings.Contains(model, "llama-3"): - return 8192 - case strings.Contains(model, "llama-2"): - return 4096 - case strings.Contains(model, "mistral") || strings.Contains(model, "mixtral"): - return 32768 - case strings.Contains(model, "yi-") || strings.Contains(model, "零一"): - return 200000 - case strings.Contains(model, "moonshot") || strings.Contains(model, "kimi"): - return 131072 - default: - // 模型名推断不出窗口(如 "AUTO"):不要静静退回一个比真实小得多的值。 - // 报一行日志,让“窗口被低估”这件事可见;部署方用 per-source - // core.llm.sources..context_window 声明真实值即可覆盖。 - log.Printf("[provider] 模型 %q 无法推断上下文窗口,回退 %d;"+ - "若真实窗口更大,请设置 core.llm.sources..context_window", - model, defaultInferredContextWindow) - return defaultInferredContextWindow - } -} +// defaultInferredContextWindow 与 ModelContextWindow 已移至 codec.go / +// codec_pure.go(编解码层 C 化,见 docs/zh/c-core/llm-orchestration-c.md)。 +// 这里不再重复定义,避免两份实现漂移。 type BaseConfig struct { Model string `json:"model"` diff --git a/internal/agent/core/tokenbudget.go b/internal/agent/core/tokenbudget.go index c1ddef6..e7855ec 100644 --- a/internal/agent/core/tokenbudget.go +++ b/internal/agent/core/tokenbudget.go @@ -1,8 +1,6 @@ package core import ( - "unicode/utf8" - "gitcode.com/JianFeeeee/HomeAgent/internal/agent/api" ) @@ -16,24 +14,21 @@ type TokenBudget struct { Reserved int // 预留(response 空间) } -// EstimateTokens 粗略估算 token 数 -// 中文 ~1.5 token/字,英文 ~0.3 token/字符 -// 保守估计取 max(1, runeCount * 2),对混合文本足够安全 -func EstimateTokens(text string) int { - if text == "" { - return 0 - } - runeCount := utf8.RuneCountInString(text) - if runeCount == 0 { - return 0 - } - t := runeCount * 2 - if t < 1 { - return 1 - } - return t -} +// EstimateTokens / TruncateByTokens 转发到编解码层统一出口。 +// +// 本包内调用点很多(context.go / process.go / resident.go / tooldefs.go…), +// 而实现只有一份(api 包,C 化后可选走 C)。保留这两个同名转发, +// 是为了不把调用点全部改写成 api.EstimateTokens —— 那场改动对行为零收益, +// 却把「本包依赖 api」这件事铺得到处都是。 +// EstimateTokens 粗略估算 token 数(转发到 api.EstimateTokens)。 +func EstimateTokens(text string) int { return api.EstimateTokens(text) } + +// TruncateByTokens 截断字符串至不超过 maxTokens 估计值(转发到 api.TruncateByTokens)。 +func TruncateByTokens(s string, maxTokens int) string { return api.TruncateByTokens(s, maxTokens) } + +// ComputeTokenBudget 计算各部分的 token 预算。 +// // maxTargetTokens 是**有效工作区间**的上限(不是模型窗口)。 // // 为什么窗口 1M 却不能按 800K 干活:标称窗口 ≠ 有效窗口。接近满窗口时注意力 @@ -85,19 +80,5 @@ func ComputeTokenBudget(provider api.Provider, systemPromptBase string) TokenBud } } -// TruncateByTokens 截断字符串至不超过 maxTokens 估计值 -func TruncateByTokens(s string, maxTokens int) string { - if maxTokens <= 0 || s == "" { - return "" - } - runes := []rune(s) - if len(runes)*2 <= maxTokens { - return s - } - // 从开头保留 maxTokens/2 个字符(每个字符约 2 token) - keep := maxTokens / 2 - if keep >= len(runes) { - return s - } - return string(runes[:keep]) -} +// TruncateByTokens 已移至 api 包(编解码层统一出口,见 codec.go)。 +// 上方已有同名转发,此处不再重复定义。 diff --git a/plan.md b/plan.md index 79e36dc..2c845f4 100644 --- a/plan.md +++ b/plan.md @@ -1,69 +1,105 @@ # HomeAgent 遗留问题清单 -> **本文定位(2026-09-24 重写)**:本文**只写仍未完成、且经源码核实确属本仓库的问题**。 -> 上一版 plan.md 是 1474 行的「历史工单 + 路线图」混合档,混入了大量已解决记录、 -> 以及**根本不属于本仓库的跨项目工单**(见 §五),本次全部剔除。 +> **本文定位(2026-09-25 重写)**:只写**仍未完成、且经源码/实测核实确属本仓库**的问题。 > -> **每一条的判定方法**:不是继承旧档的复选框,而是回到源码逐项核实 -> (`grep` 定符号存在性、`go build ./...`、`go test ./...`、`go test -race`)。 -> 旧档里标记「未做」但代码已落地的项、以及引用别仓代码的项,统一进 §三/§五 并附证据。 +> **判定方法**:不继承任何旧档的复选框,回到源码与构建现场逐条重验 +> (`grep` 定符号、`go build`、`go test -count=1`、交叉编译试验、双构建试验)。 +> 旧档标记「未做」但代码已落地的项、以及引用别仓代码的项,统一进 §三/§五 并附证据。 > -> **本文不写**:已完成项的定位过程(那是 git log 和 commit message 的职责)、 +> **本文不写**:已完成项的定位过程(那是 git log 与 commit message 的职责)、 > 无实测支撑的性能断言、任何凭据/生产路径。 --- +## 〇、本次重写相对上一版(b6027f3)的三个更正 + +上一版是源码核实版,结论基本可靠;但本轮**现场实测**推翻了其中三处,记在此处防复发: + +1. **「`go test ./...` 有 3 个 FAIL」是环境相关的,不是稳定复现** —— + 本机 12:50 实测 `go test -count=1 ./...` **57 包:38 ok + 19 无测试 + 0 FAIL**; + 但 11:45 同一命令确实报过 PTY 三例失败(当时 `/dev/ptmx` 报 + `permission denied`)。环境恢复后不再复现。 + ★ **真正的缺陷不是「有无 FAIL」,而是那个 skip 是坏的**:PTY 用例**有** + `t.Skipf("PTY not available: ...")`(`integration_test.go:284/337/395`), + 但它查的是 `resp["status"] == "error"`,而 `terminal_create` 失败返回的是 + `{"error": "创建终端失败: ..."}`(`agentcli/plugin.go:496`)——**键名不匹配**, + 所以环境退化时走的是 `t.Fatalf` 而非 skip。 + ⇒ 「探测形同虚设」比「没有探测」隐蔽,这才是该修的点(见 §六)。 +2. **P2-8「Windows 真机验证」整条作废** —— Windows 支持**已被设计性放弃** + (`cmd/homed/platform_windows.go`:原生 Windows 拒绝启动,改走 WSL2)。 + 拿「Windows 下 homed 加载插件往返」当验收目标,等于要求一个明确不存在的产物。 +3. **新增最高优先级项:C 化 WIP 的三处构建回归** —— 已 **修复**(2026-09-25,见 §二 P0-1)。 + 原始症状:`linux/arm64` 发布目标必然失败、clean clone 编不出 `homed`。 + ★ 修复过程中又发现一个更隐蔽的陷阱:**Go 构建缓存不跟踪包外 `#include` 的 C 文件** + (改 C 源码但缓存命中时会静默沿用旧代码)——这对「逐步推进 C 化」是致命的, + 已改用包内符号链接避坑。 + +--- + ## 一、结论速览 | 类别 | 数量 | 去向 | |---|---|---| -| 真正待办(本仓库、代码层可动) | 10 项 | §二 | +| 真正待办(本仓库、代码层可动) | 7 项 | §二 | +| 已修复(本轮) | 1 项 | §二 P0-1 | | 生产部署后验证(代码已就绪,需现场跑) | 4 项 | §四 | | 已关闭 / 已实现(旧档误标为 TODO) | 8 项 | §三 | -| 跨项目工单(不属本仓,已在别处解决或归档) | 2 项 | §五 | +| 跨项目工单(不属本仓) | 2 项 | §五 | +| 明确「不做」(防反复挂账) | 2 项 | §六 | -当前健康度:`go build ./...` 通过;`go test ./...` 有 **3 个 FAIL**,全部是 -测试自身缺陷(见 P2-10),非产品代码问题;`go test -race` 于 `internal/lua`、 -`internal/plugin/**` 全绿。 +当前健康度(实测): + +- `go build ./...`(宿主)通过;`go test -count=1 ./...` **57 个包:38 ok + 19 无测试文件 + 0 FAIL** +- `CGO_ENABLED=0 go build ./cmd/homed` **必然失败**(`gojieba` 是 cgo-only)—— + 这是**既有事实**,非本轮问题;`homed` 历来只有 cgo 构建。 +- 编解码层双路径(cgo / !cgo)均绿:`make check-codec-paths`。 +- `linux/amd64` 与 `linux/arm64` 发布目标均可构建: + `bash deploy/packaging/build.sh linux/arm64 homed` → ELF aarch64(已修,见 P0-1)。 --- ## 二、真正待办 -按「影响面 × 可验证性」排序。每条给出**证据**(file:line)与**验收**。 +按「影响面 × 可验证性」排序。每条给出**证据**(file:line / 实测命令)与**验收**。 -### P0-1 §13.7 RuntimeManager + 分组 worker(架构演进,唯一大件) +### ✅ P0-1 C 化第一刀的构建回归 —— **已修复(2026-09-25)** -**现状**:`RuntimeManager` / `worker_group` / `WorkerGroup` 在全仓库 -(含 .go / .json / .md,排除 plan.md 自身)**零引用** —— 该能力从未实现。 -当前是**一插件一子进程**:`internal/plugin/proc/plugin.go:134` 的 `Spawn` -按插件各起一个进程。 +**原始问题**(并行 agent 的 `feature/c-core` 工作区改动引入): +1. clean clone 编不出 homed:cgo `LDFLAGS` 硬指向 `csrc/build/libha_codec.a`, + 而该 `.a` 既不入库(`.gitignore` 的 `build/` 命中)又不被发布脚本生成 +2. `linux/arm64` 发布目标必然失败:宿主 x86-64 的 `.a` 被链进 aarch64 产物, + 报 `file in wrong format` +3. `Makefile` arm64 目标缺 CC/CXX,且无 `.syso` 隔离 -**为什么要做**:每个外部插件一个进程 → N 个插件 = N 个常驻进程 + N 份 -transport,进程数随插件线性涨。目标是一个 RuntimeManager + 少量 worker + -多插件共享 transport + 每插件独立 PluginContext(独立身份,共享管道)。 +**修复方式**(判据:不是「能跑」,而是「不能被静默绕过」): -**证据**: -- `internal/plugin/proc/plugin.go:134`(`Spawn`,逐插件起进程) -- manifest 无 `worker_group` 字段:`third_party/homeagent-sdk/example/a2a/plugin.json` - 的键集合为 `author/description/entry/name/name_en/name_zh/platforms/tags/version` +| 问题 | 修法 | +|---|---| +| 链接预构建 `.a` | 改为**包内符号链接** `internal/agent/api/ha_codec.{c,h}` → `csrc/` 权威源;cgo 直接编源码 | +| 交叉编译架构错 | 编源码按目标重编,天然正确 | +| arm64 缺工具链 | `Makefile` 补 `CC_ARM64`/`CXX_ARM64` 与 `.syso` 隔离(照抄 `build.sh` 已有做法)| +| 双构建无覆盖 | 新增 `make check-codec-paths`(cgo 与 !cgo 两条都跑)| -**子任务**: -1. `RuntimeManager` 类型:worker 池 + 调度 -2. `Worker` 类型:一个进程,承载多个插件,共享 `RuntimeClient` -3. `PluginContext` 类型:每插件独立身份(能力门、ownerID、工具命名空间) - 挂在同一 transport 上 -4. manifest 增 `worker_group` 字段(缺省 = 全部归同一 worker,兼容迁移) -5. 高风险插件可声明独立 `worker_group` +★ **为何不用 `#include "../../../csrc/src/ha_codec.c"`(包外相对包含)**: +**Go 构建缓存不跟踪包外被 `#include` 的 C 文件**。实测:在包外源里把返回值从 7 +改成 8,`go test` 依然通过(缓存命中、静默沿用旧代码);同样改动落在包内文件时 +立即判红。对「逐步推进 C 化」这是致命的——改 C 源码却不生效且无任何报错。 +(包内 shim `#include` 包外源同样漏跟踪,已实测排除。) -**验收**: -- [ ] 缺省分组行为与现状逐字节等价(现有 proc 测试全绿,不改协议) -- [ ] 两个插件同 worker 时,各自 `ReclaimOwner` 只回收自己的 arena 块 -- [ ] 一插件崩溃不带走同 worker 的另一插件(或明确:带走,并写进文档) -- [ ] manifest 无 `worker_group` 的旧插件可原样加载(向后兼容) +**实测证据**(每条都可复现): -> 注:`.pi/subagents/missions/` 里有一份针对本项的只读架构评审产物, -> 可作为设计输入;其中结论尚未落地为代码。 +- `make build-linux-arm64` → **ELF 64-bit LSB executable, ARM aarch64**(82MB) +- `bash deploy/packaging/build.sh linux/arm64 homed` → **ELF aarch64**(79MB) +- 移走 `csrc/build/` 后,homed(cgo)与 waiter(CGO=0)**均能构建** +- 变异 C 源(`result = 131072` → `777`)后**同一缓存**下 `go test` **立即 FAIL** + (改前:仍报 ok = 漏跟踪) +- `make check-codec-paths` 两条路径 OK;`make csrc-test` C 契约测试 100% 通过 +- 全量 `go test -count=1 ./...` → **57 包:38 ok + 19 无测试 + 0 FAIL** + +**遗留(不阻塞,已记入 `docs/zh/c-core/llm-orchestration-c.md` §七)**: +- 下一个切片选谁(L1 剩下的协议编解码,依赖 JSON 解析 ⇒ 先定 `ha_json.c` 复用还是新写) +- 符号链接是本仓**首例**(先例数=0)。已验证 git 往返保留(mode 120000), + 且 `core.symlinks=false` 降级时会**响亮报编译错**(非静默错误);扩张前应有意识 --- @@ -73,30 +109,60 @@ transport,进程数随插件线性涨。目标是一个 RuntimeManager + 少 (Go `plugin.Open` 路径、`DF_1_NODELETE`)会被假装重载成功**。 **证据**: -- `internal/plugins/pluginmgr/plugin.go:528 / :548 / :755` —— 仍返回 - `reload_required` -- 全仓库无 `DF_1_NODELETE` / `NODELETE` 检测(`grep` 为空) -- `internal/plugin/registry.go:823-824` 注释已自认:`Go plugin.Open 路径 - (dynamicPlugin)不可卸载,跳过` +- `internal/plugins/pluginmgr/plugin.go:528 / :548 / :755` —— 仍返回 `reload_required` +- 全仓库 `grep -rn "DF_1_NODELETE\|NODELETE" --include=*.go` **为空**(无检测) +- `internal/plugin/registry.go` 注释已自认:`Go plugin.Open 路径(dynamicPlugin)不可卸载,跳过` **子任务**: -1. ELF 检测 `DF_1_NODELETE` → 标记插件「不可热重载」 - (`internal/plugin/dynamic_loader_unix.go`) -2. `ReloadOne` 对这类插件返回「需重启 homed」,停止假装成功(`registry.go:785`) +1. ELF 检测 `DF_1_NODELETE` → 标记插件「不可热重载」(`internal/plugin/dynamic_loader_unix.go`) +2. `ReloadOne` 对这类插件返回「需重启 homed」,停止假装成功 3. `plugin_install` 返回 `restart_required` 替代误导性的 `reload_required` - (`pluginmgr/plugin.go`) **验收**: - [ ] 装一个 `DF_1_NODELETE` 插件后,调用方拿到 `restart_required`,不是 `reload_required` -- [ ] `ReloadOne` 在该插件上返回明确错误,且**摘除旧注册面**(工具/stage/output) - 仍已完成,不留悬空闭包 +- [ ] `ReloadOne` 在该插件上返回明确错误,且**摘除旧注册面**(工具/stage/output)仍完成, + 不留悬空闭包 --- -### P1-3 §13.2 arena 容量固定,无 Grow/Shrink +### P0-3 §13.7 RuntimeManager + 分组 worker(唯一架构大件) + +**现状**:`RuntimeManager` / `worker_group` / `WorkerGroup` / `RuntimeClient` +在全仓库**零引用** —— 该能力从未实现。当前是**一插件一子进程** +(`internal/plugin/proc/plugin.go:134` 的 `Spawn`)。 + +**为什么要做**:N 个外部插件 = N 个常驻进程 + N 份 transport,进程数随插件线性涨。 +目标是一个 RuntimeManager + 少量 worker + 多插件共享 transport + 每插件独立 +PluginContext(独立身份,共享管道)。 + +**证据**: +- `internal/plugin/proc/plugin.go:134`(`Spawn`,逐插件起进程) +- manifest 无 `worker_group` 键(`third_party/homeagent-sdk/example/a2a/plugin.json` + 的键集合为 `author/description/entry/name/name_en/name_zh/platforms/tags/version`) + +**子任务**: +1. `RuntimeManager` 类型:worker 池 + 调度 +2. `Worker` 类型:一个进程承载多个插件,共享 `RuntimeClient` +3. `PluginContext` 类型:每插件独立身份(能力门、ownerID、工具命名空间)挂在同一 transport +4. manifest 增 `worker_group`(缺省 = 全部归同一 worker,兼容迁移) +5. 高风险插件可声明独立 `worker_group` + +**验收**: +- [ ] 缺省分组行为与现状逐字节等价(现有 proc 测试全绿,不改协议) +- [ ] 两个插件同 worker 时,各自 `ReclaimOwner` 只回收自己的 arena 块 +- [ ] 一插件崩溃不带走同 worker 的另一插件(或明确:带走,并写进文档) +- [ ] manifest 无 `worker_group` 的旧插件可原样加载(向后兼容) + +> 注:旧档称 `.pi/subagents/missions/` 里有一份针对本项的只读架构评审可作设计输入—— +> **经核实该评审实际失败了**(`0475d2e9`:`EADDRINUSE 127.0.0.1:14010` 进程崩溃, +> `ok:false`、`output:""`,`exitCode:1`)。**没有可用结论**,本项要从零开始设计。 + +--- + +### P1-4 §13.2 arena 容量固定,无 Grow/Shrink **现状**:内核独占的变长分配器已落地(first-fit + 邻块合并 + owner 校验), -但容量**固定 4MB**,用尽即调用失败,不再退回内联。 +但容量**固定 4MB**,用尽即调用失败,不退回内联。 **证据**: - `internal/plugin/proc/arena.go:99`:`const arenaDefaultCapacity = 4 * 1024 * 1024` @@ -108,7 +174,7 @@ transport,进程数随插件线性涨。目标是一个 RuntimeManager + 少 **可选路径**(择一,需先定方案再动手): - a) 预映射大虚拟区间(未触碰页不占物理内存),容量「逻辑无限」 - b) 段表 + 多段拼接:新块分配在新段,不必 remap 旧段 -- c) 维持固定容量,但**把超限错误做成可执行指引**(告诉插件该怎么分片) +- c) 维持固定容量,但**把超限错误做成可执行指引**(告诉插件怎么分片) **验收**(按所选路径定): - [ ] 超过当前 4MB 的单次 payload 仍能送达,或收到带指引的明确错误 @@ -116,16 +182,16 @@ transport,进程数随插件线性涨。目标是一个 RuntimeManager + 少 --- -### P1-4 §12.3 事件环:机制完成,零真实负载检验 +### P1-5 §12.3 事件环:机制完成,零真实负载检验 **现状**:事件环(区内 segment + eventfd)与 `events.subscribe` 能力已完成, -但**没有一个真实外部插件订阅 `stage` / `tool_call` 事件**,`dropped` 计数 -在长跑下的行为也无人观察。 +但**没有一个真实外部插件订阅 `stage` / `tool_call` 事件**,`dropped` 在长跑下的 +行为无人观察。 **证据**: - `internal/plugin/proc/capability.go:149-150`(subscribe/unsubscribe 已授权) - `internal/plugin/proc/corehandler.go:46-58`(`EvtRingSubscriber` 接口) -- 无对应 example 插件(`third_party/homeagent-sdk/example/` 无事件订阅样例) +- `third_party/homeagent-sdk/example/` 下 21 个 example 中无事件订阅样例 **子任务**: 1. 写一个订阅 `stage` / `tool_call` 事件的 example 插件,跑真实负载 @@ -137,31 +203,30 @@ transport,进程数随插件线性涨。目标是一个 RuntimeManager + 少 --- -### P1-5 §11.2 工具超时措辞误导 + browser 插件超时聚集 +### P1-6 §11.2 工具超时措辞误导 + browser 插件超时聚集 **现状**:内核侧工具超时文案仍写「已取消」,但**进程内 cgo 插件根本取消不了** -(OS 线程永久占用)——这是措辞与事实不符。 +(OS 线程永久占用)——措辞与事实不符。 **证据**: - `internal/agent/core/toolcall.go:42`: `fmt.Sprintf("工具 %s 执行超时(60秒),已取消", tc.Name)` -- `internal/plugin/proc/process.go:27 / :114 / :577`:注释自认 cgo 路径 - 「超时后 OS 线程永久占用,现网已泄漏 26 次」 +- `internal/plugin/proc/process.go` 注释自认 cgo 路径「超时后 OS 线程永久占用」 **子任务**: 1. 措辞改「已放弃等待(插件仍在后台运行,其占用的线程无法回收)」 2. 排查 `browser` 插件为何频繁 60s 超时(旧档记录 22/26 次集中于此); - 真取消能力依赖子进程模型(与 P0-1 相关) + 真取消能力依赖子进程模型(与 P0-3 相关) **验收**: - [ ] 超时文案不再暗示「已取消」 - [ ] browser 超时率下降到可解释水平,或给出根因 -> 与 P0-1 的关系:把内部 cgo 插件也迁到子进程后,这条的严重性自动消除。 +> 与 P0-3 的关系:把内部 cgo 插件也迁到子进程后,这条的严重性自动消除。 --- -### P1-6 §13.11 尾项:`handleAgentAction` 直接 501 +### P1-7 WebUI `handleAgentAction` 直接 501 **现状**:WebUI 的 agent 操作接口**所有 action 一律返回 `501 Not Implemented`**, 是明确的未接线桩。 @@ -169,25 +234,25 @@ transport,进程数随插件线性涨。目标是一个 RuntimeManager + 少 **证据**: - `internal/plugins/webui/handler_agents.go:239`: `writeJSON(w, http.StatusNotImplemented, ..."action not implemented by supervisor")` +- 路由已注册(`handler.go:404` 的 `/api/v1/agents/`) +- **但 UI 未暴露入口**:`dashboard.js` / `cmd/gui/renderer/app.js` 均无对该端点的调用 -**子任务**:决定该端点该做什么——要么接线到 supervisor 的真实动作 -(stop/restart/snapshot 等),要么从 UI 撤掉入口,不要留一个必然失败的按钮。 +**子任务**:决定该端点该做什么——接线到 supervisor 的真实动作 +(stop/restart/snapshot),或**直接删掉路由**(UI 既然没用,留着只是待爆的债)。 **验收**: -- [ ] UI 上不存在「点了必 501」的入口,或该入口真的能动作 - -> 说明:旧档 §13.11 的「11 项」其余各项**已实现**(见 §三.6),仅此一项是真缺口。 +- [ ] `/api/v1/agents//` 要么真的能动作,要么不再存在(无 501 桩) --- -### P2-7 §12.4 Lua 仍走独立 ABI(进程内解释器) +### P2-8 §12.4 Lua 仍走独立 ABI(进程内解释器) -**现状**:Lua 插件在**内核进程内**跑 gopher-lua,不走 proc 通道,是三套 -ABI 里唯一没收敛的。代价是:Lua 插件崩溃 = 内核崩溃,且无共享内存数据面。 +**现状**:Lua 插件在**内核进程内**跑 gopher-lua,不走 proc 通道,是三套 ABI 里 +唯一没收敛的。代价:Lua 插件崩溃 = 内核崩溃,且无共享内存数据面。 **证据**: - `internal/plugin/lua_plugin.go:947`(`makeStageHandler`,进程内) -- `internal/plugin/lua_plugin.go:128 / :329`(`register_stage` 直接注册到内核 SDK) +- `internal/plugin/lua_plugin.go:334 / :1270`(`register_stage` 直接注册到内核 SDK) **子任务**:评估「Lua 走 proc 通道」的代价(解释器进程启动开销 vs 隔离收益), 据此决定收敛还是明确保留为独立 ABI 并写进文档。 @@ -197,101 +262,17 @@ ABI 里唯一没收敛的。代价是:Lua 插件崩溃 = 内核崩溃,且无 --- -### P2-8 §11.5 / §12.5 Windows 只有交叉编译,无真机验证 +## 三、已关闭 / 已实现(旧档误标或本轮更正,防复活) -**现状**:Windows 侧共享内存(`CreateFileMappingW`)、事件通知 -(`CreateEventW`)、命名对象传递均已实现(桩已合回平台中立文件),但 -**从未在 Windows 真机端到端跑过**。 - -**证据**: -- `internal/plugin/dynamic_proc.go:17-27` 注释:平台差异已全部封装, - 桩已删除 -- 无 Windows CI / 真机记录 - -**子任务**: -1. 找一台 Windows 机器跑端到端 -2. **特别验证命名对象的撞名防护**(名字带 PID + 递增序号) - -**验收**: -- [ ] Windows 下 `homed` 能加载外部插件并完成工具调用往返 -- [ ] 并发起多个插件时命名对象不撞 - ---- - -### P2-9 §11.9 homed 主 heap 常驻未解释 - -**现状**:旧档记录 homed 主 heap 有约 **2.36GB 常驻**,来源未定位。 -配置侧已有缓解手段(`embedding_model_path` 支持 `#topN` 限词向量数量), -但**未确认现状是否仍存在**。 - -**证据**: -- `internal/config/registry.go:856`:`embedding_model_path` 描述已写明 - `#topN` 可「控制常驻内存」 -- 无 `GOMEMLIMIT` 相关设置(`grep` 为空) - -**子任务**: -1. 现场 `pprof` 定位常驻来源(是否仍为双模型加载) -2. 若确认,评估是否加 `GOMEMLIMIT` 或默认 `#topN` - -**验收**: -- [ ] 给出常驻内存的构成分解(哪块占多少) -- [ ] 有明确取舍结论(可接受 / 需优化 / 已优化) - ---- - -### P2-10 仓库卫生:三个真实的测试缺陷(3 FAIL) - -**现状**:`go test ./...` 有 **3 个 FAIL**,根因都是**测试自身缺陷**, -不是「环境玄学」,也都可修: - -1. **PTY 用例要求可打开的 `/dev/ptmx`**:容器里节点存在但 `open` 被 - `EACCES` 拒(实测 `PermissionError: [Errno 13]`),而用例**没有环境探测、 - 直接 `t.Fatal`** → 应用 `t.Skip` 或 build tag 隔离,而不是硬 FAIL -2. **测试端口硬编码 `127.0.0.1:9890`** → 并行/残留实例即 `bind: address - already in use`,波及无关用例 -3. **`TestRestoreFileFromBaseline` 写真实系统路径且忽略错误**: - `internal/system/system_test.go:83` 的 target 是 - `"/etc/RestoreFileFromBaseline.test.tmp"`,而用例内 - `os.WriteFile(target, ...)` **不检查 err**(`os.WriteFile(target, []byte("v1"), 0644)`); - 在 `/etc` 不可写的环境(实测本机 root 也被拒)里,前面的写全部静默失败, - 留档内容为空/不存在,到 `RestoreFileFromBaseline` 就报 - `expected restore to happen`。**根因是测试写死真实路径 + 吞错误**, - 不是 `RestoreFileFromBaseline` 实现有问题。 - -**证据**: -- `internal/plugins/integration_test.go:262 / :318 / :376`(PTY 三例) -- `internal/plugins/remotedevice/plugin.go:27`:`const defaultAddr = "127.0.0.1:9890"` - (测试沿用固定端口) -- 实测输出:`open /dev/ptmx: permission denied`、 - `listen tcp 127.0.0.1:9890: bind: address already in use` - -**子任务**: -1. PTY 用例:无 `/dev/ptmx` 时 `t.Skip`(环境能力探测,不静默) -2. remotedevice 相关测试改用 `:0` 让 OS 分配端口,或测试内随机端口 -3. `TestRestoreFileFromBaseline`:改用可写的临时路径(并保留 - `IsProtectedPath` 语义所需的显式前缀,用 `IsProtectedPathExplicit`), - 且**每一步 WriteFile 都检查 err**;顺带审计同类「写真实系统路径」的测试 - -**验收**: -- [ ] 在无 PTY 权限、`/etc` 不可写的环境里,`go test ./...` 全绿 - (受限用例显式 skip,不是静默通过) -- [ ] 重复/并行跑不再端口冲突 - ---- - -## 三、已关闭 / 已实现(旧档误标,防复活) - -逐条给出「旧档怎么说」与「源码实际怎样」,**不要再往待办里加**。 +逐条给出「旧档怎么说」与「实际怎样」。 ### 1. §13.12 L3 原生多模态 —— 已实现 -- 旧档:标「未做」,要求 media 成为一等图节点 + contains/depicts/derived_from 原生边 -- 实际:`internal/memory/graph.go:169` 起建 `memory_blocks`(含 - `modality/payload_digest/mime/vector/fingerprint/scene`)+ - `memory_block_edges`(`source_kind/target_kind/edge_type`), - 以及 `scenes` / `scene_features` / `scene_refs`; - `internal/agent/core/graphmedia.go`(310 行)实现 - `migrateLegacyGraphMedia` / `attachBlocksToSentence` / - `linkBlocksToDocument` / `commitTriplesWithMedia`;`graphmedia_test.go` 21 个测试 +- 实际:`internal/memory/graph.go:169` 起建 `memory_blocks` + (含 `modality/payload_digest/mime/vector/fingerprint/scene`)+ + `memory_block_edges`(`source_kind/target_kind/edge_type`),以及 + `scenes`/`scene_features`/`scene_refs`;`internal/agent/core/graphmedia.go`(310 行) + 实现 `migrateLegacyGraphMedia`/`attachBlocksToSentence`/`linkBlocksToDocument`/ + `commitTriplesWithMedia`;`graphmedia_test.go` 21 个测试。 - 结论:**关闭** ### 2. §13.9 llmsproxy 上下文溢出感知 —— 不属本仓(见 §五.1) @@ -299,42 +280,65 @@ ABI 里唯一没收敛的。代价是:Lua 插件崩溃 = 内核崩溃,且无 ### 3. §13.10 AgentMail 三个 bug —— 不属本仓(见 §五.2) ### 4. §11.4 Lua stage 快照缺读锁(DATA RACE)—— 已修 -- 旧档:要求加 `sc.RLock()`/`sc.RUnlock()` 包裹快照构造 -- 实际:`internal/plugin/proc/shmcodec.go:42 / :326 / :425` 已在 - `captureLocal` / `WriteDirty` 前后持读锁 -- 实测:`go test -race ./internal/lua/... ./internal/plugin/...` 全绿 +- 实际:`internal/plugin/proc/shmcodec.go:42` 的 `WriteAll` 已在 `captureLocal` + 前后持 `sc.RLock()/RUnlock()`;`go test -race ./internal/lua/... ./internal/plugin/...` 全绿 - 结论:**关闭** ### 5. §12.2 `io.setToolBlocks` 内核侧是桩 —— 已实现 -- 旧档:要求实现内核侧 handler + 补 example - 实际:`internal/plugin/proc/corehandler_inject.go:132` 实现 `MethodIOSetToolBlocks`;模板 `putArena` → `blocks_ref`; `e2e_template_test.go:371` 用**真实 SDK 模板**验证 - 结论:**关闭** -### 6. §13.11 WebUI 修复清单 —— 主体已实现,仅剩 P1-6 -逐项核实:`Last-Event-ID` 重放(`handler_chat.go:710`)、请求超时 -(`handler_chat.go:592` 等 300s)、XSS 消毒(`dashboard.js:252` DOMPurify)、 -`renderAll` 增量(`dashboard.js:34 / :452 / :1361` 增量游标 + 流式增量)、 -`handleKnowledge` 不再吞错(`handler_memory.go:122` 起逐分支返回错误)、 -CSS/DesignSystem(`dashboard.css:3` 起 sakura/frost 令牌)、 -GUI 重构(`cmd/gui/renderer/app.js`)—— 上述均已落地。 -**仅 `handleAgentAction` 501 是真缺口**(已列 P1-6)。 +### 6. §13.11 WebUI 修复清单 —— 主体已实现,仅剩 P1-7 +- 逐项核实:`Last-Event-ID` 重放(`handler_chat.go:710`)、请求超时 + (`handler_chat.go:592` 等 300s)、XSS 消毒(`dashboard.js:252` DOMPurify)、 + `renderAll` 增量(`dashboard.js:34 / :452 / :1361` 增量游标 + 流式增量)、 + `handleKnowledge` 不再吞错(`handler_memory.go:122` 起逐分支返回错误)、 + CSS/DesignSystem(`dashboard.css:3` 起 sakura/frost 令牌)、 + GUI 重构(`cmd/gui/renderer/app.js`)—— 均已落地。 +- 仅 `handleAgentAction` 501 是真缺口(已列 P1-7) -### 7. §12.5 Windows 桩未删 / 旧档称「交叉编译通过」 —— 已收敛 -- 实际:`internal/plugin/dynamic_proc.go` 已合为平台中立单文件, - 平台差异封装在 `shmalloc_*` / `evtfd_*` / `shmpass_*` / `procattr_*`(各带 - 构建标签);旧桩已删。**真机验证仍缺**(已列 P2-8) +### 7. Windows 支持 —— **已设计性放弃**(旧档 P2-8 与 C 化 §2.3 的前提均据此更正) +- 旧档说:「Windows 桩已收敛,但缺真机验证」(把它当待办) +- 实际:`cmd/homed/platform_windows.go` 明确**原生 Windows 拒绝启动**并给 WSL2 指引。 + 原因写入注释:插件体系依赖「继承的 fd」+「统一共享内存区的段内偏移解引用」, + Windows 句柄模型无法表达;强适等于再维护一套平台专属 ABI(C ABI 时代三套 ABI + 并存曾致改写型插件静默失效)。 +- 配套:`internal/plugin/proc/shmalloc_windows.go` 的 `allocShm` 直接报错不返回半可用段; + `deploy/packaging/windows/install-via-wsl.ps1`(新)引导 WSL2 并复用 Linux 包; + `build.sh` windows 目标**只构建 waiter + gui**,homed/initconfig 明确拒绝 + (见 `build.sh:257-267`)。 +- 实测佐证:`GOOS=windows GOARCH=amd64 CGO_ENABLED=0 go build ./cmd/waiter` 成功 + (12MB .exe);`homed` 无论如何都编不出 Windows(`internal/memory` 依赖 cgo-only 的 + `gojieba`)。 +- 结论:**关闭**(该项不是待办;Windows 的正确验收 = WSL2 内按 Linux 路径跑, + 与 Linux 目标同一条流水线) -### 8. §12.7 `cmd/ohos/.../SettingsPage.ets` 有未提交改动 —— 已提交 -- 实际:`git status --short` 于工作区全清(含 `cmd/ohos/`) +### 8. 仓库卫生:PTY 三例的 FAIL —— 环境相关 + skip 判据失效(旧档 P2-10) +- 旧档说:`go test ./...` 有 3 个 FAIL(PTY 三例 / 端口 9890 冲突 / `system_test` 写 `/etc`) +- 本轮实测(分时)时: + - `go test -count=1 ./...` → **57 包:38 ok + 19 无测试文件 + 0 FAIL** + - PTY 三例**本就有 skip 意图**(`integration_test.go:284/337/395` 的 + `t.Skipf("PTY not available: ...")`),且 `/dev/ptmx` 可用时正常通过(连跑 3 次均 ok) + - **但该 skip 的判据是坏的**:它查 `resp["status"] == "error"`,而插件失败时返回的是 + `{"error": "创建终端失败: ..."}`(`internal/plugins/agentcli/plugin.go:496`)—— + **键名不匹配**,于是真遇到无 PTY 权限的环境会走到 `t.Fatalf` 而非 skip。 + 这是「探测存在但失效」的典型:比没有探测更隐蔽 + - `TestRestoreFileFromBaseline` 在 `/etc` 可写时 **PASS**(`system_test.go:83` 确实 + 写真实路径且不检查 err——**代码确实不干净**,但它不构成「稳定 FAIL」) + - `9890` 端口**确有占用**(本机 homed 常驻监听),但测试用 `setupIntegration` + 起的实例未与之冲突(连跑 3 次均 ok) +- 结论:**旧档的记录在当时是真的**(环境退化:ptmx 无权限 + 端口被占), + 环境恢复后自然全绿。但**两个真缺陷存留**:① skip 判据键名不匹配(探测失效, + 退化时硬 FAIL);② 测试依赖固定端口。两条已列 §六,不列为「待办功能项」。 --- ## 四、生产部署后验证(代码已就绪,本就无法在仓库内完成) 这些**不是待开发项**,是「必须落到生产实例才能确认」的验收。仓库内有 -脚本 `scripts/verify_deploy.sh [data_dir]` 可一键检查前两条。 +`scripts/verify_deploy.sh [data_dir]` 可一键检查前两条。 - [ ] **§0.1 healthcheck 隔离**:部署后 `knowledge/`、`memory/graph.db`、 `memory/documents/` 不再出现 `_hc_*` 残留 @@ -347,40 +351,92 @@ GUI 重构(`cmd/gui/renderer/app.js`)—— 上述均已落地。 ## 五、跨项目工单(**不属本仓**,旧档误并入) -旧 plan.md 把别仓的工单写成本仓 TODO,导致「查无此代码却挂着未完成」。 -移出并说明归属: +旧 plan.md 把别仓的工单写成本仓 TODO,导致「查无此代码却挂着未完成」。移出并说明归属: ### 1. §13.9「llmsproxy 上下文溢出感知」 -- 旧档写:补 `OVERFLOW_PATTERNS`("Context window is full")、 - AUTO 截断宽度 `80→160`、`go test ./internal/ai/...` -- 事实:本仓**没有 `internal/ai/`**;`OVERFLOW_PATTERNS` / - `clientUpstreamErr` / `OneLine(...,80)` 都在 **`/home/program/llmsproxy`** - (`internal/gateway/chat.go`) -- 现状:该仓已把宽度改成 160(`chat.go:634` 注释「160 而不是 80」) -- 归属:**llmsproxy 仓**,与本仓无关 +- 旧档写:补 `OVERFLOW_PATTERNS`("Context window is full")、AUTO 截断宽度 `80→160`、 + `go test ./internal/ai/...` +- 事实:本仓**没有 `internal/ai/`**;相关符号在 **`/home/program/llmsproxy`** + (`internal/gateway/chat.go`)。本仓 `internal/agent/api/provider.go` 只**消费** + 该网关(注释里提到 "llmsproxy 的 AUTO 链",:361) +- 现状:该仓已把宽度改成 160 +- 归属:**llmsproxy 仓** ### 2. §13.10「AgentMail 三个 bug」 - 旧档写:提示词修正 / `InReplyTo` / `relay_key ≤ 64 字节` - 事实:AgentMail 是独立仓 **`/home/program/agentmail`** - (`relay_key` 见 `server/internal/handler/permission.go:32 / :90 / :382`) -- 现状:`relay_key` 上限已实现为 **160 字节**并带测试 - (`permission.go:90`、`relay_test.go:55`),`in_reply_to` 语义见 - `forward.go:225` + (`relay_key` 见 `server/internal/handler/permission.go`)。本仓 + `grep relay_key\|InReplyTo` **零命中** - 归属:**agentmail 仓** > 若这两仓也要纳入统一管理,应各自建 plan,不要塞进本仓文档。 --- -## 六、明确「不做」的决定(避免反复挂账) +## 六、明确「不做」与「建议修但不阻塞」 -- **§13.13 反向结果入共享内存**:原设想把 `doc.query` / `llm.chat` 的大结果 - 也搬进段。核实后:**本仓根本不存在 `llm.chat`**(`llm.*` 只映射 - listSources/setSource/currentSource);唯一可能返回大结果的 `doc.query` - 被 `CapDocMemory` 能力门挡着,且无任何外部插件使用。**不做**, - 而不是留成永久 TODO。 +### 不做(防反复挂账) + +- **§13.13 反向结果入共享内存**:本仓**不存在 `llm.chat`**(`llm.*` 只映射 + listSources/setSource/currentSource);唯一可能返回大结果的 `doc.query` 被 + `CapDocMemory` 能力门挡着,且无外部插件使用。**不做**。 +- **Windows 原生适配**:见 §三.7,**设计上不做**。 + +### 建议修但不阻塞(测试卫生,非当前 FAIL) + +- `internal/system/system_test.go:83`:`target := "/etc/RestoreFileFromBaseline.test.tmp"` + 写真实系统路径,且两处 `os.WriteFile(...)` **不检查 err** → 在 `/etc` 不可写的 + 环境里静默失败,报 `expected restore to happen`(根因是测试,不是实现)。 + 建议改用 `t.TempDir()` + 保留 `IsProtectedPath` 语义所需的显式前缀,并检查每步 err。 +- `internal/plugins/remotedevice/plugin.go:27` 固定端口 `127.0.0.1:9890`:测试沿用该 + 默认值,本机已有 homed 常驻监听。建议测试改用 `:0` 让 OS 分配。 +- **PTY 三例的 skip 判据是坏的(真缺陷,不只是卫生)**:`integration_test.go` + 284/337/395 查 `resp["status"] == "error"`,而 `terminal_create` 失败时返回 + `{"error": "创建终端失败: ..."}`(`internal/plugins/agentcli/plugin.go:496`)—— + 键名不匹配 ⇒ 真遇到无 PTY 权限的环境**会 `t.Fatalf` 而非 skip**。 + 同类型:其他依赖工具错误响应的环境探测(应统一认 `error` 键)。 +- 同类审计:其他「写真实系统路径且吞错误」的测试。 --- -*重写时间:2026-09-24 · 核实方式:源码 grep / build / test / race* -*旧档备份于 `/tmp/plan.md.bak-*`(仅作对照,内容已判定过时)* +## 七、C 化:已定事项与待拍板事项 + +第一刀(L1 纯函数层)已落地并闭环(见 §二 P0-1 与 +`docs/zh/c-core/llm-orchestration-c.md`)。下阶段扩大前,有三处**需 jianf 拍板**: + +1. **C 实现放主仓 `csrc/` 还是 SDK `third_party/homeagent-sdk/`?** + - 当前已在主仓 `csrc/`(`ha_codec.{c,h}` 是权威源,Go 侧符号链接过去) + - 若 SDK / 鸿蒙 / C SDK 侧也要复用,需定同步机制;搬进 SDK 则要走 SDK 冻结流程 +2. **`ha_json.c` 复用还是新写?**(复用会动 SDK 目录结构) + - 这是下一个切片的**前置**:L1 剩下的协议编解码(`parseOpenAICompatible*`、 + `normalize*ToolCalls`)全部依赖 JSON 解析,不定就推不下去 +3. **是否接受「C 化后内核体积/构建复杂度上升」换延迟确定性?** + - 需先有真实延迟基线(当前没有),否则是空头承诺 + - 尤其 `EstimateTokens` 是**高频热路径**(上下文裁剪对每个事件都调), + 跨语言开销对短文本未必划算;它是第一个该拿数据说话的候选 + +### 已定事项(不再挂账) + +- **回退路径保留**:用 `cgo` / `!cgo` 一组约束,**不引入额外 tag**。 + 理由:ha_codec 是零依赖纯 C99 源码内联编译(不需外部库/工具链), + 而 homed 本就强制 cgo(sqlite3 + gojieba),故 C 路径自然生效。 + + **回退路径当前的真实受益者**(实测,不要夸大): + - `CGO_ENABLED=0 go build ./...` / `go vet ./...` 能遍历含 `internal/agent/api` + 的包——若无 `codec_nocgo.go`,这些命令会因找不到 `modelContextWindowC` + 等符号而**整包编译失败**。这是当前主要价值。 + - ★ 需知道的事实:`go list -deps` 实测**只有 `cmd/homed` 依赖 + `internal/agent/api`**;`waiter` / `initconfig` / `memgc` / `mock-server` + 均不依赖。而 homed 强制 cgo ⇒ **`!cgo` 分支目前无任何生产目标在用**。 + 保留它是为了「包在 CGO_ENABLED=0 下仍可编译」与未来 CGO-free 目标, + 而不是因为现有什么目标需要它。 + - 黄金对照的**基准**不靠它:`codec_pure.go` **无 build tag**、永远参与编译。 +- **不链接预构建 `.a`,也不用包外 `#include`**(前者架构错 + 产物两头空, + 后者缓存漏跟踪)。用包内符号链接,理由与实测见 §二 P0-1 与设计文档 §2.4。 + +--- + +*重写时间:2026-09-25 · 核实方式:源码 grep / go build / go test -count=1 / +交叉编译对照试验 / 双构建试验 / 变异测试* +*旧档备份:`/tmp/plan.md.bak-20260925-124700`(上一版)、 +`/tmp/plan.md.bak-20260924-171420`(历史 1474 行混合档)*