e071084ebe
govulncheck / govulncheck (push) Waiting to run
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
184 lines
5.2 KiB
Go
184 lines
5.2 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"strings"
|
|
"testing"
|
|
|
|
"go-micro.dev/v6/ai"
|
|
"go-micro.dev/v6/registry"
|
|
"go-micro.dev/v6/store"
|
|
)
|
|
|
|
// A registered wrapper runs around every tool call and can observe and
|
|
// modify the result.
|
|
func TestWrapToolWraps(t *testing.T) {
|
|
var saw string
|
|
wrap := func(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
saw = call.Name
|
|
res := next(ctx, call)
|
|
res.Content = "wrapped:" + res.Content
|
|
return res
|
|
}
|
|
}
|
|
|
|
a := newTestAgent(Name("wrapped"), WrapTool(wrap))
|
|
content := toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
|
|
|
|
if saw != "demo_Svc_Do" {
|
|
t.Errorf("wrapper saw %q, want demo_Svc_Do", saw)
|
|
}
|
|
if !strings.HasPrefix(content, "wrapped:") {
|
|
t.Errorf("wrapper did not modify the result; got %q", content)
|
|
}
|
|
}
|
|
|
|
// Multiple wrappers compose outermost-first: the first registered wrapper
|
|
// is the outer layer, so it runs first on the way in and last on the way
|
|
// out.
|
|
func TestWrapToolOrder(t *testing.T) {
|
|
var order []string
|
|
mk := func(tag string) ai.ToolWrapper {
|
|
return func(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
order = append(order, "in:"+tag)
|
|
res := next(ctx, call)
|
|
order = append(order, "out:"+tag)
|
|
return res
|
|
}
|
|
}
|
|
}
|
|
|
|
a := newTestAgent(Name("ordered"), WrapTool(mk("a"), mk("b")))
|
|
toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
|
|
|
|
want := "in:a in:b out:b out:a"
|
|
if got := strings.Join(order, " "); got != want {
|
|
t.Errorf("wrapper order = %q, want %q", got, want)
|
|
}
|
|
}
|
|
|
|
// Wrappers run outside the built-in guardrails, so they observe a refused
|
|
// call and its refusal result rather than being short-circuited.
|
|
func TestWrapToolSeesGuardrailRefusal(t *testing.T) {
|
|
var sawResult string
|
|
wrap := func(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
res := next(ctx, call)
|
|
sawResult = res.Content
|
|
return res
|
|
}
|
|
}
|
|
|
|
a := newTestAgent(Name("gated-wrap"),
|
|
ApproveTool(func(tool string, input map[string]any) (bool, string) {
|
|
return false, "denied"
|
|
}),
|
|
WrapTool(wrap),
|
|
)
|
|
toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
|
|
|
|
if !strings.Contains(sawResult, "not approved") {
|
|
t.Errorf("wrapper should observe the guardrail refusal; got %q", sawResult)
|
|
}
|
|
}
|
|
|
|
// A guardrail refusal carries a structured reason a wrapper can switch on,
|
|
// so reliability tooling (e.g. loop handling) needn't parse the message.
|
|
func TestWrapToolSeesRefusedReason(t *testing.T) {
|
|
a := newTestAgent(Name("looper"), LoopLimit(2))
|
|
h := a.toolHandler()
|
|
|
|
var last ai.ToolResult
|
|
for i := 0; i < 3; i++ {
|
|
last = h(context.Background(), ai.ToolCall{ID: "x", Name: "demo_Svc_Do", Input: map[string]any{"q": "same"}})
|
|
}
|
|
if last.Refused != ai.RefusedLoop {
|
|
t.Errorf("Refused = %q, want %q", last.Refused, ai.RefusedLoop)
|
|
}
|
|
}
|
|
|
|
// ctxMock is a model that forwards the Generate context to the tool
|
|
// handler (as real providers do), so a wrapper can read ai.RunInfo.
|
|
type ctxMock struct{ opts ai.Options }
|
|
|
|
func (m *ctxMock) Init(opts ...ai.Option) error {
|
|
for _, o := range opts {
|
|
o(&m.opts)
|
|
}
|
|
return nil
|
|
}
|
|
func (m *ctxMock) Options() ai.Options { return m.opts }
|
|
func (m *ctxMock) String() string { return "ctxmock" }
|
|
func (m *ctxMock) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
|
|
return nil, fmt.Errorf("no stream")
|
|
}
|
|
func (m *ctxMock) Generate(ctx context.Context, _ *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
|
|
if m.opts.ToolHandler != nil {
|
|
m.opts.ToolHandler(ctx, ai.ToolCall{ID: "c1", Name: "demo_Svc_Do", Input: map[string]any{}})
|
|
}
|
|
return &ai.Response{Answer: "done"}, nil
|
|
}
|
|
|
|
// During an Ask, a wrapper sees RunInfo on the context: a correlation id
|
|
// for the run and the agent's name.
|
|
func TestWrapToolSeesRunInfo(t *testing.T) {
|
|
ai.Register("ctxmock", func(opts ...ai.Option) ai.Model {
|
|
m := &ctxMock{}
|
|
_ = m.Init(opts...)
|
|
return m
|
|
})
|
|
|
|
var got ai.RunInfo
|
|
var ok bool
|
|
a := New(
|
|
Name("runner"),
|
|
Provider("ctxmock"),
|
|
WithRegistry(registry.NewMemoryRegistry()),
|
|
WithStore(store.NewMemoryStore()),
|
|
WrapTool(func(next ai.ToolHandler) ai.ToolHandler {
|
|
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
|
got, ok = ai.RunInfoFrom(ctx)
|
|
return next(ctx, call)
|
|
}
|
|
}),
|
|
)
|
|
|
|
resp, err := a.Ask(context.Background(), "go")
|
|
if err != nil {
|
|
t.Fatalf("Ask: %v", err)
|
|
}
|
|
if !ok {
|
|
t.Fatal("wrapper did not see RunInfo on the context")
|
|
}
|
|
if got.Agent != "runner" {
|
|
t.Errorf("RunInfo.Agent = %q, want runner", got.Agent)
|
|
}
|
|
if got.RunID == "" {
|
|
t.Error("RunInfo.RunID is empty")
|
|
}
|
|
if resp.RunID != got.RunID {
|
|
t.Errorf("Response.RunID = %q, want wrapper RunID %q", resp.RunID, got.RunID)
|
|
}
|
|
if resp.ParentID != "" {
|
|
t.Errorf("Response.ParentID = %q, want empty", resp.ParentID)
|
|
}
|
|
}
|
|
|
|
// call.Scan decodes a tool call's input into a typed struct.
|
|
func TestToolCallScan(t *testing.T) {
|
|
call := ai.ToolCall{Input: map[string]any{"query": "hello", "limit": 5}}
|
|
var args struct {
|
|
Query string `json:"query"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if err := call.Scan(&args); err != nil {
|
|
t.Fatalf("Scan: %v", err)
|
|
}
|
|
if args.Query != "hello" || args.Limit != 5 {
|
|
t.Errorf("Scan decoded %+v, want {hello 5}", args)
|
|
}
|
|
}
|