mirror of
https://gitcode.com/JianFeeeee/HomeAgent.git
synced 2026-10-15 12:15:16 +00:00
## 背景 仓库早已迁移到 GitHub(`github.com/JianFeeeee/HomeAgent`,CI/CD 都在那边跑), gitcode 保留为**国内只读镜像**(`ci.yml` 的 sync-gitcode-main 快进推送)。 但 go.mod 的 module 路径一直是 `gitcode.com/JianFeeeee/...` —— 既与权威源 不符,`meta.SourceURL`(AGPL §13 的源码提供入口)也指向镜像而非实际运行的代码。 ## 关键区分(这是本次改动安全的前提) 两个 module 是**不同**的,影响面差一个数量级: | module | 谁依赖 | 改它的后果 | |---|---|---| | `.../HomeAgent`(主仓) | **只有主仓自己** | 内部重构,零外部影响 | | `.../homeagent-sdk`(SDK 仓) | 主仓 49 文件 + 所有外部插件 | 需重编插件 | ★ 实测确认:**插件不 import 主仓 module**(`strings qq/plugin.bin | grep HomeAgent` = 0), 只依赖 SDK 仓。而插件与内核的边界是 **RPC 方法名字符串** (`plugin.init` / `tool.invoke`…,见 proc/protocol.go),**与 Go module 名无关**。 ⇒ module 改名**不改变任何 wire 协议**,现网已装的插件照常运行。 SDK 的 GitHub 仓名是 `homeagentsdk`(无连字符),module 路径必须与之精确匹配, 否则 `go get` 会因路径不符失败,故取 `github.com/JianFeeeee/homeagentsdk`。 ## 改动 - `go.mod` / `go.sum`:两个 module 路径 + require/replace - 277 个 Go 文件 + Makefile + 打包脚本的 import 与 ldflags 路径 - SDK 仓 `meta.CoreModule`、`tools/hmapdev` 的 module 声明与工程模板 - 对外文档/链接的仓库地址(README、PLUGIN_DEV、OVERVIEW 等) ## 明确**保留** gitcode 的地方(镜像机制,不能动) - `GITCODE_TOKEN`、`GC_URL="…@gitcode.com"`(CI 同步 job) - `gitcode.com/api/v5/...`(release 附件上传) - git remote `gitcode`(镜像拉取/推送) - `docs/zh/ci-cd-runbook.md`(整篇讲镜像运维) ## 验证 - `go build ./...` / `go vet ./...` 绿 - `go test ./... -count=1` → 48 包 ok / 0 FAIL - hmapdev 工具链编译通过(`third_party/homeagent-sdk/tools/hmapdev`) - 全仓残留检查:module 形态的 `gitcode.com/JianFeeeee` = 0 - 镜像机制抽验:GC_URL / @gitcode / api/v5 / remote 均完好 ## 顺带修正 `hmapdev/templates.go` 的注释此前写「gitcode 的模块不在 proxy.golang.org 上」—— module 已迁 GitHub,该论断不再成立。改为如实说明**为何仍默认 replace 到本机 SDK**: 离线可构建、避免版本漂移、内网可用。理由与托管地无关,故 replace 保留。
319 lines
11 KiB
Go
319 lines
11 KiB
Go
package core
|
||
|
||
import (
|
||
"fmt"
|
||
"strings"
|
||
"sync"
|
||
"sync/atomic"
|
||
"testing"
|
||
"time"
|
||
|
||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
// 阶段 2d:批次调度与保序。
|
||
//
|
||
// 三条规则:
|
||
// 1. 全批 ParallelSafe ⇒ 并发;否则**整批**降级串行(不做部分并发——
|
||
// 收益不抵不可预测性)。
|
||
// 2. 同一 output_send__<通道> 的多次发送**保序**(用户可见消息顺序敏感)。
|
||
// 3. 并发时每个工具只写自己的 per-tool ctx(这同时闭合 2c 的判据缺口)。
|
||
|
||
// slowDevice 是一个"可并发"的测试设备:每个 Execute 阻塞到被显式放行,
|
||
// 用来观察多个工具是否**同时**在执行中。
|
||
type slowDevice struct {
|
||
name string
|
||
toolNames []string
|
||
safe []string // 声明为 ParallelSafe 的工具名
|
||
entered *int32
|
||
active *int32
|
||
maxActive *int32
|
||
release chan struct{}
|
||
delay time.Duration
|
||
}
|
||
|
||
func (d *slowDevice) Name() string { return d.name }
|
||
func (d *slowDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||
func (d *slowDevice) Description() string { return "slow test device" }
|
||
func (d *slowDevice) Tools() []agentIO.ToolDef {
|
||
safe := map[string]bool{}
|
||
for _, n := range d.safe {
|
||
safe[n] = true
|
||
}
|
||
out := make([]agentIO.ToolDef, 0, len(d.toolNames))
|
||
for _, n := range d.toolNames {
|
||
out = append(out, agentIO.ToolDef{Name: n, ParallelSafe: safe[n]})
|
||
}
|
||
return out
|
||
}
|
||
|
||
func (d *slowDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||
atomic.AddInt32(d.entered, 1)
|
||
cur := atomic.AddInt32(d.active, 1)
|
||
// 记录并发峰值
|
||
for {
|
||
old := atomic.LoadInt32(d.maxActive)
|
||
if cur <= old || atomic.CompareAndSwapInt32(d.maxActive, old, cur) {
|
||
break
|
||
}
|
||
}
|
||
if d.release != nil {
|
||
<-d.release // 阻塞,直到测试放行
|
||
} else if d.delay > 0 {
|
||
time.Sleep(d.delay)
|
||
}
|
||
atomic.AddInt32(d.active, -1)
|
||
return "ran:" + tool, nil
|
||
}
|
||
func (d *slowDevice) Start() error { return nil }
|
||
func (d *slowDevice) Stop() error { return nil }
|
||
func (d *slowDevice) OutputCapabilities() agentIO.OutputCapability { return agentIO.CapText }
|
||
func (d *slowDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||
|
||
// ① 并发:全批 ParallelSafe ⇒ 同一时刻有多个工具在执行。
|
||
func TestBatchParallelWhenAllToolsParallelSafe(t *testing.T) {
|
||
var entered, active, maxActive int32
|
||
release := make(chan struct{})
|
||
names := []string{"p_a", "p_b", "p_c"}
|
||
|
||
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
|
||
{ToolCalls: []agentAPI.ToolCall{
|
||
{ID: "c1", Name: "p_a", Arguments: map[string]interface{}{}},
|
||
{ID: "c2", Name: "p_b", Arguments: map[string]interface{}{}},
|
||
{ID: "c3", Name: "p_c", Arguments: map[string]interface{}{}},
|
||
}},
|
||
{Content: "final"},
|
||
}}
|
||
a := newParallelAgent(t, sp, names, names, &entered, &active, &maxActive, release)
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
defer close(done)
|
||
if out := a.runTaskSteps(a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))); out != outcomeDone {
|
||
t.Errorf("runTaskSteps=%v", out)
|
||
}
|
||
}()
|
||
|
||
// 等到**至少两个**同时进入执行;若串行则永远只有一个,会超时。
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for atomic.LoadInt32(&maxActive) < 2 && time.Now().Before(deadline) {
|
||
time.Sleep(5 * time.Millisecond)
|
||
}
|
||
close(release)
|
||
<-done
|
||
|
||
if got := atomic.LoadInt32(&maxActive); got < 2 {
|
||
t.Errorf("全批 ParallelSafe 却未并发(并发峰值=%d,应 >=2)", got)
|
||
}
|
||
if entered != int32(len(names)) {
|
||
t.Errorf("应执行 %d 个工具,实际 %d", len(names), entered)
|
||
}
|
||
}
|
||
|
||
// ② 整批降级:只要有一个**非** ParallelSafe ⇒ 整批串行(不做部分并发)。
|
||
func TestBatchFallsBackToSerialIfAnyToolNotParallelSafe(t *testing.T) {
|
||
var entered, active, maxActive int32
|
||
release := make(chan struct{})
|
||
names := []string{"s_a", "s_b", "s_c"}
|
||
|
||
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
|
||
{ToolCalls: []agentAPI.ToolCall{
|
||
{ID: "c1", Name: "s_a", Arguments: map[string]interface{}{}},
|
||
{ID: "c2", Name: "s_b", Arguments: map[string]interface{}{}},
|
||
{ID: "c3", Name: "s_c", Arguments: map[string]interface{}{}},
|
||
}},
|
||
{Content: "final"},
|
||
}}
|
||
// 只声明前两个可并发 —— 第三个不声明 ⇒ 整批必须串行
|
||
a := newParallelAgent(t, sp, names, names[:2], &entered, &active, &maxActive, release)
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
defer close(done)
|
||
a.runTaskSteps(a.newTaskFrame("go", a.stageCtxFromInput("go", "", "")))
|
||
}()
|
||
// 给串行留出充分时间:让第一个工具走完并进入第二个
|
||
time.Sleep(150 * time.Millisecond)
|
||
observed := atomic.LoadInt32(&maxActive)
|
||
close(release)
|
||
<-done
|
||
|
||
if observed > 1 {
|
||
t.Errorf("存在非 ParallelSafe 工具时不应部分并发(并发峰值=%d)", observed)
|
||
}
|
||
if atomic.LoadInt32(&entered) != int32(len(names)) {
|
||
t.Errorf("整批仍应全部执行,实际 %d", entered)
|
||
}
|
||
}
|
||
|
||
// ③ 保序:同一 output_send__<通道> 的多次发送**必须**按声明顺序到达。
|
||
//
|
||
// 这是用户可见的语义:同一条通道连发 3 条消息,顺序颠倒用户就读错了。
|
||
func TestBatchSameOutputChannelKeepsOrder(t *testing.T) {
|
||
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
|
||
{ToolCalls: []agentAPI.ToolCall{
|
||
{ID: "c1", Name: "output_send__testch", Arguments: map[string]interface{}{"payload": "first"}},
|
||
{ID: "c2", Name: "output_send__testch", Arguments: map[string]interface{}{"payload": "second"}},
|
||
{ID: "c3", Name: "output_send__testch", Arguments: map[string]interface{}{"payload": "third"}},
|
||
}},
|
||
{Content: "final"},
|
||
}}
|
||
a := newPreemptAgent(t, sp)
|
||
|
||
var mu sync.Mutex
|
||
var got []string
|
||
dev := &recordingDevice{name: "testch", caps: agentIO.CapText,
|
||
tools: []agentIO.ToolDef{{Name: "output_send__testch"}}}
|
||
dev.onExec = func(payload string) {
|
||
mu.Lock()
|
||
got = append(got, payload)
|
||
mu.Unlock()
|
||
// 每条之间加延迟:若并发执行,顺序会被打乱
|
||
time.Sleep(20 * time.Millisecond)
|
||
}
|
||
if err := a.io.RegisterDevice(dev); err != nil {
|
||
t.Fatalf("注册设备失败: %v", err)
|
||
}
|
||
|
||
if out := a.runTaskSteps(a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))); out != outcomeDone {
|
||
t.Fatalf("runTaskSteps=%v", out)
|
||
}
|
||
want := []string{"first", "second", "third"}
|
||
if len(got) != len(want) {
|
||
t.Fatalf("应发送 %d 条,实际 %d(%v)", len(want), len(got), got)
|
||
}
|
||
for i := range want {
|
||
if got[i] != want[i] {
|
||
t.Errorf("第 %d 条应是 %q,实际 %q —— 同通道发送未保序(完整 %v)", i, want[i], got[i], got)
|
||
}
|
||
}
|
||
}
|
||
|
||
// recordingDevice 记录 output 工具的 payload 顺序。
|
||
type recordingDevice struct {
|
||
name string
|
||
caps agentIO.OutputCapability
|
||
tools []agentIO.ToolDef
|
||
onExec func(payload string)
|
||
}
|
||
|
||
func (d *recordingDevice) Name() string { return d.name }
|
||
func (d *recordingDevice) Type() agentIO.DeviceType { return agentIO.DeviceOutput }
|
||
func (d *recordingDevice) Description() string { return "recording test device" }
|
||
func (d *recordingDevice) Tools() []agentIO.ToolDef { return d.tools }
|
||
func (d *recordingDevice) Execute(tool string, args map[string]interface{}) (interface{}, error) {
|
||
if d.onExec != nil {
|
||
p, _ := args["payload"].(string)
|
||
d.onExec(p)
|
||
}
|
||
return map[string]interface{}{"status": "sent"}, nil
|
||
}
|
||
func (d *recordingDevice) Start() error { return nil }
|
||
func (d *recordingDevice) Stop() error { return nil }
|
||
func (d *recordingDevice) OutputCapabilities() agentIO.OutputCapability { return d.caps }
|
||
func (d *recordingDevice) ChannelDef() agentIO.ChannelDef { return agentIO.ChannelDef{} }
|
||
|
||
// newParallelAgent 建一个带 slowDevice 的 agent。
|
||
func newParallelAgent(t *testing.T, sp agentAPI.Provider, names, safe []string,
|
||
entered, active, maxActive *int32, release chan struct{}) *Agent {
|
||
t.Helper()
|
||
a := New(AgentConfig{
|
||
ID: "paragent",
|
||
Provider: sp,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
IO: agentIO.NewIOManager(),
|
||
StageHost: NewStageHost(),
|
||
})
|
||
if err := a.io.RegisterDevice(&slowDevice{
|
||
name: "slowdev", toolNames: names, safe: safe,
|
||
entered: entered, active: active, maxActive: maxActive, release: release,
|
||
}); err != nil {
|
||
t.Fatalf("注册设备失败: %v", err)
|
||
}
|
||
return a
|
||
}
|
||
|
||
// 阶段 2c 判据的**并发版**(闭合此前"串行下测不出差别"的缺口)。
|
||
//
|
||
// 此前两条 2c 判据在串行路径下无法区分「per-tool ctx」与「单槽」——
|
||
// 变体验证(toolCtxFor 退回单槽)后仍然全绿。差别只在并发下显现。
|
||
// 本判据在**真实并发批次**下断言:
|
||
//
|
||
// · 每个工具的 before_toolcall ctx 只带自己的 ToolCalls[0].Name
|
||
// · after_toolcall 读到的 Result 属于当前工具,不是批内另一个的
|
||
// · 全程 -race 无数据竞争
|
||
func TestBatchConcurrentEachToolSeesOwnContext(t *testing.T) {
|
||
names := []string{"k_a", "k_b", "k_c", "k_d"}
|
||
var entered, active, maxActive int32
|
||
release := make(chan struct{})
|
||
|
||
sp := &batchProvider{responses: []*agentAPI.CompletionResponse{
|
||
{ToolCalls: []agentAPI.ToolCall{
|
||
{ID: "c1", Name: names[0], Arguments: map[string]interface{}{}},
|
||
{ID: "c2", Name: names[1], Arguments: map[string]interface{}{}},
|
||
{ID: "c3", Name: names[2], Arguments: map[string]interface{}{}},
|
||
{ID: "c4", Name: names[3], Arguments: map[string]interface{}{}},
|
||
}},
|
||
{Content: "final"},
|
||
}}
|
||
a := newParallelAgent(t, sp, names, names, &entered, &active, &maxActive, release)
|
||
|
||
var mu sync.Mutex
|
||
beforeNames := map[string]string{}
|
||
crossTalk := map[string]string{}
|
||
|
||
a.stageHost.RegisterStage(sdk.StageBeforeToolcall, func(ctx *sdk.StageContext) error {
|
||
if len(ctx.ToolCalls) != 1 {
|
||
t.Errorf("并发下 before_toolcall 的 ctx 应只带 1 个 ToolCall,实际 %d", len(ctx.ToolCalls))
|
||
}
|
||
name := ""
|
||
if len(ctx.ToolCalls) > 0 {
|
||
name = ctx.ToolCalls[0].Name
|
||
}
|
||
mu.Lock()
|
||
if prev, dup := beforeNames[name]; dup {
|
||
crossTalk["dup:"+name] = "与 " + prev + " 撞名"
|
||
}
|
||
beforeNames[name] = name
|
||
mu.Unlock()
|
||
return nil
|
||
})
|
||
a.stageHost.RegisterStage(sdk.StageAfterToolcall, func(ctx *sdk.StageContext) error {
|
||
if len(ctx.ToolResults) == 0 {
|
||
return nil
|
||
}
|
||
name := ctx.ToolResults[0].Name
|
||
res := fmt.Sprint(ctx.ToolResults[0].Result)
|
||
// 结果必须含**自己**的名字("ran:k_a"),否则读到的是别人的
|
||
if !strings.Contains(res, name) {
|
||
mu.Lock()
|
||
crossTalk["after:"+name] = res
|
||
mu.Unlock()
|
||
}
|
||
return nil
|
||
})
|
||
|
||
done := make(chan struct{})
|
||
go func() {
|
||
defer close(done)
|
||
if out := a.runTaskSteps(a.newTaskFrame("go", a.stageCtxFromInput("go", "", ""))); out != outcomeDone {
|
||
t.Errorf("runTaskSteps=%v", out)
|
||
}
|
||
}()
|
||
deadline := time.Now().Add(3 * time.Second)
|
||
for atomic.LoadInt32(&maxActive) < 2 && time.Now().Before(deadline) {
|
||
time.Sleep(5 * time.Millisecond)
|
||
}
|
||
close(release)
|
||
<-done
|
||
|
||
if len(crossTalk) > 0 {
|
||
t.Errorf("并发下 StageContext 串味: %v", crossTalk)
|
||
}
|
||
if len(beforeNames) != len(names) {
|
||
t.Errorf("每个工具应各看到自己的 ctx,实际看到 %v(期望 %v)", beforeNames, names)
|
||
}
|
||
}
|