mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
An agent can now keep its memory in Hanzo Base, so its state sits in the same single binary as everything it runs beside: one record per scope and key, in one collection, visible in Base's own admin view while the agent is running. The collection ships as a schema to import rather than being built on first write — a client that writes its own schema can write the wrong one from a typo and then read nothing from the collection everyone else is looking at. The similarity search that every backend needs now exists once. The in-memory backend answered every search with an empty list, which is the one answer a caller cannot tell from "nothing was similar", and it holds the vectors already. Filters were honoured by nobody. Both read the same cosine and the same matcher now, so they cannot drift. A scope id is caller data and rides a quoted filter clause, so the order of escaping is load-bearing: backslashes double first, then quotes. The other order lets a value ending in a backslash escape the closing quote and end the literal where the caller chose. Co-authored-by: Hanzo Dev <[email protected]>
102 lines
3.3 KiB
Go
102 lines
3.3 KiB
Go
package agent
|
|
|
|
import (
|
|
"context"
|
|
"math"
|
|
"testing"
|
|
)
|
|
|
|
func TestCosine(t *testing.T) {
|
|
for _, c := range []struct {
|
|
name string
|
|
a, b []float64
|
|
want float64
|
|
}{
|
|
{"identical", []float64{1, 2, 3}, []float64{1, 2, 3}, 1},
|
|
{"scaled is the same direction", []float64{1, 0}, []float64{7, 0}, 1},
|
|
{"perpendicular", []float64{1, 0}, []float64{0, 1}, 0},
|
|
{"opposite", []float64{1, 0}, []float64{-1, 0}, -1},
|
|
{"a zero vector has no direction", []float64{0, 0}, []float64{1, 0}, 0},
|
|
} {
|
|
if got := cosine(c.a, c.b); math.Abs(got-c.want) > 1e-9 {
|
|
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
func TestMatches(t *testing.T) {
|
|
meta := map[string]any{"kind": "note", "depth": 2.0, "live": true}
|
|
|
|
for _, c := range []struct {
|
|
name string
|
|
filters map[string]any
|
|
want bool
|
|
}{
|
|
{"no filters match everything", nil, true},
|
|
{"a value that agrees", map[string]any{"kind": "note"}, true},
|
|
{"a value that disagrees", map[string]any{"kind": "task"}, false},
|
|
{"every filter must hold", map[string]any{"kind": "note", "live": false}, false},
|
|
{"an int filter meets a decoded float", map[string]any{"depth": 2}, true},
|
|
{"a key the record lacks", map[string]any{"author": "z"}, false},
|
|
} {
|
|
if got := matches(meta, c.filters); got != c.want {
|
|
t.Errorf("%s: got %v, want %v", c.name, got, c.want)
|
|
}
|
|
}
|
|
}
|
|
|
|
// The default backend is the one a caller gets from NewMemory(nil), and it
|
|
// answered every similarity search with an empty list — which a caller cannot
|
|
// tell from "nothing was similar".
|
|
func TestInMemorySearchRanks(t *testing.T) {
|
|
backend := NewInMemoryBackend()
|
|
backend.SetVector(ScopeSession, "s1", "same", []float64{1, 0, 0}, nil)
|
|
backend.SetVector(ScopeSession, "s1", "near", []float64{0.9, 0.4, 0}, nil)
|
|
backend.SetVector(ScopeSession, "s1", "across", []float64{0, 1, 0}, map[string]any{"kind": "task"})
|
|
|
|
hits, err := backend.SearchVector(ScopeSession, "s1", []float64{1, 0, 0}, SearchOptions{})
|
|
if err != nil {
|
|
t.Fatalf("search: %v", err)
|
|
}
|
|
if len(hits) != 3 {
|
|
t.Fatalf("%d hits, want 3", len(hits))
|
|
}
|
|
if hits[0].Key != "same" || hits[2].Key != "across" {
|
|
t.Fatalf("order %s %s %s", hits[0].Key, hits[1].Key, hits[2].Key)
|
|
}
|
|
|
|
filtered, _ := backend.SearchVector(ScopeSession, "s1", []float64{1, 0, 0},
|
|
SearchOptions{Filters: map[string]any{"kind": "task"}})
|
|
if len(filtered) != 1 || filtered[0].Key != "across" {
|
|
t.Fatalf("filtered %#v", filtered)
|
|
}
|
|
|
|
capped, _ := backend.SearchVector(ScopeSession, "s1", []float64{1, 0, 0},
|
|
SearchOptions{Threshold: 0.5, Limit: 1})
|
|
if len(capped) != 1 || capped[0].Key != "same" {
|
|
t.Fatalf("capped %#v", capped)
|
|
}
|
|
|
|
if _, err := backend.SearchVector(ScopeSession, "s1", nil, SearchOptions{}); err == nil {
|
|
t.Fatal("searching with no query vector should refuse")
|
|
}
|
|
}
|
|
|
|
// A search reaches the backend through the Memory type, which is how a handler
|
|
// asks. It used to arrive at a stub.
|
|
func TestMemorySearchReachesTheBackend(t *testing.T) {
|
|
memory := NewMemory(nil)
|
|
ctx := context.Background()
|
|
|
|
if err := memory.SetVector(ctx, "recalled", []float64{1, 0}, nil); err != nil {
|
|
t.Fatalf("set: %v", err)
|
|
}
|
|
hits, err := memory.SearchVector(ctx, []float64{1, 0}, SearchOptions{})
|
|
if err != nil {
|
|
t.Fatalf("search: %v", err)
|
|
}
|
|
if len(hits) != 1 || hits[0].Key != "recalled" {
|
|
t.Fatalf("hits %#v", hits)
|
|
}
|
|
}
|