feat: AutoZero 通用 Agent 框架(从 AutoGo 五阶段拆分完成)
llm(三协议 Provider)/ session(JSONL 持久化)/ tools(Registry、围栏、 builtin、computer)/ cdp(browser)/ skills / mcp / agent(循环、两级压缩、 编排、审批门控)+ 底座 jsonl/textx/execx/diag/scratch/spill/imagecache。 产品装配代码(config 桥接、schedule 工具、memory/plan/retro/prompt) 留在 AutoGo,宿主接入方式见 MIGRATION.md。
This commit is contained in:
commit
d5dac5a6fc
140 files changed
+26497
No files matched your search
@@ -0,0 +1,174 @@
|
||||
package llm
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
// stallServer 吐一个增量之后彻底静默——正是"网关半开连接/服务端卡在生成阶段"的形态:
|
||||
// TCP 不断、HTTP 头已回、之后再无字节。没有看门狗时 bufio.Scanner 会永久阻塞在 Read。
|
||||
func stallServer(t *testing.T, hold chan struct{}) *httptest.Server {
|
||||
t.Helper()
|
||||
return httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.Method == http.MethodPost {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"partial\"}}]}\n\n"))
|
||||
if f, ok := w.(http.Flusher); ok {
|
||||
f.Flush()
|
||||
}
|
||||
}
|
||||
<-hold // 永不返回
|
||||
}))
|
||||
}
|
||||
|
||||
func TestStreamIdleWatchdogUnblocks(t *testing.T) {
|
||||
old := streamIdleTimeout
|
||||
streamIdleTimeout = 150 * time.Millisecond
|
||||
defer func() { streamIdleTimeout = old }()
|
||||
|
||||
hold := make(chan struct{})
|
||||
srv := stallServer(t, hold)
|
||||
defer func() {
|
||||
close(hold)
|
||||
srv.Close()
|
||||
}()
|
||||
|
||||
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m"}
|
||||
done := make(chan error, 1)
|
||||
go func() {
|
||||
_, err := p.Stream(context.Background(), "sys",
|
||||
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
|
||||
done <- err
|
||||
}()
|
||||
|
||||
select {
|
||||
case err := <-done:
|
||||
var se *StalledError
|
||||
if !errors.As(err, &se) {
|
||||
t.Fatalf("要的是 StalledError,got %v", err)
|
||||
}
|
||||
if !strings.Contains(err.Error(), "stalled") {
|
||||
t.Errorf("错误必须说清是静默掐断,got=%q", err)
|
||||
}
|
||||
case <-time.After(10 * time.Second):
|
||||
t.Fatal("看门狗没生效:Stream 仍被静默的连接卡着")
|
||||
}
|
||||
}
|
||||
|
||||
// TestStreamIdleWatchdogSparesHealthyStream:不能错杀慢但活的流——
|
||||
// 边睡边吐字节的流必须完整读完。
|
||||
func TestStreamIdleWatchdogSparesHealthyStream(t *testing.T) {
|
||||
old := streamIdleTimeout
|
||||
streamIdleTimeout = 400 * time.Millisecond
|
||||
defer func() { streamIdleTimeout = old }()
|
||||
|
||||
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "text/event-stream")
|
||||
f, _ := w.(http.Flusher)
|
||||
// 三段,每段间隔 250ms:单段静默都低于 400ms 阈值。
|
||||
for _, s := range []string{"he", "ll", "o"} {
|
||||
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"" + s + "\"}}]}\n\n"))
|
||||
if f != nil {
|
||||
f.Flush()
|
||||
}
|
||||
time.Sleep(250 * time.Millisecond)
|
||||
}
|
||||
w.Write([]byte("data: [DONE]\n\n"))
|
||||
if f != nil {
|
||||
f.Flush()
|
||||
}
|
||||
}))
|
||||
defer srv.Close()
|
||||
|
||||
p := &OpenAIProvider{BaseURL: srv.URL, APIKey: "k", Model: "m"}
|
||||
msg, err := p.Stream(context.Background(), "sys",
|
||||
[]Message{{Role: RoleUser, Content: "hi"}}, nil, StreamOptions{}, nil)
|
||||
if err != nil {
|
||||
t.Fatalf("健康流被误杀:%v", err)
|
||||
}
|
||||
if msg.Content != "hello" {
|
||||
t.Errorf("Content = %q, want hello", msg.Content)
|
||||
}
|
||||
}
|
||||
|
||||
// trickle 是一个可控的"滴流"响应体:按给定节奏每次吐一个字节,
|
||||
// 滴完之后彻底静默(阻塞在读上),用来精确复现"服务端连上不吐字节"。
|
||||
// 和真响应体一样:Close 会让阻塞中的 Read 立刻返回错误。
|
||||
type trickle struct {
|
||||
gaps []time.Duration
|
||||
i int
|
||||
closed chan struct{}
|
||||
}
|
||||
|
||||
func (r *trickle) Read(p []byte) (int, error) {
|
||||
if r.i >= len(r.gaps) {
|
||||
<-r.closed
|
||||
return 0, io.ErrClosedPipe
|
||||
}
|
||||
time.Sleep(r.gaps[r.i])
|
||||
r.i++
|
||||
p[0] = 'x'
|
||||
return 1, nil
|
||||
}
|
||||
|
||||
func (r *trickle) Close() error { close(r.closed); return nil }
|
||||
|
||||
func (r *trickle) closedNow() bool {
|
||||
select {
|
||||
case <-r.closed:
|
||||
return true
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
// TestIdleGuardMarksStall:看护的两条基本契约——有字节就重置计时,
|
||||
// 真静默了才掐断,并且能把原因报出来。
|
||||
func TestIdleGuardMarksStall(t *testing.T) {
|
||||
const idle = 300 * time.Millisecond
|
||||
src := &trickle{gaps: []time.Duration{60 * time.Millisecond, 60, 60, 60}, closed: make(chan struct{})}
|
||||
g := guardStream(src, idle)
|
||||
defer g.Stop()
|
||||
|
||||
buf := make([]byte, 1)
|
||||
for i := 0; i < len(src.gaps); i++ {
|
||||
if _, err := g.Read(buf); err != nil {
|
||||
t.Fatalf("Read 透传失败:%v", err)
|
||||
}
|
||||
if err := g.Err(); err != nil {
|
||||
t.Fatalf("活跃流被误判为静默:%v", err)
|
||||
}
|
||||
}
|
||||
|
||||
// 此后彻底没有数据:Read 阻塞在源上,只有看门狗关掉它能解阻塞。
|
||||
readErr := make(chan error, 1)
|
||||
go func() { _, err := g.Read(buf); readErr <- err }()
|
||||
|
||||
deadline := time.Now().Add(10 * time.Second)
|
||||
for g.Err() == nil {
|
||||
if time.Now().After(deadline) {
|
||||
t.Fatal("静默没有被判定")
|
||||
}
|
||||
time.Sleep(20 * time.Millisecond)
|
||||
}
|
||||
if !strings.Contains(g.Err().Error(), idle.String()) {
|
||||
t.Errorf("错误里应带阈值,got=%q", g.Err())
|
||||
}
|
||||
select {
|
||||
case err := <-readErr:
|
||||
if err == nil {
|
||||
t.Error("body 被掐断后 Read 必须返回错误(否则调用方仍会阻塞)")
|
||||
}
|
||||
case <-time.After(2 * time.Second):
|
||||
t.Fatal("掐断后 Read 仍卡着——调用方解不了阻塞,看门狗等于没做")
|
||||
}
|
||||
if !src.closedNow() {
|
||||
t.Error("看门狗没有关闭响应体")
|
||||
}
|
||||
}
|
||||
Reference in new issue
Block a user