mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-09-26 12:23:23 +00:00
feat(c-core): 内核编解码层 C 化第一刀 —— L1 纯函数层落地并打通构建链
第一刀只做三个纯函数(窗口推断 / token 估算 / token 截断), 价值不在功能(Go 版没问题),而在打通「Go → cgo → C」全链路并 建立可复现的对照范式,后面每扩一个函数都复用它。 ## 为什么是这三个 按「无状态 → 有状态」分层,L1 协议编解码最安全:纯 string in → struct out, 不碰网络、不碰 Lua、不碰 goroutine。三个函数更是同一组纯算术,最小可验证切片。 ## 关键设计:包内符号链接,不链接静态库、不 include 包外源 三种做法都实测过,只有一种同时满足「可构建 + 可交叉编译 + 缓存可跟踪」: 1. ❌ 链接 `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` 2. ❌ `#include "../../../csrc/src/ha_codec.c"`(包外相对包含) ★ Go 构建缓存**不跟踪包外被 #include 的 C 文件**。实测:包外源把返回值 7→8,`go test` 依然通过(缓存命中、静默沿用旧代码);同样改动落在包内 文件时立即判红。对「逐步推进 C 化」这是致命的——改 C 源码不生效且无报错。 (包内 shim `#include` 包外源同样漏跟踪,已实测排除。) 3. ✅ 包内符号链接 `internal/agent/api/ha_codec.{c,h}` → `csrc/` 文件在包目录内 ⇒ 缓存按内容正确跟踪;只有一份权威源 ⇒ 无副本漂移, 也不需要「同步 C 源」的 make 目标。 ## 不需要额外 build tag ha_codec 是零依赖纯 C99 源码内联编译,不需要外部库或工具链前提; 而 homed 本就强制 cgo(sqlite3 + gojieba),故 C 路径自然生效。 只用 `cgo` / `!cgo` 一组约束(对比 onnxruntime:那个需运行期 .so,故必须显式 tag)。 ## 顺带修掉的既有缺陷(非 C 化引入,但一直缺覆盖) - Makefile 的 arm64 目标缺 CC/CXX:cgo 回退到宿主 g++,报 `gcc_arm64.S: no such instruction: 'stp x29,x30,[sp,'` (deploy/packaging/build.sh:49 一直是对的,Makefile 漏了) - Makefile 的 arm64 目标缺 .syso 隔离:cmd/{homed,waiter}/*.syso 是 Windows COFF 资源对象,Go 会把同目录 .syso 无条件链进任何目标,交叉到非 Windows 平台报 `file format not recognized`(build.sh 有 hide_syso_for_target) ## 验证(每条可复现) - `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 源(131072→777)后**同一缓存**下 go test 立即 FAIL(改前:仍报 ok) - `make check-codec-paths` 两条路径 OK;`make csrc-test` C 契约测试 100% - 黄金对照:手写用例 + 2000 次随机对拍,C 与纯 Go 逐值相等 - 全量 `go test -count=1 ./...` → 57 包:38 ok + 19 无测试 + 0 FAIL 新增 `make check-codec-paths` 防回归:只测一条路径时,另一条的破坏不会被发现。 ## 文档 - `docs/zh/c-core/llm-orchestration-c.md` 同步为「已落地」,并更正因 「Windows 原生已放弃」而过时的 §2.3(回退路径的理由需重述) - plan.md 的 P0-1 标记为已修复,附实测证据
This commit is contained in:
89
Makefile
89
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
|
||||
|
||||
57
csrc/CMakeLists.txt
Normal file
57
csrc/CMakeLists.txt
Normal file
@ -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()
|
||||
67
csrc/include/ha_codec.h
Normal file
67
csrc/include/ha_codec.h
Normal file
@ -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 <stddef.h>
|
||||
|
||||
#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 */
|
||||
191
csrc/src/ha_codec.c
Normal file
191
csrc/src/ha_codec.c
Normal file
@ -0,0 +1,191 @@
|
||||
/*
|
||||
* ha_codec.c — HomeAgent 内核编解码层(C 实现)
|
||||
*
|
||||
* 第一个最小切片:模型窗口推断 + token 估算/截断。
|
||||
* 语义必须与 Go 侧实现逐值一致,由黄金对照测试钉死。
|
||||
*/
|
||||
|
||||
#include "ha_codec.h"
|
||||
|
||||
#include <ctype.h>
|
||||
#include <stdlib.h>
|
||||
#include <string.h>
|
||||
|
||||
/* ---------------------------------------------------------------- */
|
||||
/* 小工具 */
|
||||
/* ---------------------------------------------------------------- */
|
||||
|
||||
/* 在 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;
|
||||
}
|
||||
131
csrc/test/test_ha_codec.c
Normal file
131
csrc/test/test_ha_codec.c
Normal file
@ -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 <stdio.h>
|
||||
#include <string.h>
|
||||
|
||||
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;
|
||||
}
|
||||
287
docs/zh/c-core/llm-orchestration-c.md
Normal file
287
docs/zh/c-core/llm-orchestration-c.md
Normal file
@ -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*
|
||||
43
internal/agent/api/codec.go
Normal file
43
internal/agent/api/codec.go
Normal file
@ -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.<name>.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.<name>.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) }
|
||||
97
internal/agent/api/codec_cgo.go
Normal file
97
internal/agent/api/codec_cgo.go
Normal file
@ -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 <stdlib.h>
|
||||
#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))
|
||||
}
|
||||
115
internal/agent/api/codec_golden_test.go
Normal file
115
internal/agent/api/codec_golden_test.go
Normal file
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
22
internal/agent/api/codec_nocgo.go
Normal file
22
internal/agent/api/codec_nocgo.go
Normal file
@ -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) }
|
||||
103
internal/agent/api/codec_pure.go
Normal file
103
internal/agent/api/codec_pure.go
Normal file
@ -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])
|
||||
}
|
||||
1
internal/agent/api/ha_codec.c
Symbolic link
1
internal/agent/api/ha_codec.c
Symbolic link
@ -0,0 +1 @@
|
||||
../../../csrc/src/ha_codec.c
|
||||
1
internal/agent/api/ha_codec.h
Symbolic link
1
internal/agent/api/ha_codec.h
Symbolic link
@ -0,0 +1 @@
|
||||
../../../csrc/include/ha_codec.h
|
||||
@ -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.<name>.context_window 声明真实值即可覆盖。
|
||||
log.Printf("[provider] 模型 %q 无法推断上下文窗口,回退 %d;"+
|
||||
"若真实窗口更大,请设置 core.llm.sources.<name>.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"`
|
||||
|
||||
@ -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)。
|
||||
// 上方已有同名转发,此处不再重复定义。
|
||||
|
||||
468
plan.md
468
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/<id>/<action>` 要么真的能动作,要么不再存在(无 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 行混合档)*
|
||||
|
||||
Reference in New Issue
Block a user