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 保留。
239 lines
8.5 KiB
Go
239 lines
8.5 KiB
Go
package core
|
||
|
||
// M1 验收测试:状态机与 TaskFrame 的**行为等价性**。
|
||
//
|
||
// 设计依据 docs/zh/input-scheduler-design.md §11(R3 / X3 的 M1 形态):
|
||
// M1 不引入抢占,因此 R3 退化为「经状态机跑出的结果与脚本预期一致」;
|
||
// X3 在 M1 退化为「驱动循环必然以一次终态返回结束(不空转、不超步数)」。
|
||
//
|
||
// 抢占/挂起/恢复/优先级在 M3 起才有测试。
|
||
|
||
import (
|
||
"context"
|
||
"errors"
|
||
"testing"
|
||
|
||
agentAPI "github.com/JianFeeeee/HomeAgent/internal/agent/api"
|
||
agentIO "github.com/JianFeeeee/HomeAgent/internal/agent/io"
|
||
sdk "github.com/JianFeeeee/HomeAgent/internal/sdk"
|
||
)
|
||
|
||
// scriptProvider 按脚本依次返回 CompletionResponse。
|
||
//
|
||
// ChatStream 故意返回错误:驱动 chatStreamWithFallback 走非流式回退,
|
||
// 这样脚本就是「第 N 次调用返回第 N 个响应」,不依赖流式分片语义。
|
||
type scriptProvider struct {
|
||
script []*agentAPI.CompletionResponse
|
||
idx int
|
||
reqs []*agentAPI.CompletionRequest
|
||
// err 非空时 Chat 直接返回它(用于错误路径测试)。
|
||
// 配合 ProviderError(401) 可跳过 2s 瞬时重试,让测试保持快速。
|
||
err error
|
||
}
|
||
|
||
func (s *scriptProvider) Name() string { return "script" }
|
||
func (s *scriptProvider) Chat(ctx context.Context, req *agentAPI.CompletionRequest) (*agentAPI.CompletionResponse, error) {
|
||
s.reqs = append(s.reqs, req)
|
||
if s.err != nil {
|
||
return nil, s.err
|
||
}
|
||
if s.idx >= len(s.script) {
|
||
return &agentAPI.CompletionResponse{Content: ""}, nil
|
||
}
|
||
r := s.script[s.idx]
|
||
s.idx++
|
||
return r, nil
|
||
}
|
||
func (s *scriptProvider) ChatStream(ctx context.Context, req *agentAPI.CompletionRequest) (<-chan agentAPI.StreamChunk, error) {
|
||
return nil, errors.New("script provider: streaming disabled")
|
||
}
|
||
func (s *scriptProvider) MaxContextTokens() int { return 8192 }
|
||
|
||
func newTaskTestAgent(t *testing.T, sp agentAPI.Provider, sh *StageHost) *Agent {
|
||
t.Helper()
|
||
return New(AgentConfig{
|
||
ID: "task-test",
|
||
Provider: sp,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
StageHost: sh,
|
||
IO: agentIO.NewIOManager(),
|
||
})
|
||
}
|
||
|
||
func tc(id, name string) agentAPI.ToolCall {
|
||
return agentAPI.ToolCall{ID: id, Name: name, Arguments: map[string]interface{}{"q": id}}
|
||
}
|
||
|
||
// R3(M1 形态):一次工具轮 + 一次收尾轮,结果与工具调用计数必须正确。
|
||
func TestTaskFrame_R3_ToolRoundTrip(t *testing.T) {
|
||
sh := NewStageHost()
|
||
var got []string
|
||
sh.RegisterTool("t_echo", sdk.ToolDef{Name: "t_echo", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||
got = append(got, args["q"].(string))
|
||
return "OUT", nil
|
||
})
|
||
|
||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||
{Content: "让我调用工具", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_echo")}},
|
||
{Content: "最终答复"},
|
||
}}
|
||
a := newTaskTestAgent(t, sp, sh)
|
||
|
||
stageCtx := a.stageCtxFromInput("你好", "", "")
|
||
resp, toolsUsed, toolResults, err := a.process("你好", stageCtx)
|
||
if err != nil {
|
||
t.Fatalf("process 返回错误: %v", err)
|
||
}
|
||
if resp != "最终答复" {
|
||
t.Fatalf("响应=%q,期望 %q", resp, "最终答复")
|
||
}
|
||
if len(toolsUsed) != 1 || toolsUsed[0] != "t_echo" {
|
||
t.Fatalf("toolsUsed=%v,期望恰好一次 t_echo", toolsUsed)
|
||
}
|
||
if len(toolResults) != 1 || toolResults[0].Name != "t_echo" || toolResults[0].Output != "OUT" {
|
||
t.Fatalf("toolResults=%+v,期望一条 t_echo/OUT", toolResults)
|
||
}
|
||
if len(got) != 1 || got[0] != "c1" {
|
||
t.Fatalf("工具实参=%v,期望恰好执行一次且参数来自脚本", got)
|
||
}
|
||
if len(sp.reqs) != 2 {
|
||
t.Fatalf("LLM 调用次数=%d,期望 2(工具轮 + 收尾轮)", len(sp.reqs))
|
||
}
|
||
|
||
// 第二轮请求必须携带 assistant(tool_call) + tool 结果两条消息。
|
||
msgs := sp.reqs[1].Messages
|
||
var hasAssistantCall, hasToolResult bool
|
||
for _, m := range msgs {
|
||
if m.Role == "assistant" && len(m.ToolCalls) == 1 && m.ToolCalls[0].ID == "c1" {
|
||
hasAssistantCall = true
|
||
}
|
||
if m.Role == "tool" && m.ToolCallID == "c1" && m.Content == "OUT" {
|
||
hasToolResult = true
|
||
}
|
||
}
|
||
if !hasAssistantCall || !hasToolResult {
|
||
t.Fatalf("第二轮请求缺少工具调用配对:assistant=%v tool=%v", hasAssistantCall, hasToolResult)
|
||
}
|
||
}
|
||
|
||
// X3(M1 形态):多轮脚本必须在有限步内以一次终态返回结束。
|
||
func TestTaskFrame_X3_TerminatesWithinBudget(t *testing.T) {
|
||
sh := NewStageHost()
|
||
sh.RegisterTool("t_noop", sdk.ToolDef{Name: "t_noop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||
return "ok", nil
|
||
})
|
||
|
||
// 3 个工具轮 + 收尾轮:状态机会在 StepLLM/StepToolBegin/.../StepTurnEnd 间往返 4 次。
|
||
var script []*agentAPI.CompletionResponse
|
||
for i := 0; i < 3; i++ {
|
||
script = append(script, &agentAPI.CompletionResponse{
|
||
Content: "round",
|
||
ToolCalls: []agentAPI.ToolCall{tc("c"+string(rune('a'+i)), "t_noop")},
|
||
})
|
||
}
|
||
script = append(script, &agentAPI.CompletionResponse{Content: "done"})
|
||
|
||
sp := &scriptProvider{script: script}
|
||
a := newTaskTestAgent(t, sp, sh)
|
||
|
||
resp, toolsUsed, toolResults, err := a.process("跑三轮", a.stageCtxFromInput("跑三轮", "", ""))
|
||
if err != nil {
|
||
t.Fatalf("process 返回错误: %v", err)
|
||
}
|
||
if resp != "done" {
|
||
t.Fatalf("响应=%q,期望 done", resp)
|
||
}
|
||
if len(toolsUsed) != 3 || len(toolResults) != 3 {
|
||
t.Fatalf("toolsUsed=%d toolResults=%d,期望各 3", len(toolsUsed), len(toolResults))
|
||
}
|
||
// 步数护栏未触发(触发了会是 "step budget exhausted" 错误)。
|
||
if len(sp.reqs) != 4 {
|
||
t.Fatalf("LLM 调用次数=%d,期望 4", len(sp.reqs))
|
||
}
|
||
}
|
||
|
||
// 状态机对未知 step 必须失败退出而不是空转。
|
||
func TestTaskFrame_UnknownStepFails(t *testing.T) {
|
||
sp := &scriptProvider{}
|
||
a := newTaskTestAgent(t, sp, NewStageHost())
|
||
f := a.newTaskFrame("x", a.stageCtxFromInput("x", "", ""))
|
||
f.Step = Step(999)
|
||
if out := a.step(f); out != outcomeFailed {
|
||
t.Fatalf("未知 step 应返回 outcomeFailed,实际 %v", out)
|
||
}
|
||
if f.Err == nil {
|
||
t.Fatal("未知 step 必须带错误信息")
|
||
}
|
||
}
|
||
|
||
// D6:工具轮次硬上限——模型不停调用工具时,必须在有限步内收尾。
|
||
//
|
||
// 这是审查里定位的 P0(core.agent.max_tool_turns 只定义、从没被读过),
|
||
// 也是调度器的前提:任务必须可终止。
|
||
func TestMaxToolTurns_CapsRunawayLoop(t *testing.T) {
|
||
sh := NewStageHost()
|
||
sh.RegisterTool("t_loop", sdk.ToolDef{Name: "t_loop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||
return "again", nil
|
||
})
|
||
|
||
// 脚本远长于上限:provider 每轮都给下一批工具调用,模拟“永不停止”。
|
||
script := make([]*agentAPI.CompletionResponse, 0, 20)
|
||
for i := 0; i < 20; i++ {
|
||
script = append(script, &agentAPI.CompletionResponse{
|
||
Content: "继续",
|
||
ToolCalls: []agentAPI.ToolCall{tc("c1", "t_loop")},
|
||
})
|
||
}
|
||
sp := &scriptProvider{script: script}
|
||
a := New(AgentConfig{
|
||
ID: "cap",
|
||
Provider: sp,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
IO: agentIO.NewIOManager(),
|
||
StageHost: sh,
|
||
MaxToolTurns: 3,
|
||
})
|
||
|
||
resp, toolsUsed, toolResults, err := a.process("循环", a.stageCtxFromInput("循环", "", ""))
|
||
if err != nil {
|
||
t.Fatalf("process 返回错误: %v", err)
|
||
}
|
||
if len(toolsUsed) != 3 || len(toolResults) != 3 {
|
||
t.Fatalf("工具批=%d/%d,期望恰好 3(到上限即止,不多跑第 4 轮)", len(toolsUsed), len(toolResults))
|
||
}
|
||
if len(sp.reqs) != 3 {
|
||
t.Fatalf("LLM 调用=%d,期望 3(上限后不再发起新请求)", len(sp.reqs))
|
||
}
|
||
if resp != "继续" {
|
||
t.Fatalf("响应=%q,期望返回最近一次 LLM 文本", resp)
|
||
}
|
||
}
|
||
|
||
// 上限为 0 表示不限(显式退出机制)。
|
||
func TestMaxToolTurns_ZeroMeansUnlimited(t *testing.T) {
|
||
sh := NewStageHost()
|
||
sh.RegisterTool("t_loop", sdk.ToolDef{Name: "t_loop", Plugin: "t"}, func(args map[string]interface{}) (interface{}, error) {
|
||
return "again", nil
|
||
})
|
||
sp := &scriptProvider{script: []*agentAPI.CompletionResponse{
|
||
{Content: "a", ToolCalls: []agentAPI.ToolCall{tc("c1", "t_loop")}},
|
||
{Content: "b", ToolCalls: []agentAPI.ToolCall{tc("c2", "t_loop")}},
|
||
{Content: "c"},
|
||
}}
|
||
a := New(AgentConfig{
|
||
ID: "nocap",
|
||
Provider: sp,
|
||
ProviderManager: agentAPI.NewProviderManager(),
|
||
IO: agentIO.NewIOManager(),
|
||
StageHost: sh,
|
||
MaxToolTurns: 0,
|
||
})
|
||
resp, toolsUsed, _, err := a.process("x", a.stageCtxFromInput("x", "", ""))
|
||
if err != nil {
|
||
t.Fatalf("process 返回错误: %v", err)
|
||
}
|
||
if len(toolsUsed) != 2 || resp != "c" {
|
||
t.Fatalf("不限时应跑完脚本:tools=%d resp=%q", len(toolsUsed), resp)
|
||
}
|
||
}
|