205 lines
5.5 KiB
Go
205 lines
5.5 KiB
Go
package loop
|
|
|
|
import (
|
|
"context"
|
|
"io"
|
|
"log/slog"
|
|
"testing"
|
|
"time"
|
|
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/capability/registry"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/db"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/agent"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/guardrail"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/memory"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/model"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/persist"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/internal/runtime/tool"
|
|
"git.yeij.top/AskaEth/Cyrene_v2/pkg/plugins/sdk"
|
|
)
|
|
|
|
// mgResp is one scripted adapter result: a response plus an optional error.
|
|
type mgResp struct {
|
|
resp model.Response
|
|
err error
|
|
}
|
|
|
|
// mockAdapter is a configurable model.Adapter for loop tests. It keeps two
|
|
// separate scripts and call counters (one for Chat, one for ChatStream) so a
|
|
// single instance serves both the non-streaming and streaming paths. Every
|
|
// request it is handed is recorded so tests can assert on the messages that were
|
|
// fed to the model (e.g. the injected memory or a fed-back tool result).
|
|
type mockAdapter struct {
|
|
name string
|
|
|
|
chat []mgResp
|
|
chatCalls int
|
|
reqs []model.Request
|
|
|
|
stream []mgResp
|
|
chunks [][]string
|
|
streamCalls int
|
|
streamReqs []model.Request
|
|
}
|
|
|
|
func (m *mockAdapter) Name() string {
|
|
if m.name == "" {
|
|
return "mock"
|
|
}
|
|
return m.name
|
|
}
|
|
|
|
func (m *mockAdapter) Chat(_ context.Context, req model.Request, _ model.Options) (model.Response, error) {
|
|
m.reqs = append(m.reqs, req)
|
|
if m.chatCalls >= len(m.chat) {
|
|
return model.Response{Content: "done"}, nil
|
|
}
|
|
r := m.chat[m.chatCalls]
|
|
m.chatCalls++
|
|
return r.resp, r.err
|
|
}
|
|
|
|
func (m *mockAdapter) ChatStream(_ context.Context, req model.Request, on func(string)) (model.Response, error) {
|
|
m.streamReqs = append(m.streamReqs, req)
|
|
i := m.streamCalls
|
|
m.streamCalls++
|
|
if i < len(m.chunks) {
|
|
for _, c := range m.chunks[i] {
|
|
if on != nil {
|
|
on(c)
|
|
}
|
|
}
|
|
}
|
|
if i >= len(m.stream) {
|
|
return model.Response{Content: "done"}, nil
|
|
}
|
|
return m.stream[i].resp, m.stream[i].err
|
|
}
|
|
|
|
// mockGuard is a configurable guardrail.Guardrail for loop tests.
|
|
type mockGuard struct {
|
|
inputVerdict *guardrail.Verdict
|
|
outputVerdict *guardrail.Verdict
|
|
inputErr error
|
|
outputErr error
|
|
}
|
|
|
|
func (m *mockGuard) CheckInput(_ context.Context, _ *model.Request) (*guardrail.Verdict, error) {
|
|
return m.inputVerdict, m.inputErr
|
|
}
|
|
|
|
func (m *mockGuard) CheckOutput(_ context.Context, _ *model.Response) (*guardrail.Verdict, error) {
|
|
return m.outputVerdict, m.outputErr
|
|
}
|
|
|
|
// mockTool is a minimal sdk.Tool for loop tests. It supports a delay so a test
|
|
// can exercise the per-invoke timeout, and an error so a test can verify that a
|
|
// failed call is fed back to the model.
|
|
type mockTool struct {
|
|
name string
|
|
desc string
|
|
params map[string]any
|
|
result any
|
|
err error
|
|
delay time.Duration
|
|
}
|
|
|
|
func (m *mockTool) Name() string { return m.name }
|
|
|
|
func (m *mockTool) Description() string {
|
|
if m.desc == "" {
|
|
return "mock tool " + m.name
|
|
}
|
|
return m.desc
|
|
}
|
|
|
|
func (m *mockTool) Parameters() (map[string]any, error) { return m.params, nil }
|
|
|
|
func (m *mockTool) Execute(ctx context.Context, _ any) (any, error) {
|
|
if m.delay > 0 {
|
|
select {
|
|
case <-time.After(m.delay):
|
|
case <-ctx.Done():
|
|
return nil, ctx.Err()
|
|
}
|
|
}
|
|
return m.result, m.err
|
|
}
|
|
|
|
// testLogger returns a logger that writes nowhere, keeping test output clean.
|
|
func testLogger() *slog.Logger {
|
|
return slog.New(slog.NewTextHandler(io.Discard, nil))
|
|
}
|
|
|
|
// newTestAgent builds an Agent over ad with the given tools and no guardrails.
|
|
func newTestAgent(t *testing.T, ad model.Adapter, tools *tool.Dispatcher) *agent.Agent {
|
|
t.Helper()
|
|
a, err := agent.New("You are a helpful assistant.", tools, nil, agent.WithModel(ad, nil))
|
|
if err != nil {
|
|
t.Fatalf("agent.New: %v", err)
|
|
}
|
|
return a
|
|
}
|
|
|
|
// dispatcherWith builds a Dispatcher with the default tool timeout.
|
|
func dispatcherWith(t *testing.T, tools ...sdk.Tool) *tool.Dispatcher {
|
|
t.Helper()
|
|
return dispatcherWithTimeout(t, 0, tools...)
|
|
}
|
|
|
|
// dispatcherWithTimeout builds a Dispatcher applying a custom default timeout
|
|
// (a non-positive value selects the package default) and registering tools.
|
|
func dispatcherWithTimeout(t *testing.T, timeout time.Duration, tools ...sdk.Tool) *tool.Dispatcher {
|
|
t.Helper()
|
|
reg := registry.New()
|
|
var opts []tool.Option
|
|
if timeout > 0 {
|
|
opts = append(opts, tool.WithDefaultTimeout(timeout))
|
|
}
|
|
d, err := tool.New(reg, opts...)
|
|
if err != nil {
|
|
t.Fatalf("tool.New: %v", err)
|
|
}
|
|
for _, tk := range tools {
|
|
if err := d.Register(tk); err != nil {
|
|
t.Fatalf("Register %q: %v", tk.Name(), err)
|
|
}
|
|
}
|
|
return d
|
|
}
|
|
|
|
// newTestMemoryStore opens an isolated in-memory SQLite database and returns a
|
|
// memory.Store over it. Each call owns a private DB, so parallel tests are
|
|
// isolated by construction.
|
|
func newTestMemoryStore(t *testing.T) *memory.Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
d, err := db.Open(ctx, ":memory:", testLogger())
|
|
if err != nil {
|
|
t.Fatalf("db.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
s, err := memory.NewStore(ctx, d, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("memory.NewStore: %v", err)
|
|
}
|
|
return s
|
|
}
|
|
|
|
// newTestCheckpointStore opens an isolated in-memory SQLite database and returns
|
|
// a persist.Store over it.
|
|
func newTestCheckpointStore(t *testing.T) *persist.Store {
|
|
t.Helper()
|
|
ctx := context.Background()
|
|
d, err := db.Open(ctx, ":memory:", testLogger())
|
|
if err != nil {
|
|
t.Fatalf("db.Open: %v", err)
|
|
}
|
|
t.Cleanup(func() { _ = d.Close() })
|
|
s, err := persist.New(ctx, d, testLogger())
|
|
if err != nil {
|
|
t.Fatalf("persist.New: %v", err)
|
|
}
|
|
return s
|
|
}
|