mirror of
https://github.com/hanzoai/agent.git
synced 2026-09-11 21:08:51 +00:00
The SDKs here are the ones that shipped, under this repo's module path
hanzoai/agents carried the reconciled Go, TypeScript and Python SDKs: memory in Base with one record schema across all three, scope ids resolved the way the control plane resolves them, /v1 paths, one address per vector operation, agents that start under Express 5, and suites that run (Go green, 92 TypeScript, 527 Python). They come home here. The Go module is github.com/hanzoai/agent/sdk/go, because the path has to name the repository it is fetched from. The TypeScript manifest names its versions directly and keeps npm's lockfile; this repo is not a pnpm workspace. Versions are 0.2.10. Co-authored-by: Hanzo Dev <[email protected]>
This commit is contained in:
+2
-2
@@ -5,7 +5,7 @@ The Hanzo Agents Go SDK provides idiomatic Go bindings for interacting with the
|
||||
## Installation
|
||||
|
||||
```bash
|
||||
go get github.com/hanzoai/agents/sdk/go
|
||||
go get github.com/hanzoai/agent/sdk/go
|
||||
```
|
||||
|
||||
## Quick Start
|
||||
@@ -17,7 +17,7 @@ import (
|
||||
"context"
|
||||
"log"
|
||||
|
||||
hanzo-agentsagent "github.com/hanzoai/agents/sdk/go/agent"
|
||||
hanzo-agentsagent "github.com/hanzoai/agent/sdk/go/agent"
|
||||
)
|
||||
|
||||
func main() {
|
||||
|
||||
+5
-13
@@ -17,9 +17,7 @@ curl -X PUT http://127.0.0.1:8090/v1/collections/import \
|
||||
|
||||
Base's admin UI imports the same file from Settings.
|
||||
|
||||
It is not created on first write. A client that builds its own schema can build
|
||||
the wrong one from a typo in a field name, and then read nothing from the
|
||||
collection everyone else is looking at.
|
||||
Nothing creates it on first write.
|
||||
|
||||
Two things in that schema are load-bearing:
|
||||
|
||||
@@ -27,8 +25,7 @@ Two things in that schema are load-bearing:
|
||||
record. Without it a losing race writes a second record at the same key and
|
||||
reads start depending on which one Base returns first.
|
||||
- **The rules are null**, so out of the box the collection is reachable by a
|
||||
superuser alone. Open them deliberately, per deployment. An agent's memory is
|
||||
the agent's reasoning written down.
|
||||
superuser alone. Open them deliberately, per deployment.
|
||||
|
||||
## Use it
|
||||
|
||||
@@ -45,11 +42,6 @@ and loses everything when the process ends.
|
||||
|
||||
## Similarity search
|
||||
|
||||
The embedding is the caller's to compute, so which model produced it stays one
|
||||
decision made in one place. `SearchVector` reads the scope's vectors and ranks
|
||||
them by cosine in the client — right for an agent's own working set, wrong for a
|
||||
corpus. A scope large enough to need server-side scoring should use a Base hook.
|
||||
|
||||
A vector of a different width came from a different model, so it is skipped
|
||||
rather than scored: cosine across two of those returns a number that means
|
||||
nothing.
|
||||
The caller computes embeddings. `SearchVector` ranks the scope's vectors by
|
||||
cosine in the client, which suits an agent's working set, not a corpus. Vectors
|
||||
of another width came from another model and are skipped.
|
||||
|
||||
@@ -18,9 +18,9 @@ import (
|
||||
"syscall"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/agents/sdk/go/ai"
|
||||
"github.com/hanzoai/agents/sdk/go/client"
|
||||
"github.com/hanzoai/agents/sdk/go/types"
|
||||
"github.com/hanzoai/agent/sdk/go/ai"
|
||||
"github.com/hanzoai/agent/sdk/go/client"
|
||||
"github.com/hanzoai/agent/sdk/go/types"
|
||||
)
|
||||
|
||||
type executionContextKey struct{}
|
||||
|
||||
@@ -12,8 +12,8 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/agents/sdk/go/ai"
|
||||
"github.com/hanzoai/agents/sdk/go/types"
|
||||
"github.com/hanzoai/agent/sdk/go/ai"
|
||||
"github.com/hanzoai/agent/sdk/go/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
@@ -13,21 +13,8 @@ import (
|
||||
"time"
|
||||
)
|
||||
|
||||
// BaseMemoryBackend stores agent memory in Hanzo Base.
|
||||
//
|
||||
// Base is the single-binary Go backend, so this is the backend to reach for
|
||||
// when an agent should keep its state in the same process tree as everything
|
||||
// else it runs beside — no second database, no separate migration, and the
|
||||
// records are visible in Base's own admin view while an agent is running.
|
||||
//
|
||||
// One record per (scope, scopeID, key), in one collection. The alternative —
|
||||
// a collection per scope — makes the number of collections a function of how
|
||||
// many workflows have ever run.
|
||||
//
|
||||
// Paths are /v1/, with no /api/ prefix. Base served /api for a while and does
|
||||
// not now, and a client written against the old shape fails in a way that
|
||||
// reads like an auth problem: the router answers the SPA catch-all, so a
|
||||
// wrong path is 200 text/html rather than 404.
|
||||
// BaseMemoryBackend stores agent memory in Hanzo Base, one record per
|
||||
// (scope, scopeID, key) in one collection.
|
||||
type BaseMemoryBackend struct {
|
||||
baseURL string
|
||||
token string
|
||||
@@ -41,11 +28,8 @@ type BaseMemoryBackend struct {
|
||||
// token is an IAM bearer; Base guards the records API and an empty token gets
|
||||
// a refusal rather than anonymous access.
|
||||
//
|
||||
// The collection must exist, with the fields this backend writes — import
|
||||
// agent_memory.collection.json once, as MEMORY.md shows. It is not created
|
||||
// here on purpose: a client that creates schema on first write is a client
|
||||
// that can create the WRONG schema on a typo, and then read nothing from the
|
||||
// collection anybody was looking at.
|
||||
// The collection must exist: import agent_memory.collection.json once, as
|
||||
// MEMORY.md shows. Nothing here creates schema.
|
||||
func NewBaseMemoryBackend(baseURL, token, collection string) *BaseMemoryBackend {
|
||||
if collection == "" {
|
||||
collection = "agent_memory"
|
||||
@@ -58,9 +42,8 @@ func NewBaseMemoryBackend(baseURL, token, collection string) *BaseMemoryBackend
|
||||
}
|
||||
}
|
||||
|
||||
// The record this backend writes. `mkey` rather than `key`: `key` is a
|
||||
// reserved word in enough query grammars that a filter on it is a portability
|
||||
// bet nobody needs to take.
|
||||
// The record this backend writes. `mkey`, because `key` is reserved in some
|
||||
// filter grammars.
|
||||
type baseRecord struct {
|
||||
ID string `json:"id,omitempty"`
|
||||
Scope string `json:"scope"`
|
||||
@@ -84,7 +67,7 @@ func (b *BaseMemoryBackend) records() string {
|
||||
// data and an unquoted one is a filter injection.
|
||||
func filterFor(scope MemoryScope, scopeID, key string) string {
|
||||
parts := []string{
|
||||
"scope=" + quote(string(scope)),
|
||||
"scope=" + quote(apiScope(scope)),
|
||||
"scope_id=" + quote(scopeID),
|
||||
}
|
||||
if key != "" {
|
||||
@@ -93,13 +76,9 @@ func filterFor(scope MemoryScope, scopeID, key string) string {
|
||||
return strings.Join(parts, " && ")
|
||||
}
|
||||
|
||||
// A single-quoted literal in Base's filter grammar, with the value escaped.
|
||||
//
|
||||
// Base's tokenizer treats a backslash as the escape rune, so a quote preceded
|
||||
// by one does not close the literal. That makes the ORDER here load-bearing:
|
||||
// backslashes are doubled first, then quotes escaped. Escaping quotes first
|
||||
// would leave a value ending in a backslash escaping the closing quote we
|
||||
// added, which ends the literal somewhere the caller chose.
|
||||
// A single-quoted literal in Base's filter grammar. Base's tokenizer reads a
|
||||
// backslash as an escape, so backslashes double before quotes are escaped: the
|
||||
// other order lets a value ending in one escape the closing quote.
|
||||
func quote(s string) string {
|
||||
s = strings.ReplaceAll(s, "\\", "\\\\")
|
||||
s = strings.ReplaceAll(s, "'", "\\'")
|
||||
@@ -138,9 +117,7 @@ func (b *BaseMemoryBackend) do(ctx context.Context, method, endpoint string, bod
|
||||
return nil, resp.StatusCode, err
|
||||
}
|
||||
|
||||
// An HTML body on a JSON endpoint means the path was not served and the
|
||||
// SPA catch-all answered. Saying so beats a JSON parse error, which reads
|
||||
// as a broken server rather than a wrong URL.
|
||||
// Base answers a path it does not route with its admin SPA, at 200.
|
||||
if ct := resp.Header.Get("Content-Type"); strings.HasPrefix(ct, "text/html") {
|
||||
return nil, resp.StatusCode, fmt.Errorf(
|
||||
"base answered %s with HTML for %s — that path is not served by this Base", ct, endpoint)
|
||||
@@ -169,8 +146,9 @@ func (b *BaseMemoryBackend) find(ctx context.Context, scope MemoryScope, scopeID
|
||||
}
|
||||
|
||||
// write creates or updates the one record at a key.
|
||||
func (b *BaseMemoryBackend) write(ctx context.Context, rec baseRecord) error {
|
||||
existing, found, err := b.find(ctx, MemoryScope(rec.Scope), rec.ScopeID, rec.MKey)
|
||||
func (b *BaseMemoryBackend) write(ctx context.Context, scope MemoryScope, rec baseRecord) error {
|
||||
rec.Scope = apiScope(scope)
|
||||
existing, found, err := b.find(ctx, scope, rec.ScopeID, rec.MKey)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -186,6 +164,17 @@ func (b *BaseMemoryBackend) write(ctx context.Context, rec baseRecord) error {
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !found && status == http.StatusBadRequest {
|
||||
// Another writer created the key between the read and this create, and
|
||||
// the unique index refused a second record. A Set means the last write
|
||||
// wins, so update the record that won.
|
||||
if winner, ok, ferr := b.find(ctx, scope, rec.ScopeID, rec.MKey); ferr == nil && ok {
|
||||
raw, status, err = b.do(ctx, http.MethodPatch, b.records()+"/"+url.PathEscape(winner.ID), rec)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
}
|
||||
if status != http.StatusOK && status != http.StatusCreated {
|
||||
return fmt.Errorf("base write returned %d: %s", status, snippet(raw))
|
||||
}
|
||||
@@ -198,8 +187,8 @@ func (b *BaseMemoryBackend) Set(scope MemoryScope, scopeID, key string, value an
|
||||
if err != nil {
|
||||
return fmt.Errorf("encode value: %w", err)
|
||||
}
|
||||
return b.write(context.Background(), baseRecord{
|
||||
Scope: string(scope), ScopeID: scopeID, MKey: key, Value: string(encoded),
|
||||
return b.write(context.Background(), scope, baseRecord{
|
||||
ScopeID: scopeID, MKey: key, Value: string(encoded),
|
||||
})
|
||||
}
|
||||
|
||||
@@ -234,9 +223,7 @@ func (b *BaseMemoryBackend) Delete(scope MemoryScope, scopeID, key string) error
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all keys in a scope, following Base's pages to the end. A
|
||||
// caller asking for "all keys" and receiving the first thirty would read that
|
||||
// as the whole answer.
|
||||
// List returns every key in a scope, across all of Base's pages.
|
||||
func (b *BaseMemoryBackend) List(scope MemoryScope, scopeID string) ([]string, error) {
|
||||
ctx := context.Background()
|
||||
var keys []string
|
||||
@@ -266,8 +253,8 @@ func (b *BaseMemoryBackend) List(scope MemoryScope, scopeID string) ([]string, e
|
||||
|
||||
// SetVector stores a vector embedding with optional metadata.
|
||||
func (b *BaseMemoryBackend) SetVector(scope MemoryScope, scopeID, key string, embedding []float64, metadata map[string]any) error {
|
||||
return b.write(context.Background(), baseRecord{
|
||||
Scope: string(scope), ScopeID: scopeID, MKey: key,
|
||||
return b.write(context.Background(), scope, baseRecord{
|
||||
ScopeID: scopeID, MKey: key,
|
||||
Embedding: embedding, Metadata: metadata,
|
||||
})
|
||||
}
|
||||
@@ -281,17 +268,8 @@ func (b *BaseMemoryBackend) GetVector(scope MemoryScope, scopeID, key string) ([
|
||||
return rec.Embedding, rec.Metadata, true, nil
|
||||
}
|
||||
|
||||
// SearchVector ranks the scope's vectors by cosine similarity.
|
||||
//
|
||||
// Scored here rather than in the database, and that is a limit worth stating:
|
||||
// this reads the scope's vectors and ranks them in the client, so cost grows
|
||||
// with the size of the scope. It is right for an agent's own working set and
|
||||
// wrong for a corpus. Base can score server-side through a hook, and a scope
|
||||
// large enough to need that should use one.
|
||||
//
|
||||
// Threshold and Filters are applied while scoring, before Limit, so a limit
|
||||
// of five returns the five best MATCHING records rather than whatever survives
|
||||
// filtering out of the first five.
|
||||
// SearchVector ranks the scope's vectors by cosine similarity, in the client:
|
||||
// cost grows with the scope, which suits an agent's working set, not a corpus.
|
||||
func (b *BaseMemoryBackend) SearchVector(scope MemoryScope, scopeID string, embedding []float64, opts SearchOptions) ([]VectorSearchResult, error) {
|
||||
if len(embedding) == 0 {
|
||||
return nil, fmt.Errorf("search needs a query vector")
|
||||
@@ -318,10 +296,7 @@ func (b *BaseMemoryBackend) SearchVector(scope MemoryScope, scopeID string, embe
|
||||
break
|
||||
}
|
||||
for _, item := range out.Items {
|
||||
// A vector of a different width is not less similar, it is a
|
||||
// different embedding model. Skipped rather than scored, because
|
||||
// cosine over mismatched widths returns a number that means
|
||||
// nothing.
|
||||
// A different width is a different embedding model; skip it.
|
||||
if len(item.Embedding) != len(embedding) || !matches(item.Metadata, opts.Filters) {
|
||||
continue
|
||||
}
|
||||
@@ -331,7 +306,7 @@ func (b *BaseMemoryBackend) SearchVector(scope MemoryScope, scopeID string, embe
|
||||
}
|
||||
found = append(found, VectorSearchResult{
|
||||
Key: item.MKey, Score: score, Metadata: item.Metadata,
|
||||
Scope: MemoryScope(item.Scope), ScopeID: item.ScopeID,
|
||||
Scope: scope, ScopeID: item.ScopeID,
|
||||
})
|
||||
}
|
||||
if len(out.Items) >= out.TotalItems {
|
||||
@@ -342,10 +317,7 @@ func (b *BaseMemoryBackend) SearchVector(scope MemoryScope, scopeID string, embe
|
||||
return rank(found, opts.Limit), nil
|
||||
}
|
||||
|
||||
// DeleteVector removes a vector from storage.
|
||||
//
|
||||
// The embedding, not the record: a value may be stored at the same key and is
|
||||
// not the caller's to lose here.
|
||||
// DeleteVector clears the embedding and keeps any value stored at the key.
|
||||
func (b *BaseMemoryBackend) DeleteVector(scope MemoryScope, scopeID, key string) error {
|
||||
ctx := context.Background()
|
||||
rec, found, err := b.find(ctx, scope, scopeID, key)
|
||||
|
||||
@@ -4,16 +4,11 @@ import (
|
||||
"context"
|
||||
"fmt"
|
||||
|
||||
"github.com/hanzoai/agents/sdk/go/agent"
|
||||
"github.com/hanzoai/agent/sdk/go/agent"
|
||||
)
|
||||
|
||||
// Keeping an agent's memory in Hanzo Base.
|
||||
//
|
||||
// The collection must already exist, carrying the fields the backend writes:
|
||||
// scope, scope_id, mkey and value as text, embedding and metadata as json.
|
||||
// Create it once with the Base admin UI or its CLI — an SDK that creates
|
||||
// schema on first write can create the WRONG schema from a typo, and then read
|
||||
// nothing from the collection everyone else is looking at.
|
||||
// Keeping an agent's memory in Hanzo Base. Import agent_memory.collection.json
|
||||
// once first; MEMORY.md shows how.
|
||||
func ExampleNewBaseMemoryBackend() {
|
||||
memory := agent.NewMemory(
|
||||
agent.NewBaseMemoryBackend("http://127.0.0.1:8090", token, "agent_memory"),
|
||||
@@ -24,8 +19,7 @@ func ExampleNewBaseMemoryBackend() {
|
||||
panic(err)
|
||||
}
|
||||
|
||||
// GetWithDefault, because Memory.Get folds an absent key and a stored nil
|
||||
// into one answer — the backend tells them apart, the wrapper does not.
|
||||
// Memory.Get cannot tell an absent key from a stored nil; this can.
|
||||
tone, err := memory.GetWithDefault(ctx, "tone", "plain")
|
||||
if err != nil {
|
||||
panic(err)
|
||||
@@ -33,9 +27,7 @@ func ExampleNewBaseMemoryBackend() {
|
||||
fmt.Println(tone)
|
||||
}
|
||||
|
||||
// Recalling by similarity. The embedding is the caller's to compute — the
|
||||
// backend stores the vector it is handed and scores against it, so which model
|
||||
// produced it stays one decision made in one place.
|
||||
// Recalling by similarity. The caller computes the embedding.
|
||||
func ExampleNewBaseMemoryBackend_recall() {
|
||||
memory := agent.NewMemory(
|
||||
agent.NewBaseMemoryBackend("http://127.0.0.1:8090", token, "agent_memory"),
|
||||
@@ -60,9 +52,7 @@ func ExampleNewBaseMemoryBackend_recall() {
|
||||
}
|
||||
}
|
||||
|
||||
// Stand-ins so the examples above read as the calling code someone writes,
|
||||
// rather than as setup. A real token comes from Hanzo IAM and a real embedding
|
||||
// from whichever model the caller has chosen.
|
||||
// Stand-ins: a real token comes from Hanzo IAM, a real embedding from a model.
|
||||
var token = "..."
|
||||
|
||||
func embed(string) []float64 { return nil }
|
||||
|
||||
@@ -20,6 +20,8 @@ type fakeBase struct {
|
||||
// Every path this backend asked for, so a test can say what it sent and
|
||||
// not only what it got back.
|
||||
asked []string
|
||||
// Runs before a create is decided, which is where a second writer lands.
|
||||
beforeCreate func()
|
||||
}
|
||||
|
||||
var clause = regexp.MustCompile(`(\w+)='((?:[^'\\]|\\.)*)'`)
|
||||
@@ -118,15 +120,30 @@ func (f *fakeBase) list(w http.ResponseWriter, r *http.Request) {
|
||||
json.NewEncoder(w).Encode(baseList{Items: hits[from:to], TotalItems: total})
|
||||
}
|
||||
|
||||
// A create under the unique index over scope, scope_id and mkey.
|
||||
func (f *fakeBase) create(w http.ResponseWriter, r *http.Request) {
|
||||
var rec baseRecord
|
||||
json.NewDecoder(r.Body).Decode(&rec)
|
||||
if f.beforeCreate != nil {
|
||||
f.beforeCreate()
|
||||
}
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
for _, have := range f.records {
|
||||
if have.Scope == rec.Scope && have.ScopeID == rec.ScopeID && have.MKey == rec.MKey {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"message":"Failed to create record.","data":{"mkey":{"code":"validation_not_unique"}}}`))
|
||||
return
|
||||
}
|
||||
}
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(f.insert(rec))
|
||||
}
|
||||
|
||||
func (f *fakeBase) insert(rec baseRecord) *baseRecord {
|
||||
f.next++
|
||||
rec.ID = fmt.Sprintf("r%d", f.next)
|
||||
f.records[rec.ID] = &rec
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.WriteHeader(http.StatusCreated)
|
||||
json.NewEncoder(w).Encode(rec)
|
||||
return &rec
|
||||
}
|
||||
|
||||
// A merge, which is what PATCH means: a field the request does not carry keeps
|
||||
@@ -399,6 +416,45 @@ func TestBaseMemorySearchRefusesEmptyQuery(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
// Two writers creating one key race between the read and the create, and the
|
||||
// unique index refuses the second record. A Set means the last write wins, so
|
||||
// the loser updates the record that won rather than failing.
|
||||
func TestBaseMemoryLastWriteWinsARace(t *testing.T) {
|
||||
backend, fake := backedByFake(t)
|
||||
fake.beforeCreate = func() {
|
||||
fake.beforeCreate = nil
|
||||
fake.insert(baseRecord{Scope: "session", ScopeID: "s1", MKey: "k", Value: `"theirs"`})
|
||||
}
|
||||
|
||||
if err := backend.Set(ScopeSession, "s1", "k", "ours"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
if got, _, _ := backend.Get(ScopeSession, "s1", "k"); got != "ours" {
|
||||
t.Fatalf("got %#v, want ours", got)
|
||||
}
|
||||
if len(fake.records) != 1 {
|
||||
t.Fatalf("%d records, want 1", len(fake.records))
|
||||
}
|
||||
}
|
||||
|
||||
// Go names the per-user scope "user"; the control plane and the other SDKs
|
||||
// name it "actor". Records are filed under the shared name so every SDK reads
|
||||
// the others' records.
|
||||
func TestBaseMemoryFilesUserScopeAsActor(t *testing.T) {
|
||||
backend, fake := backedByFake(t)
|
||||
if err := backend.Set(ScopeUser, "u1", "tone", "plain"); err != nil {
|
||||
t.Fatalf("set: %v", err)
|
||||
}
|
||||
for _, rec := range fake.records {
|
||||
if rec.Scope != "actor" {
|
||||
t.Fatalf("filed under %q, want actor", rec.Scope)
|
||||
}
|
||||
}
|
||||
if got, found, _ := backend.Get(ScopeUser, "u1", "tone"); !found || got != "plain" {
|
||||
t.Fatalf("got %#v found=%v", got, found)
|
||||
}
|
||||
}
|
||||
|
||||
// A value and an embedding share one record, so neither write may erase the
|
||||
// other and neither delete may take both.
|
||||
func TestBaseMemoryValueAndVectorCoexist(t *testing.T) {
|
||||
|
||||
@@ -54,7 +54,7 @@ func (b *ControlPlaneMemoryBackend) Set(scope MemoryScope, scopeID, key string,
|
||||
body := map[string]any{
|
||||
"key": key,
|
||||
"data": value,
|
||||
"scope": b.apiScope(scope),
|
||||
"scope": apiScope(scope),
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, mustJSONReader(body))
|
||||
if err != nil {
|
||||
@@ -83,7 +83,7 @@ func (b *ControlPlaneMemoryBackend) Get(scope MemoryScope, scopeID, key string)
|
||||
|
||||
body := map[string]any{
|
||||
"key": key,
|
||||
"scope": b.apiScope(scope),
|
||||
"scope": apiScope(scope),
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, mustJSONReader(body))
|
||||
if err != nil {
|
||||
@@ -120,7 +120,7 @@ func (b *ControlPlaneMemoryBackend) Delete(scope MemoryScope, scopeID, key strin
|
||||
|
||||
body := map[string]any{
|
||||
"key": key,
|
||||
"scope": b.apiScope(scope),
|
||||
"scope": apiScope(scope),
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, mustJSONReader(body))
|
||||
if err != nil {
|
||||
@@ -150,7 +150,7 @@ func (b *ControlPlaneMemoryBackend) List(scope MemoryScope, scopeID string) ([]s
|
||||
return nil, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint+"?scope="+url.QueryEscape(b.apiScope(scope)), nil)
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint+"?scope="+url.QueryEscape(apiScope(scope)), nil)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
@@ -183,7 +183,7 @@ func (b *ControlPlaneMemoryBackend) List(scope MemoryScope, scopeID string) ([]s
|
||||
}
|
||||
|
||||
func (b *ControlPlaneMemoryBackend) SetVector(scope MemoryScope, scopeID, key string, embedding []float64, metadata map[string]any) error {
|
||||
endpoint, err := url.JoinPath(b.baseURL, "/v1/memory/vector/set")
|
||||
endpoint, err := url.JoinPath(b.baseURL, "/v1/memory/vector")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -198,7 +198,7 @@ func (b *ControlPlaneMemoryBackend) SetVector(scope MemoryScope, scopeID, key st
|
||||
"key": key,
|
||||
"embedding": embeddingF32,
|
||||
"metadata": metadata,
|
||||
"scope": b.apiScope(scope),
|
||||
"scope": apiScope(scope),
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, mustJSONReader(body))
|
||||
if err != nil {
|
||||
@@ -225,7 +225,7 @@ func (b *ControlPlaneMemoryBackend) GetVector(scope MemoryScope, scopeID, key st
|
||||
return nil, nil, false, err
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint+"?scope="+url.QueryEscape(b.apiScope(scope)), nil)
|
||||
req, err := http.NewRequest(http.MethodGet, endpoint+"?scope="+url.QueryEscape(apiScope(scope)), nil)
|
||||
if err != nil {
|
||||
return nil, nil, false, err
|
||||
}
|
||||
@@ -279,10 +279,10 @@ func (b *ControlPlaneMemoryBackend) SearchVector(scope MemoryScope, scopeID stri
|
||||
"top_k": opts.Limit,
|
||||
"threshold": opts.Threshold,
|
||||
"filters": opts.Filters,
|
||||
"scope": b.apiScope(scope),
|
||||
"scope": apiScope(scope),
|
||||
}
|
||||
if opts.Scope != "" {
|
||||
body["scope"] = b.apiScope(opts.Scope)
|
||||
body["scope"] = apiScope(opts.Scope)
|
||||
}
|
||||
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, mustJSONReader(body))
|
||||
@@ -327,19 +327,12 @@ func (b *ControlPlaneMemoryBackend) SearchVector(scope MemoryScope, scopeID stri
|
||||
}
|
||||
|
||||
func (b *ControlPlaneMemoryBackend) DeleteVector(scope MemoryScope, scopeID, key string) error {
|
||||
// The address and the shape the Python and TypeScript SDKs both send:
|
||||
// POST, with the key in the body. This was alone in deleting a vector by a
|
||||
// keyed DELETE, which no server serving the other two would answer.
|
||||
endpoint, err := url.JoinPath(b.baseURL, "/v1/memory/vector/delete")
|
||||
endpoint, err := url.JoinPath(b.baseURL, "/v1/memory/vector", url.PathEscape(key))
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
body := map[string]any{
|
||||
"key": key,
|
||||
"scope": b.apiScope(scope),
|
||||
}
|
||||
req, err := http.NewRequest(http.MethodPost, endpoint, mustJSONReader(body))
|
||||
req, err := http.NewRequest(http.MethodDelete, endpoint+"?scope="+url.QueryEscape(apiScope(scope)), nil)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
@@ -372,7 +365,7 @@ func (b *ControlPlaneMemoryBackend) applyHeaders(req *http.Request, scope Memory
|
||||
}
|
||||
|
||||
// Provide the scope ID via headers so the control plane can resolve scope_id consistently.
|
||||
switch b.apiScope(scope) {
|
||||
switch apiScope(scope) {
|
||||
case "workflow":
|
||||
if scopeID != "" {
|
||||
req.Header.Set("X-Workflow-ID", scopeID)
|
||||
@@ -390,7 +383,9 @@ func (b *ControlPlaneMemoryBackend) applyHeaders(req *http.Request, scope Memory
|
||||
}
|
||||
}
|
||||
|
||||
func (b *ControlPlaneMemoryBackend) apiScope(scope MemoryScope) string {
|
||||
// apiScope is the name the control plane and the other SDKs give a scope. Go
|
||||
// calls the per-user scope "user"; everyone else calls it "actor".
|
||||
func apiScope(scope MemoryScope) string {
|
||||
switch scope {
|
||||
case ScopeWorkflow:
|
||||
return "workflow"
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"strconv"
|
||||
"strings"
|
||||
|
||||
"github.com/hanzoai/agents/sdk/go/types"
|
||||
"github.com/hanzoai/agent/sdk/go/types"
|
||||
)
|
||||
|
||||
// DiscoveryOption configures discovery requests.
|
||||
|
||||
@@ -464,11 +464,8 @@ func (b *InMemoryBackend) GetVector(scope MemoryScope, scopeID, key string) ([]f
|
||||
return rec.embedding, rec.metadata, true, nil
|
||||
}
|
||||
|
||||
// SearchVector ranks this scope's vectors by cosine similarity.
|
||||
//
|
||||
// A vector of a different width is skipped rather than scored: a different
|
||||
// width means a different embedding model, and cosine across two of those
|
||||
// returns a number that means nothing.
|
||||
// SearchVector ranks this scope's vectors by cosine similarity. Vectors of
|
||||
// another width come from another model and are skipped.
|
||||
func (b *InMemoryBackend) SearchVector(scope MemoryScope, scopeID string, embedding []float64, opts SearchOptions) ([]VectorSearchResult, error) {
|
||||
if len(embedding) == 0 {
|
||||
return nil, errors.New("search needs a query vector")
|
||||
|
||||
@@ -61,12 +61,9 @@ func (a *Agent) sendNote(ctx context.Context, message string, tags []string) {
|
||||
// Get execution context from the provided context
|
||||
execCtx := ExecutionContextFrom(ctx)
|
||||
|
||||
// Notes go to a /ui subtree of the version prefix, which is the address
|
||||
// the Python and TypeScript SDKs also send them to. A caller may hand over
|
||||
// the server root or a base that already carries a version, so one
|
||||
// trailing version segment comes off before this appends its own.
|
||||
root := strings.TrimSuffix(strings.TrimSuffix(strings.TrimSuffix(baseURL, "/"), "/v1"), "/api")
|
||||
noteURL := root + "/v1/ui/executions/note"
|
||||
// Notes go to the /ui subtree of the version prefix. A base that already
|
||||
// carries its version loses it before one is appended.
|
||||
noteURL := strings.TrimSuffix(strings.TrimSuffix(baseURL, "/"), "/v1") + "/v1/ui/executions/note"
|
||||
|
||||
// Build payload
|
||||
payload := notePayload{
|
||||
|
||||
@@ -39,7 +39,7 @@ func TestNote_Basic(t *testing.T) {
|
||||
cfg := Config{
|
||||
NodeID: "test-node",
|
||||
Version: "1.0.0",
|
||||
HanzoAgentsURL: server.URL + "/v1", // the note goes to /v1/ui below
|
||||
HanzoAgentsURL: server.URL + "/v1", // Will be converted to /v1/ui
|
||||
Logger: log.New(io.Discard, "", 0),
|
||||
}
|
||||
|
||||
@@ -226,12 +226,12 @@ func TestNote_URLConversion(t *testing.T) {
|
||||
expectedPath string
|
||||
}{
|
||||
{
|
||||
name: "a base carrying the version",
|
||||
name: "Standard /v1 URL",
|
||||
controlPlaneURL: server.URL + "/v1",
|
||||
expectedPath: "/v1/ui/executions/note",
|
||||
},
|
||||
{
|
||||
name: "the server root",
|
||||
name: "URL without /v1",
|
||||
controlPlaneURL: server.URL,
|
||||
expectedPath: "/v1/ui/executions/note",
|
||||
},
|
||||
|
||||
+3
-20
@@ -5,13 +5,7 @@ import (
|
||||
"sort"
|
||||
)
|
||||
|
||||
// The vector math every memory backend needs, in one place.
|
||||
//
|
||||
// It lived nowhere: the in-memory backend answered a similarity search with an
|
||||
// empty list and a comment saying vector math was required, which is the one
|
||||
// answer a caller cannot tell from "nothing was similar". A search that cannot
|
||||
// run should say so; a search that can should run. Both backends hold the
|
||||
// vectors already, so the arithmetic is all that was missing.
|
||||
// The vector math every memory backend shares.
|
||||
|
||||
// cosine is the similarity of two vectors of equal width: 1 for the same
|
||||
// direction, 0 for perpendicular, -1 for opposite. A zero vector has no
|
||||
@@ -29,12 +23,8 @@ func cosine(a, b []float64) float64 {
|
||||
return dot / (math.Sqrt(na) * math.Sqrt(nb))
|
||||
}
|
||||
|
||||
// matches reports whether a record's metadata satisfies every filter.
|
||||
//
|
||||
// Equality on the JSON shapes metadata actually carries, so a filter written
|
||||
// as a number matches a record decoded from JSON as a float. A filter naming a
|
||||
// key the record does not carry excludes it — the caller asked for records
|
||||
// where that key holds a value, and a record without the key is not one.
|
||||
// matches reports whether metadata satisfies every filter. A missing key fails
|
||||
// its filter; numbers compare as float64, as JSON decodes them.
|
||||
func matches(metadata map[string]any, filters map[string]any) bool {
|
||||
for key, want := range filters {
|
||||
got, ok := metadata[key]
|
||||
@@ -45,9 +35,6 @@ func matches(metadata map[string]any, filters map[string]any) bool {
|
||||
return true
|
||||
}
|
||||
|
||||
// same compares two decoded JSON values. Numbers are compared as float64
|
||||
// because that is what a JSON decode produces, so a filter of `2` written as
|
||||
// an int still matches a stored `2`.
|
||||
func same(a, b any) bool {
|
||||
if x, ok := number(a); ok {
|
||||
if y, ok := number(b); ok {
|
||||
@@ -76,10 +63,6 @@ func number(v any) (float64, bool) {
|
||||
}
|
||||
|
||||
// rank orders results by score, strongest first, and cuts to the limit.
|
||||
//
|
||||
// The threshold is applied while scoring, before this — cutting to the limit
|
||||
// first would return fewer results than asked for whenever the tail is weak,
|
||||
// which reads as missing data rather than as a weak tail.
|
||||
func rank(found []VectorSearchResult, limit int) []VectorSearchResult {
|
||||
sort.SliceStable(found, func(i, j int) bool { return found[i].Score > found[j].Score })
|
||||
if limit > 0 && len(found) > limit {
|
||||
|
||||
+2
-2
@@ -18,8 +18,8 @@ This package provides AI/LLM capabilities for the Hanzo Agents Go SDK, supportin
|
||||
```go
|
||||
import (
|
||||
"context"
|
||||
"github.com/hanzoai/agents/sdk/go/agent"
|
||||
"github.com/hanzoai/agents/sdk/go/ai"
|
||||
"github.com/hanzoai/agent/sdk/go/agent"
|
||||
"github.com/hanzoai/agent/sdk/go/ai"
|
||||
)
|
||||
|
||||
// Create agent with AI configured
|
||||
|
||||
@@ -15,9 +15,10 @@ import (
|
||||
)
|
||||
|
||||
func TestNewClient(t *testing.T) {
|
||||
// Cleared for the same reason as in TestDefaultConfig: the default-config
|
||||
// case asserts what a caller with nothing set receives.
|
||||
// A caller with no provider key set.
|
||||
t.Setenv("HANZO_API_KEY", "")
|
||||
t.Setenv("OPENROUTER_API_KEY", "")
|
||||
t.Setenv("OPENAI_API_KEY", "")
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
|
||||
+5
-21
@@ -36,38 +36,22 @@ type Config struct {
|
||||
}
|
||||
|
||||
// Where a client points when nobody says otherwise.
|
||||
//
|
||||
// api.hanzo.ai, because this is Hanzo's SDK. The default used to be
|
||||
// api.openai.com, which meant a caller who set an API key and nothing else
|
||||
// sent that key to another company — and the example in this package had to
|
||||
// set BaseURL by hand to avoid teaching it.
|
||||
const (
|
||||
hanzoBaseURL = "https://api.hanzo.ai/v1"
|
||||
|
||||
// The same default the MCP runtime carries (rust/src/tools), so one
|
||||
// estate answers with one model rather than each client choosing.
|
||||
// The model the MCP runtime also defaults to.
|
||||
hanzoModel = "zen3-vl"
|
||||
)
|
||||
|
||||
// DefaultConfig returns a Config with sensible defaults, read from the
|
||||
// environment.
|
||||
//
|
||||
// Ours first: HANZO_API_KEY selects api.hanzo.ai. A third-party key is an
|
||||
// explicit choice and is still honoured — OPENAI_API_KEY or OPENROUTER_API_KEY
|
||||
// each select that provider's endpoint and a model it serves — so every setup
|
||||
// that worked before still works, and the only behaviour that changed is the
|
||||
// one where nobody had chosen at all.
|
||||
//
|
||||
// AI_BASE_URL and AI_MODEL override whichever of those was selected.
|
||||
// DefaultConfig reads the environment. HANZO_API_KEY selects api.hanzo.ai;
|
||||
// otherwise OPENROUTER_API_KEY, then OPENAI_API_KEY, selects that provider.
|
||||
// AI_BASE_URL and AI_MODEL override the result.
|
||||
func DefaultConfig() *Config {
|
||||
apiKey := os.Getenv("HANZO_API_KEY")
|
||||
baseURL := hanzoBaseURL
|
||||
model := hanzoModel
|
||||
|
||||
// Only where our own key is absent, so setting HANZO_API_KEY is enough to
|
||||
// mean it and a stale third-party key in a shell cannot redirect it.
|
||||
// OpenRouter ahead of OpenAI where both are set, which is the precedence
|
||||
// this package already had and has a test naming it.
|
||||
// OpenRouter ahead of OpenAI where both are set.
|
||||
if apiKey == "" {
|
||||
if k := os.Getenv("OPENROUTER_API_KEY"); k != "" {
|
||||
apiKey, baseURL, model = k, "https://openrouter.ai/api/v1", "openai/gpt-4o"
|
||||
|
||||
@@ -10,10 +10,7 @@ import (
|
||||
)
|
||||
|
||||
func TestDefaultConfig(t *testing.T) {
|
||||
// Ours, cleared for the whole table. Every case below describes a caller who
|
||||
// has NOT set it, and t.Setenv restores whatever the developer's shell had.
|
||||
// Without this the suite passes or fails depending on the environment it
|
||||
// runs in, which is the one thing a test may not do.
|
||||
// Every case describes a caller without HANZO_API_KEY.
|
||||
t.Setenv("HANZO_API_KEY", "")
|
||||
|
||||
// Save original env vars
|
||||
|
||||
@@ -6,14 +6,11 @@ import (
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/agents/sdk/go/ai"
|
||||
"github.com/hanzoai/agent/sdk/go/ai"
|
||||
)
|
||||
|
||||
// The shortest thing this client does: one prompt, one answer.
|
||||
//
|
||||
// A nil config reads the environment: HANZO_API_KEY selects api.hanzo.ai, and
|
||||
// a third-party key selects that provider instead. Nothing here names an
|
||||
// address, because the default is already ours.
|
||||
// One prompt, one answer. A nil config reads the environment, so HANZO_API_KEY
|
||||
// is all this needs.
|
||||
func ExampleClient_Complete() {
|
||||
client, err := ai.NewClient(nil)
|
||||
if err != nil {
|
||||
@@ -21,8 +18,7 @@ func ExampleClient_Complete() {
|
||||
return
|
||||
}
|
||||
|
||||
// A deadline, because a model call is a network call and an example without
|
||||
// one teaches a program that can hang.
|
||||
// A model call is a network call; give it a deadline.
|
||||
ctx, cancel := context.WithTimeout(context.Background(), 60*time.Second)
|
||||
defer cancel()
|
||||
|
||||
@@ -32,9 +28,7 @@ func ExampleClient_Complete() {
|
||||
return
|
||||
}
|
||||
|
||||
// Choices is a slice and a refusal can leave it empty, so it is checked
|
||||
// rather than indexed — Choices[0] on an empty answer panics, which is a
|
||||
// worse failure than the one that produced it.
|
||||
// A refusal can leave Choices empty.
|
||||
if len(answer.Choices) == 0 {
|
||||
fmt.Println("the model returned no choices")
|
||||
return
|
||||
|
||||
@@ -12,7 +12,7 @@ import (
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/agents/sdk/go/types"
|
||||
"github.com/hanzoai/agent/sdk/go/types"
|
||||
)
|
||||
|
||||
// Client provides a thin wrapper over the HanzoAgents control plane REST API.
|
||||
|
||||
@@ -10,7 +10,7 @@ import (
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/hanzoai/agents/sdk/go/types"
|
||||
"github.com/hanzoai/agent/sdk/go/types"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
+2
-2
@@ -1,6 +1,6 @@
|
||||
module github.com/hanzoai/agents/sdk/go
|
||||
module github.com/hanzoai/agent/sdk/go
|
||||
|
||||
go 1.26.8
|
||||
go 1.26.4
|
||||
|
||||
require (
|
||||
github.com/stretchr/testify v1.8.4
|
||||
|
||||
@@ -11,7 +11,7 @@ pip install hanzo-agents
|
||||
To work on the SDK locally:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/hanzoai/agents.git
|
||||
git clone https://github.com/hanzoai/agent.git
|
||||
cd hanzo-agents/sdk/python
|
||||
python -m pip install -e .[dev]
|
||||
```
|
||||
|
||||
@@ -80,4 +80,4 @@ __all__ = [
|
||||
"register_provider",
|
||||
]
|
||||
|
||||
__version__ = "0.1.41-rc.2"
|
||||
__version__ = "0.2.10"
|
||||
|
||||
@@ -1173,13 +1173,12 @@ class Agent(FastAPI):
|
||||
if not self._current_execution_context:
|
||||
return None
|
||||
|
||||
# `store` selects where state is kept: pass a BaseMemory to keep it in
|
||||
# Hanzo Base. Absent, it goes to the control plane as before. Named
|
||||
# `store` rather than `memory` because `memory` is already the property
|
||||
# a handler reads through.
|
||||
memory_client = self.store or MemoryClient(
|
||||
self.client, self._current_execution_context, agent_node_id=self.node_id
|
||||
)
|
||||
if self.store:
|
||||
memory_client = self.store.bind(self._current_execution_context)
|
||||
else:
|
||||
memory_client = MemoryClient(
|
||||
self.client, self._current_execution_context, agent_node_id=self.node_id
|
||||
)
|
||||
if not self.memory_event_client:
|
||||
self.memory_event_client = MemoryEventClient(
|
||||
self.hanzo_agents_server, self._get_current_execution_context(), self.api_key
|
||||
@@ -3419,11 +3418,7 @@ class Agent(FastAPI):
|
||||
import aiohttp
|
||||
|
||||
timeout = aiohttp.ClientTimeout(total=5.0) # 5 second timeout
|
||||
# The UI reads notes from a /ui subtree of the same
|
||||
# version prefix. Appended rather than substituted: the old
|
||||
# replace("/v1", "/v1/ui") rewrote the FIRST /v1 it found,
|
||||
# which is a base url's own path segment whenever one has
|
||||
# a version in it, and its comment claimed the last.
|
||||
# Notes go to the /ui subtree of the version prefix.
|
||||
ui_api_base = f"{self.client.api_base}/ui"
|
||||
|
||||
if self.dev_mode:
|
||||
|
||||
@@ -1,40 +1,22 @@
|
||||
"""
|
||||
Agent memory kept in Hanzo Base.
|
||||
|
||||
Base is the single-binary Go backend, so an agent's state sits in the same
|
||||
process tree as everything it runs beside: no second database, no separate
|
||||
migration, and the records are visible in Base's own admin view while the
|
||||
agent is running.
|
||||
|
||||
One record per scope and key, in one collection. A collection per scope would
|
||||
make the number of collections a function of how many workflows have ever run.
|
||||
|
||||
The same records, schema and escaping rules as the Go and TypeScript stores, so
|
||||
all three SDKs read each other's memory.
|
||||
"""
|
||||
"""Agent memory kept in Hanzo Base, in the records the Go and TypeScript stores use."""
|
||||
|
||||
import copy
|
||||
import json
|
||||
import math
|
||||
from typing import Any, Dict, List, Optional, Sequence, Union
|
||||
from typing import Any, Dict, List, Optional, Sequence, Tuple, Union
|
||||
from urllib.parse import quote as urlquote
|
||||
|
||||
import requests
|
||||
|
||||
from .execution_context import ExecutionContext
|
||||
from .memory import _to_thread, _vector_to_list
|
||||
|
||||
|
||||
class BaseMemory:
|
||||
"""
|
||||
Memory over Base's records API.
|
||||
|
||||
Stands where ``MemoryClient`` stands, with the same eight methods, so a
|
||||
``MemoryInterface`` takes either.
|
||||
|
||||
The collection must exist, carrying the fields this writes. Import
|
||||
``agent_memory.collection.json`` from the Go SDK once — the schema is
|
||||
shared. It is not created on first write: a client that builds its own
|
||||
schema can build the wrong one from a typo and then read nothing from the
|
||||
collection everyone else is looking at.
|
||||
Memory over Base's records API, with ``MemoryClient``'s methods so a
|
||||
``MemoryInterface`` takes either. Import ``agent_memory.collection.json``
|
||||
from the Go SDK once; nothing here creates schema.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
@@ -42,19 +24,69 @@ class BaseMemory:
|
||||
base_url: str,
|
||||
token: str,
|
||||
collection: str = "agent_memory",
|
||||
default_scope: str = "workflow",
|
||||
timeout: float = 30.0,
|
||||
context: Optional[ExecutionContext] = None,
|
||||
):
|
||||
self.base_url = base_url.rstrip("/")
|
||||
self.token = token
|
||||
self.collection = collection
|
||||
self.default_scope = default_scope
|
||||
self.timeout = timeout
|
||||
self.context = context
|
||||
|
||||
@property
|
||||
def _records(self) -> str:
|
||||
return f"{self.base_url}/v1/collections/{urlquote(self.collection)}/records"
|
||||
|
||||
def bind(self, context: Optional[ExecutionContext]) -> "BaseMemory":
|
||||
"""This store, answering for one execution."""
|
||||
bound = copy.copy(self)
|
||||
bound.context = context
|
||||
return bound
|
||||
|
||||
def _ids(self) -> Dict[str, Optional[str]]:
|
||||
ctx = self.context
|
||||
return {
|
||||
"workflow": ctx and (ctx.workflow_id or ctx.run_id),
|
||||
"session": ctx and ctx.session_id,
|
||||
"actor": ctx and ctx.actor_id,
|
||||
"global": "global",
|
||||
}
|
||||
|
||||
def _where(self, scope: Optional[str], scope_id: Optional[str]) -> Tuple[str, str]:
|
||||
"""
|
||||
The scope a call addresses and the id within it, resolved the way the
|
||||
control plane resolves them: an explicit scope takes its id from the
|
||||
caller or from the execution; no scope means the narrowest one the
|
||||
execution names, and global otherwise. Global's id is "global" in every
|
||||
SDK, so their records meet.
|
||||
"""
|
||||
ids = self._ids()
|
||||
if scope:
|
||||
return scope, scope_id or ids.get(scope) or ""
|
||||
for name in ("workflow", "session", "actor"):
|
||||
if ids[name]:
|
||||
return name, ids[name]
|
||||
return "global", "global"
|
||||
|
||||
async def _read(
|
||||
self, scope: Optional[str], scope_id: Optional[str], key: str
|
||||
) -> Optional[Dict[str, Any]]:
|
||||
"""
|
||||
The record holding a value at a key. An explicit scope is read alone; no
|
||||
scope reads workflow, session, actor and global in that order, as the
|
||||
control plane's get does.
|
||||
"""
|
||||
if scope:
|
||||
record = await self._find(scope, scope_id, key)
|
||||
return record if record and record.get("value") else None
|
||||
ids = self._ids()
|
||||
for name in ("workflow", "session", "actor", "global"):
|
||||
if ids[name]:
|
||||
record = await self._find(name, ids[name], key)
|
||||
if record and record.get("value"):
|
||||
return record
|
||||
return None
|
||||
|
||||
async def set(
|
||||
self,
|
||||
key: str,
|
||||
@@ -73,8 +105,8 @@ class BaseMemory:
|
||||
scope_id: Optional[str] = None,
|
||||
) -> Any:
|
||||
"""Read a value, answering ``default`` where the key was never written."""
|
||||
record = await self._find(scope, scope_id, key)
|
||||
if not record or not record.get("value"):
|
||||
record = await self._read(scope, scope_id, key)
|
||||
if not record:
|
||||
return default
|
||||
return json.loads(record["value"])
|
||||
|
||||
@@ -85,8 +117,7 @@ class BaseMemory:
|
||||
scope_id: Optional[str] = None,
|
||||
) -> bool:
|
||||
"""Whether a value is stored at this key."""
|
||||
record = await self._find(scope, scope_id, key)
|
||||
return bool(record and record.get("value"))
|
||||
return bool(await self._read(scope, scope_id, key))
|
||||
|
||||
async def delete(
|
||||
self,
|
||||
@@ -104,11 +135,7 @@ class BaseMemory:
|
||||
await self._ask("DELETE", f"{self._records}/{urlquote(record['id'])}")
|
||||
|
||||
async def list_keys(self, scope: str, scope_id: Optional[str] = None) -> List[str]:
|
||||
"""
|
||||
Every key in a scope, following Base's pages to the end. A caller
|
||||
asking for all the keys and receiving the first page would read that as
|
||||
the whole answer.
|
||||
"""
|
||||
"""Every key in a scope, across all of Base's pages."""
|
||||
keys: List[str] = []
|
||||
page = 1
|
||||
while True:
|
||||
@@ -118,7 +145,7 @@ class BaseMemory:
|
||||
params={
|
||||
"perPage": 200,
|
||||
"page": page,
|
||||
"filter": _filter_for(scope, scope_id or "", None),
|
||||
"filter": _filter_for(*self._where(scope, scope_id), None),
|
||||
},
|
||||
)
|
||||
items = answer.get("items") or []
|
||||
@@ -149,10 +176,7 @@ class BaseMemory:
|
||||
scope: Optional[str] = None,
|
||||
scope_id: Optional[str] = None,
|
||||
) -> None:
|
||||
"""
|
||||
Remove an embedding. The embedding, not the record: a value may be
|
||||
stored at the same key and is not the caller's to lose here.
|
||||
"""
|
||||
"""Clear the embedding and keep any value stored at the key."""
|
||||
record = await self._find(scope, scope_id, key)
|
||||
if not record:
|
||||
return
|
||||
@@ -171,20 +195,14 @@ class BaseMemory:
|
||||
filters: Optional[Dict[str, Any]] = None,
|
||||
) -> List[Dict[str, Any]]:
|
||||
"""
|
||||
Rank the scope's vectors by cosine similarity, strongest first.
|
||||
|
||||
Scored here rather than in the database, and that is a limit worth
|
||||
stating: it reads the scope's vectors and ranks them in the client, so
|
||||
cost grows with the size of the scope. Right for an agent's own working
|
||||
set, wrong for a corpus. Base can score server-side through a hook, and
|
||||
a scope large enough to need that should use one.
|
||||
Rank the scope's vectors by cosine similarity, strongest first, in the
|
||||
client: cost grows with the scope, which suits an agent's working set.
|
||||
"""
|
||||
query = _vector_to_list(query_embedding)
|
||||
if not query:
|
||||
raise ValueError("search needs a query vector")
|
||||
|
||||
where = scope or self.default_scope
|
||||
of = scope_id or ""
|
||||
where, of = self._where(scope, scope_id)
|
||||
found: List[Dict[str, Any]] = []
|
||||
page = 1
|
||||
while True:
|
||||
@@ -203,9 +221,7 @@ class BaseMemory:
|
||||
|
||||
for item in items:
|
||||
embedding = item.get("embedding") or []
|
||||
# A vector of a different width came from a different embedding
|
||||
# model. Cosine across two of those returns a number that means
|
||||
# nothing, so it is skipped rather than scored.
|
||||
# A different width is a different embedding model; skip it.
|
||||
if len(embedding) != len(query):
|
||||
continue
|
||||
if not _matches(item.get("metadata"), filters):
|
||||
@@ -235,9 +251,7 @@ class BaseMemory:
|
||||
self._records,
|
||||
params={
|
||||
"perPage": 1,
|
||||
"filter": _filter_for(
|
||||
scope or self.default_scope, scope_id or "", key
|
||||
),
|
||||
"filter": _filter_for(*self._where(scope, scope_id), key),
|
||||
},
|
||||
)
|
||||
items = answer.get("items") or []
|
||||
@@ -256,25 +270,30 @@ class BaseMemory:
|
||||
PATCH, not PUT: setting a value must not erase an embedding written
|
||||
beside it, and the two share a record.
|
||||
"""
|
||||
existing = await self._find(scope, scope_id, key)
|
||||
where, of = self._where(scope, scope_id)
|
||||
existing = await self._find(where, of, key)
|
||||
if existing:
|
||||
await self._ask(
|
||||
"PATCH",
|
||||
f"{self._records}/{urlquote(existing['id'])}",
|
||||
body=fields,
|
||||
"PATCH", f"{self._records}/{urlquote(existing['id'])}", body=fields
|
||||
)
|
||||
return
|
||||
|
||||
await self._ask(
|
||||
"POST",
|
||||
self._records,
|
||||
body={
|
||||
"scope": scope or self.default_scope,
|
||||
"scope_id": scope_id or "",
|
||||
"mkey": key,
|
||||
**fields,
|
||||
},
|
||||
)
|
||||
try:
|
||||
await self._ask(
|
||||
"POST",
|
||||
self._records,
|
||||
body={"scope": where, "scope_id": of, "mkey": key, **fields},
|
||||
)
|
||||
except RuntimeError:
|
||||
# Another writer created the key between the read and this create,
|
||||
# and the unique index refused a second record. A set means the last
|
||||
# write wins, so update the record that won.
|
||||
winner = await self._find(where, of, key)
|
||||
if not winner:
|
||||
raise
|
||||
await self._ask(
|
||||
"PATCH", f"{self._records}/{urlquote(winner['id'])}", body=fields
|
||||
)
|
||||
|
||||
async def _ask(
|
||||
self,
|
||||
@@ -297,9 +316,7 @@ class BaseMemory:
|
||||
timeout=self.timeout,
|
||||
)
|
||||
|
||||
# An HTML body on a JSON endpoint means the path was not served and the
|
||||
# admin SPA answered. Saying so beats a parse error, which reads as a
|
||||
# broken server rather than a wrong collection.
|
||||
# Base answers a path it does not route with its admin SPA, at 200.
|
||||
if answer.headers.get("Content-Type", "").startswith("text/html"):
|
||||
raise RuntimeError(
|
||||
f"base answered with HTML for {url}"
|
||||
@@ -324,13 +341,9 @@ def _filter_for(scope: str, scope_id: str, key: Optional[str]) -> str:
|
||||
|
||||
def _quote(value: str) -> str:
|
||||
"""
|
||||
A single-quoted literal, escaped.
|
||||
|
||||
Base's tokenizer treats a backslash as the escape rune, so a quote preceded
|
||||
by one does not close the literal. That makes the order 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. A scope id is caller data.
|
||||
A single-quoted literal. Base's tokenizer reads a backslash as an escape, so
|
||||
backslashes double before quotes are escaped: the other order lets a value
|
||||
ending in one escape the closing quote.
|
||||
"""
|
||||
return "'" + value.replace("\\", "\\\\").replace("'", "\\'") + "'"
|
||||
|
||||
@@ -348,11 +361,7 @@ def _cosine(a: Sequence[float], b: Sequence[float]) -> float:
|
||||
def _matches(
|
||||
metadata: Optional[Dict[str, Any]], filters: Optional[Dict[str, Any]]
|
||||
) -> bool:
|
||||
"""
|
||||
Whether a record's metadata satisfies every filter. A filter naming a key
|
||||
the record lacks excludes it: the caller asked for records where that key
|
||||
holds a value, and a record without it is not one.
|
||||
"""
|
||||
"""Whether metadata satisfies every filter; a missing key fails its filter."""
|
||||
if not filters:
|
||||
return True
|
||||
metadata = metadata or {}
|
||||
|
||||
@@ -94,10 +94,6 @@ class HanzoAgentsClient:
|
||||
async_config: Optional[AsyncConfig] = None,
|
||||
):
|
||||
self.base_url = base_url
|
||||
# /v1/, with no /api/ segment: that is the shape this estate serves and
|
||||
# the shape every test in this suite registers. The extra segment was
|
||||
# never asserted anywhere, and none of these calls had run since the
|
||||
# suite stopped collecting.
|
||||
self.api_base = f"{base_url}/v1"
|
||||
self.api_key = api_key
|
||||
|
||||
|
||||
@@ -2,19 +2,8 @@
|
||||
|
||||
Provides wallet management, transaction handling, and MPC custody.
|
||||
|
||||
``web3_agent`` and ``web3_network`` are NOT exported here. They are written
|
||||
against an agent model this package does not have: they import
|
||||
``hanzo_agents.agent.InferenceResult``, ``hanzo_agents.execution_context.State``,
|
||||
``hanzo_agents.router.Router``/``RouterFn`` and ``hanzo_agents.network``, and
|
||||
none of those exist. An agent here serves reasoners and skills over HTTP; those
|
||||
two describe a router stepping a network of agents over shared state, which is
|
||||
a different design and not one this SDK can be made to satisfy by renaming
|
||||
imports.
|
||||
|
||||
Importing either from this package's ``__init__`` made every module in it
|
||||
unimportable, including the four that are sound — and took the whole test
|
||||
suite's collection down with them. Whether this SDK grows a network model, or
|
||||
those two modules go, is a decision to make rather than a rename to guess at.
|
||||
``web3_agent`` and ``web3_network`` are not exported: they import modules this
|
||||
package does not have (``hanzo_agents.router``, ``hanzo_agents.network``).
|
||||
"""
|
||||
|
||||
from .wallet import (
|
||||
|
||||
@@ -9,6 +9,7 @@ import asyncio
|
||||
import json
|
||||
import sys
|
||||
from functools import wraps
|
||||
from urllib.parse import quote
|
||||
from typing import Any, Callable, Dict, List, Optional, Sequence, Union
|
||||
from .client import HanzoAgentsClient
|
||||
from .execution_context import ExecutionContext
|
||||
@@ -170,7 +171,7 @@ class MemoryClient:
|
||||
|
||||
response = await self._async_request(
|
||||
"POST",
|
||||
f"{self.hanzo_agents_client.api_base}/memory/vector/set",
|
||||
f"{self.hanzo_agents_client.api_base}/memory/vector",
|
||||
json=payload,
|
||||
headers=headers,
|
||||
timeout=15.0,
|
||||
@@ -281,13 +282,10 @@ class MemoryClient:
|
||||
Delete a stored vector embedding.
|
||||
"""
|
||||
headers = self._build_headers(scope, scope_id)
|
||||
payload: Dict[str, Any] = {"key": key}
|
||||
if scope:
|
||||
payload["scope"] = scope
|
||||
response = await self._async_request(
|
||||
"POST",
|
||||
f"{self.hanzo_agents_client.api_base}/memory/vector/delete",
|
||||
json=payload,
|
||||
"DELETE",
|
||||
f"{self.hanzo_agents_client.api_base}/memory/vector/{quote(key, safe='')}",
|
||||
params={"scope": scope} if scope else None,
|
||||
headers=headers,
|
||||
timeout=10.0,
|
||||
)
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "setuptools.build_meta"
|
||||
|
||||
[project]
|
||||
name = "hanzo_agents"
|
||||
version = "0.1.41-rc.2"
|
||||
version = "0.2.10"
|
||||
description = "Python SDK for the Hanzo Agents control plane"
|
||||
readme = "README.md"
|
||||
license = {text = "Apache-2.0"}
|
||||
@@ -41,9 +41,9 @@ dependencies = [
|
||||
keywords = ["hanzo_agents", "sdk", "agents"]
|
||||
|
||||
[project.urls]
|
||||
Homepage = "https://github.com/hanzoai/agents"
|
||||
Documentation = "https://github.com/hanzoai/agents/tree/main/docs"
|
||||
Issues = "https://github.com/hanzoai/agents/issues"
|
||||
Homepage = "https://github.com/hanzoai/agent"
|
||||
Documentation = "https://github.com/hanzoai/agent/tree/main/docs"
|
||||
Issues = "https://github.com/hanzoai/agent/issues"
|
||||
|
||||
[tool.setuptools.packages.find]
|
||||
where = ["."]
|
||||
@@ -51,15 +51,10 @@ include = ["hanzo_agents*"]
|
||||
exclude = ["tests*", "examples*"]
|
||||
|
||||
[project.optional-dependencies]
|
||||
# The web3 extension imports eth_account at module scope, so importing
|
||||
# hanzo_agents.extensions.web3 from a clean install failed until this existed —
|
||||
# and its tests took the whole suite's collection down with them.
|
||||
web3 = [
|
||||
"eth-account>=0.11,<1",
|
||||
]
|
||||
dev = [
|
||||
# Named once, here, so the dev environment can run every test this project
|
||||
# ships rather than most of them.
|
||||
"hanzo-agents[web3]",
|
||||
"pytest>=7.4,<9",
|
||||
"pytest-asyncio>=0.21,<0.24",
|
||||
|
||||
@@ -43,6 +43,7 @@ class FakeBase:
|
||||
def __init__(self, collection="agent_memory"):
|
||||
self.collection = collection
|
||||
self.records = {}
|
||||
self.before_create = None
|
||||
self._next = 0
|
||||
|
||||
def install(self):
|
||||
@@ -66,10 +67,18 @@ class FakeBase:
|
||||
if request.method == "GET":
|
||||
return (200, headers, json.dumps(self._list(request.url)))
|
||||
if request.method == "POST":
|
||||
self._next += 1
|
||||
record = dict(body or {}, id=f"r{self._next}")
|
||||
self.records[record["id"]] = record
|
||||
return (201, headers, json.dumps(record))
|
||||
# A create under the unique index over scope, scope_id and mkey.
|
||||
if self.before_create:
|
||||
hook, self.before_create = self.before_create, None
|
||||
hook()
|
||||
body = body or {}
|
||||
if any(
|
||||
all(record.get(f) == body.get(f) for f in ("scope", "scope_id", "mkey"))
|
||||
for record in self.records.values()
|
||||
):
|
||||
refusal = {"message": "Failed to create record.", "data": {"mkey": {"code": "validation_not_unique"}}}
|
||||
return (400, headers, json.dumps(refusal))
|
||||
return (201, headers, json.dumps(self.insert(body)))
|
||||
if request.method == "PATCH":
|
||||
record = self.records.get(record_id, {})
|
||||
# A merge, which is what PATCH means: an absent field keeps its
|
||||
@@ -79,6 +88,12 @@ class FakeBase:
|
||||
self.records.pop(record_id, None)
|
||||
return (204, headers, "")
|
||||
|
||||
def insert(self, body):
|
||||
self._next += 1
|
||||
record = dict(body, id=f"r{self._next}")
|
||||
self.records[record["id"]] = record
|
||||
return record
|
||||
|
||||
def _list(self, url):
|
||||
query = parse_qs(urlparse(url).query)
|
||||
want = {
|
||||
@@ -292,3 +307,45 @@ async def test_an_agent_reads_and_writes_through_the_store_it_was_given(fake):
|
||||
await memory.set("tone", "plain")
|
||||
assert await memory.get("tone") == "plain"
|
||||
assert any(record.get("mkey") == "tone" for record in fake.records.values())
|
||||
|
||||
|
||||
def execution(workflow_id=None, session_id=None, actor_id=None):
|
||||
return SimpleNamespace(
|
||||
run_id=workflow_id, workflow_id=workflow_id, session_id=session_id, actor_id=actor_id
|
||||
)
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_takes_scope_ids_from_the_execution_it_is_bound_to(memory):
|
||||
one, two = memory.bind(execution("w1")), memory.bind(execution("w2"))
|
||||
await one.set("step", "one")
|
||||
await two.set("step", "two")
|
||||
|
||||
assert await one.get("step") == "one"
|
||||
assert await two.get("step") == "two"
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_files_global_under_the_id_every_sdk_uses(memory, fake):
|
||||
await memory.set("shared", True, scope="global")
|
||||
assert [record["scope_id"] for record in fake.records.values()] == ["global"]
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_a_read_without_a_scope_falls_back_through_the_hierarchy(memory):
|
||||
bound = memory.bind(execution("w1", session_id="s1", actor_id="a1"))
|
||||
await bound.set("tone", "plain", scope="session")
|
||||
|
||||
assert await bound.get("tone") == "plain"
|
||||
assert await bound.exists("tone")
|
||||
|
||||
|
||||
@pytest.mark.asyncio
|
||||
async def test_keeps_the_last_write_when_two_writers_create_one_key(memory, fake):
|
||||
fake.before_create = lambda: fake.insert(
|
||||
{"scope": "session", "scope_id": "s1", "mkey": "k", "value": json.dumps("theirs")}
|
||||
)
|
||||
await memory.set("k", "ours", scope="session", scope_id="s1")
|
||||
|
||||
assert await memory.get("k", scope="session", scope_id="s1") == "ours"
|
||||
assert len(fake.records) == 1
|
||||
|
||||
@@ -38,7 +38,7 @@ def mute_debug_logs(monkeypatch):
|
||||
@pytest.fixture
|
||||
def memory_client(dummy_headers):
|
||||
context = SimpleNamespace(to_headers=lambda: dict(dummy_headers))
|
||||
hanzo_agents_client = SimpleNamespace(api_base="http://hanzo_agents.local/api/v1")
|
||||
hanzo_agents_client = SimpleNamespace(api_base="http://hanzo_agents.local/v1")
|
||||
return MemoryClient(hanzo_agents_client, context)
|
||||
|
||||
|
||||
@@ -164,7 +164,7 @@ async def test_set_vector_calls_vector_endpoint(memory_client, monkeypatch):
|
||||
|
||||
await memory_client.set_vector("chunk_1", [0.1, 0.2], metadata={"source": "doc"})
|
||||
|
||||
assert captured["url"].endswith("/memory/vector/set")
|
||||
assert captured["url"].endswith("/memory/vector")
|
||||
assert captured["json"]["key"] == "chunk_1" # type: ignore[index]
|
||||
assert captured["json"]["metadata"] == {"source": "doc"} # type: ignore[index]
|
||||
|
||||
@@ -178,17 +178,19 @@ async def test_delete_vector_calls_vector_endpoint(memory_client, monkeypatch):
|
||||
def raise_for_status(self):
|
||||
return None
|
||||
|
||||
async def fake_request(method, url, json=None, headers=None, timeout=None): # type: ignore[override]
|
||||
async def fake_request(method, url, params=None, headers=None, timeout=None): # type: ignore[override]
|
||||
captured["method"] = method
|
||||
captured["url"] = url
|
||||
captured["json"] = json
|
||||
captured["params"] = params
|
||||
return DummyResponse()
|
||||
|
||||
monkeypatch.setattr(memory_client, "_async_request", fake_request)
|
||||
|
||||
await memory_client.delete_vector("chunk_1")
|
||||
await memory_client.delete_vector("chunk_1", scope="session")
|
||||
|
||||
assert captured["url"].endswith("/memory/vector/delete")
|
||||
assert captured["json"]["key"] == "chunk_1" # type: ignore[index]
|
||||
assert captured["method"] == "DELETE"
|
||||
assert captured["url"].endswith("/memory/vector/chunk_1")
|
||||
assert captured["params"] == {"scope": "session"}
|
||||
|
||||
|
||||
@pytest.mark.unit
|
||||
@@ -395,7 +397,7 @@ async def test_set_uses_async_request_when_available(dummy_headers):
|
||||
|
||||
context = SimpleNamespace(to_headers=lambda: dict(dummy_headers))
|
||||
hanzo_agents_client = SimpleNamespace(
|
||||
api_base="http://hanzo_agents.local/api/v1",
|
||||
api_base="http://hanzo_agents.local/v1",
|
||||
_async_request=fake_async_request,
|
||||
)
|
||||
client = MemoryClient(hanzo_agents_client, context)
|
||||
|
||||
@@ -95,7 +95,7 @@ async def test_memory_round_trip(monkeypatch, dummy_headers):
|
||||
monkeypatch.setattr("hanzo_agents.logger.log_debug", lambda *args, **kwargs: None)
|
||||
|
||||
context = SimpleNamespace(to_headers=lambda: dict(dummy_headers))
|
||||
hanzo_agents_client = SimpleNamespace(api_base="http://hanzo_agents.local/api/v1")
|
||||
hanzo_agents_client = SimpleNamespace(api_base="http://hanzo_agents.local/v1")
|
||||
memory_client = MemoryClient(hanzo_agents_client, context)
|
||||
interface = MemoryInterface(memory_client, SimpleNamespace()) # type: ignore[arg-type]
|
||||
|
||||
@@ -124,7 +124,7 @@ async def test_memory_client_uses_hanzo_agents_async_request(dummy_headers):
|
||||
calls: list[tuple[str, str, dict]] = []
|
||||
|
||||
class DummyHanzoAgentsClient:
|
||||
api_base = "http://hanzo_agents.local/api/v1"
|
||||
api_base = "http://hanzo_agents.local/v1"
|
||||
|
||||
async def _async_request(self, method, url, **kwargs):
|
||||
calls.append((method, url, kwargs))
|
||||
|
||||
Generated
+3
-13
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "@hanzo/agents",
|
||||
"version": "0.1.41-rc.2",
|
||||
"version": "0.2.10",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "@hanzo/agents",
|
||||
"version": "0.1.41-rc.2",
|
||||
"version": "0.2.10",
|
||||
"license": "Apache-2.0",
|
||||
"dependencies": {
|
||||
"@ai-sdk/anthropic": "^4.0.45",
|
||||
@@ -22,8 +22,7 @@
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"ws": "^8.21.3",
|
||||
"zod": "^4.5.2",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
"zod": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
@@ -4967,15 +4966,6 @@
|
||||
"funding": {
|
||||
"url": "https://github.com/sponsors/colinhacks"
|
||||
}
|
||||
},
|
||||
"node_modules/zod-to-json-schema": {
|
||||
"version": "3.25.2",
|
||||
"resolved": "https://registry.npmjs.org/zod-to-json-schema/-/zod-to-json-schema-3.25.2.tgz",
|
||||
"integrity": "sha512-O/PgfnpT1xKSDeQYSCfRI5Gy3hPf91mKVDuYLUHZJMiDFptvP41MSnWofm8dnCm0256ZNfZIM7DSzuSMAFnjHA==",
|
||||
"license": "ISC",
|
||||
"peerDependencies": {
|
||||
"zod": "^3.25.28 || ^4"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+12
-10
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "@hanzo/agents",
|
||||
"version": "0.1.41-rc.2",
|
||||
"version": "0.2.10",
|
||||
"description": "Hanzo Agents TypeScript SDK",
|
||||
"type": "module",
|
||||
"main": "dist/index.js",
|
||||
@@ -11,9 +11,8 @@
|
||||
"scripts": {
|
||||
"build": "tsup",
|
||||
"dev": "tsup --watch",
|
||||
"lint": "tsc --noEmit",
|
||||
"test": "vitest run",
|
||||
"prepare": "npm run build"
|
||||
"type-check": "tsc --noEmit",
|
||||
"test": "vitest run"
|
||||
},
|
||||
"exports": {
|
||||
".": {
|
||||
@@ -38,8 +37,7 @@
|
||||
"dotenv": "^17.4.2",
|
||||
"express": "^5.2.1",
|
||||
"ws": "^8.21.3",
|
||||
"zod": "^4.5.2",
|
||||
"zod-to-json-schema": "^3.25.2"
|
||||
"zod": "^4.5.2"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/express": "^5.0.6",
|
||||
@@ -53,12 +51,12 @@
|
||||
},
|
||||
"repository": {
|
||||
"type": "git",
|
||||
"url": "https://github.com/hanzoai/agents.git",
|
||||
"url": "git+https://github.com/hanzoai/agent.git",
|
||||
"directory": "sdk/typescript"
|
||||
},
|
||||
"homepage": "https://github.com/hanzoai/agents/tree/main/sdk/typescript",
|
||||
"homepage": "https://github.com/hanzoai/agent/tree/main/sdk/typescript",
|
||||
"bugs": {
|
||||
"url": "https://github.com/hanzoai/agents/issues"
|
||||
"url": "https://github.com/hanzoai/agent/issues"
|
||||
},
|
||||
"license": "Apache-2.0",
|
||||
"keywords": [
|
||||
@@ -68,5 +66,9 @@
|
||||
"multi-agent",
|
||||
"orchestration"
|
||||
],
|
||||
"author": "Hanzo Agents Team"
|
||||
"author": "Hanzo Agents Team",
|
||||
"publishConfig": {
|
||||
"access": "public",
|
||||
"registry": "https://registry.npmjs.org/"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -229,8 +229,6 @@ export class Agent {
|
||||
if (!execMetadata) return;
|
||||
|
||||
const baseUrl = (this.config.controlPlaneUrl ?? 'http://localhost:8080').replace(/\/$/, '');
|
||||
// The UI reads notes from a /ui subtree of the version prefix, the same
|
||||
// address the Python SDK sends them to.
|
||||
const uiApiUrl = `${baseUrl}/v1/ui`;
|
||||
|
||||
this.controlPlaneClient.sendNote(message, tags, this.config.nodeId, execMetadata, uiApiUrl, this.config.devMode);
|
||||
@@ -412,12 +410,8 @@ export class Agent {
|
||||
res.json(this.skills.all().map((s) => s.name));
|
||||
});
|
||||
|
||||
// One address per operation, the one the Python SDK also serves. There
|
||||
// used to be a second spelling of each under an /api/v1/ prefix, reading
|
||||
// the name out of a bare `*`. Express 5 refuses that pattern outright, so
|
||||
// the agent's own server could not start — and `/api/` is not a shape this
|
||||
// estate serves. A name may carry dots (`node-id.double`), which a single
|
||||
// segment parameter matches.
|
||||
// The addresses the Python SDK serves. A name may carry dots
|
||||
// (`node-id.double`), which a segment parameter matches.
|
||||
this.app.post('/reasoners/:name', (req, res) => this.executeReasoner(req, res, req.params.name));
|
||||
this.app.post('/skills/:name', (req, res) => this.executeSkill(req, res, req.params.name));
|
||||
|
||||
|
||||
@@ -77,16 +77,7 @@ export interface AIEmbeddingOptions {
|
||||
const HANZO_BASE_URL = 'https://api.hanzo.ai/v1';
|
||||
const HANZO_MODEL = 'zen3-vl';
|
||||
|
||||
/**
|
||||
* The address for a provider that speaks the OpenAI dialect, where the caller
|
||||
* gave none.
|
||||
*
|
||||
* One function rather than a copy in each switch: the model path and the
|
||||
* embedding path both route these four through the same client, and two copies
|
||||
* of the mapping is how they come to disagree about where `hanzo` lives.
|
||||
* `undefined` leaves the underlying client's own default, which is correct for
|
||||
* openai.
|
||||
*/
|
||||
/** The address for an OpenAI-dialect provider where the caller gave none. */
|
||||
function defaultBaseUrl(provider: string): string | undefined {
|
||||
switch (provider) {
|
||||
case 'hanzo':
|
||||
@@ -124,10 +115,8 @@ export class AIClient {
|
||||
|
||||
if (options.schema) {
|
||||
const schema = options.schema;
|
||||
// `mode` is still accepted on this SDK's own options, and is no longer
|
||||
// forwarded: the ai package removed it from generateObject, which now
|
||||
// chooses between structured output and tool calling itself. Passing it
|
||||
// was a type error, so nothing downstream ever saw it.
|
||||
// `mode` is not forwarded: generateObject chooses structured output or tool
|
||||
// calling itself. The cast avoids TS2589 on generic caller types.
|
||||
const call = async () =>
|
||||
generateObject({
|
||||
model: model,
|
||||
@@ -138,7 +127,7 @@ export class AIClient {
|
||||
maxOutputTokens: options.maxTokens ?? this.config.maxTokens,
|
||||
schema,
|
||||
experimental_repairText: async ({ text }) => repairJsonText(text)
|
||||
});
|
||||
} as Parameters<typeof generateObject>[0]);
|
||||
|
||||
const response = await this.withRateLimitRetry(call);
|
||||
return response.object as T;
|
||||
@@ -193,9 +182,7 @@ export class AIClient {
|
||||
}
|
||||
|
||||
private buildModel(options: AIRequestOptions) {
|
||||
// Ours where nobody chose. An explicit provider still selects that
|
||||
// provider — only the unspecified case moved, and it used to send this
|
||||
// SDK's callers to another company by default.
|
||||
// Hanzo unless the caller names a provider.
|
||||
const provider = options.provider ?? this.config.provider ?? 'hanzo';
|
||||
const modelName = options.model ?? this.config.model ?? HANZO_MODEL;
|
||||
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios, { AxiosInstance } from 'axios';
|
||||
import type { MemoryScope } from '../types/agent.js';
|
||||
import { httpAgent, httpsAgent } from '../utils/httpAgents.js';
|
||||
import type { MemoryBackend } from './MemoryBackend.js';
|
||||
import { resolveScope, type MemoryBackend } from './MemoryBackend.js';
|
||||
import type {
|
||||
MemoryRequestOptions,
|
||||
VectorSearchOptions,
|
||||
@@ -9,23 +9,9 @@ import type {
|
||||
} from './MemoryClient.js';
|
||||
|
||||
/**
|
||||
* Agent memory kept in Hanzo Base.
|
||||
*
|
||||
* Base is the single-binary Go backend, so an agent's state sits in the same
|
||||
* process tree as everything it runs beside: no second database, no separate
|
||||
* migration, and the records are visible in Base's own admin view while the
|
||||
* agent is running.
|
||||
*
|
||||
* One record per scope and key, in one collection. A collection per scope
|
||||
* would make the number of collections a function of how many workflows have
|
||||
* ever run.
|
||||
*
|
||||
* The collection must exist, carrying the fields this writes. Import
|
||||
* `agent_memory.collection.json` from the Go SDK once — the schema is the same
|
||||
* one, because the two SDKs read each other's records. It is not created on
|
||||
* first write: a client that builds its own schema can build the wrong one
|
||||
* from a typo and then read nothing from the collection everyone else is
|
||||
* looking at.
|
||||
* Agent memory kept in Hanzo Base, one record per scope and key in one
|
||||
* collection. Import `agent_memory.collection.json` from the Go SDK once; the
|
||||
* Go, TypeScript and Python stores share it.
|
||||
*/
|
||||
export class BaseMemory implements MemoryBackend {
|
||||
private readonly http: AxiosInstance;
|
||||
@@ -38,9 +24,7 @@ export class BaseMemory implements MemoryBackend {
|
||||
httpAgent,
|
||||
httpsAgent,
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
// Base's router answers a path it does not serve with the admin SPA, at
|
||||
// 200. Taking every status lets `read` say that, rather than throwing an
|
||||
// error that reads like the server is broken.
|
||||
// Every status is taken, so `ask` can name an unrouted path's HTML answer.
|
||||
validateStatus: () => true
|
||||
});
|
||||
this.collection = collection;
|
||||
@@ -67,18 +51,14 @@ export class BaseMemory implements MemoryBackend {
|
||||
await this.ask('delete', `${this.records}/${encodeURIComponent(record.id)}`);
|
||||
}
|
||||
|
||||
/**
|
||||
* Every key in a scope, following Base's pages to the end. A caller asking
|
||||
* for all the keys and receiving the first page would read that as the whole
|
||||
* answer.
|
||||
*/
|
||||
/** Every key in a scope, across all of Base's pages. */
|
||||
async listKeys(scope: MemoryScope, options: MemoryRequestOptions = {}) {
|
||||
const keys: string[] = [];
|
||||
for (let page = 1; ; page++) {
|
||||
const answer = await this.ask('get', this.records, {
|
||||
perPage: 200,
|
||||
page,
|
||||
filter: filterFor(scope, scopeId(options))
|
||||
filter: filterFor(scope, resolveScope({ ...options, scope }).scopeId)
|
||||
});
|
||||
const items = answer.items ?? [];
|
||||
keys.push(...items.map((item: any) => item.mkey).filter(Boolean));
|
||||
@@ -94,10 +74,7 @@ export class BaseMemory implements MemoryBackend {
|
||||
await this.write(options, key, { embedding, metadata: metadata ?? null });
|
||||
}
|
||||
|
||||
/**
|
||||
* The embedding, not the record: a value may be stored at the same key and
|
||||
* is not the caller's to lose here.
|
||||
*/
|
||||
/** Clears the embedding and keeps any value stored at the key. */
|
||||
async deleteVector(key: string, options: MemoryRequestOptions = {}) {
|
||||
const record = await this.find(options, key);
|
||||
if (!record) return;
|
||||
@@ -108,13 +85,8 @@ export class BaseMemory implements MemoryBackend {
|
||||
}
|
||||
|
||||
/**
|
||||
* Ranks the scope's vectors by cosine similarity.
|
||||
*
|
||||
* Scored here rather than in the database, and that is a limit worth
|
||||
* stating: it reads the scope's vectors and ranks them in the client, so
|
||||
* cost grows with the size of the scope. Right for an agent's own working
|
||||
* set, wrong for a corpus. Base can score server-side through a hook, and a
|
||||
* scope large enough to need that should use one.
|
||||
* Ranks the scope's vectors by cosine similarity, in the client: cost grows
|
||||
* with the scope, which suits an agent's working set, not a corpus.
|
||||
*/
|
||||
async searchVector(
|
||||
queryEmbedding: number[],
|
||||
@@ -122,8 +94,7 @@ export class BaseMemory implements MemoryBackend {
|
||||
): Promise<VectorSearchResult[]> {
|
||||
if (!queryEmbedding?.length) throw new Error('search needs a query vector');
|
||||
|
||||
const scope = options.scope ?? 'workflow';
|
||||
const id = scopeId(options);
|
||||
const { scope, scopeId: id } = resolveScope(options);
|
||||
const found: VectorSearchResult[] = [];
|
||||
|
||||
for (let page = 1; ; page++) {
|
||||
@@ -136,9 +107,7 @@ export class BaseMemory implements MemoryBackend {
|
||||
if (!items.length) break;
|
||||
|
||||
for (const item of items) {
|
||||
// A vector of a different width came from a different embedding model.
|
||||
// Cosine across two of those returns a number that means nothing, so
|
||||
// it is skipped rather than scored.
|
||||
// A different width is a different embedding model; skip it.
|
||||
if (item.embedding?.length !== queryEmbedding.length) continue;
|
||||
if (!matches(item.metadata, options.filters)) continue;
|
||||
found.push({
|
||||
@@ -160,7 +129,7 @@ export class BaseMemory implements MemoryBackend {
|
||||
private async find(options: MemoryRequestOptions, key: string) {
|
||||
const answer = await this.ask('get', this.records, {
|
||||
perPage: 1,
|
||||
filter: filterFor(options.scope ?? 'workflow', scopeId(options), key)
|
||||
filter: filterFor(...where(options), key)
|
||||
});
|
||||
return answer.items?.[0];
|
||||
}
|
||||
@@ -172,20 +141,23 @@ export class BaseMemory implements MemoryBackend {
|
||||
* it, and the two share a record.
|
||||
*/
|
||||
private async write(options: MemoryRequestOptions, key: string, fields: Record<string, any>) {
|
||||
const scope = options.scope ?? 'workflow';
|
||||
const id = scopeId(options);
|
||||
const { scope, scopeId: id } = resolveScope(options);
|
||||
const existing = await this.find(options, key);
|
||||
|
||||
if (existing) {
|
||||
await this.ask('patch', `${this.records}/${encodeURIComponent(existing.id)}`, undefined, fields);
|
||||
return;
|
||||
}
|
||||
await this.ask('post', this.records, undefined, {
|
||||
scope,
|
||||
scope_id: id,
|
||||
mkey: key,
|
||||
...fields
|
||||
});
|
||||
try {
|
||||
await this.ask('post', this.records, undefined, { scope, scope_id: id, mkey: key, ...fields });
|
||||
} catch (err) {
|
||||
// Another writer created the key between the read and this create, and
|
||||
// the unique index refused a second record. A set means the last write
|
||||
// wins, so update the record that won.
|
||||
const winner = await this.find(options, key);
|
||||
if (!winner) throw err;
|
||||
await this.ask('patch', `${this.records}/${encodeURIComponent(winner.id)}`, undefined, fields);
|
||||
}
|
||||
}
|
||||
|
||||
private async ask(
|
||||
@@ -196,9 +168,7 @@ export class BaseMemory implements MemoryBackend {
|
||||
) {
|
||||
const answer = await this.http.request({ method, url: path, params, data: body });
|
||||
|
||||
// An HTML body on a JSON endpoint means the path was not served and the
|
||||
// SPA catch-all answered. Saying so beats a parse error, which reads as a
|
||||
// broken server rather than a wrong collection.
|
||||
// Base answers a path it does not route with its admin SPA, at 200.
|
||||
if (String(answer.headers['content-type'] ?? '').startsWith('text/html')) {
|
||||
throw new Error(`base answered with HTML for ${path} — that path is not served by this Base`);
|
||||
}
|
||||
@@ -209,9 +179,10 @@ export class BaseMemory implements MemoryBackend {
|
||||
}
|
||||
}
|
||||
|
||||
/** A scope id, defaulting to the empty string so a filter clause is well formed. */
|
||||
function scopeId(options: MemoryRequestOptions) {
|
||||
return options.scopeId ?? '';
|
||||
/** The scope and id a request addresses, in the order a filter names them. */
|
||||
function where(options: MemoryRequestOptions): [MemoryScope, string] {
|
||||
const { scope, scopeId } = resolveScope(options);
|
||||
return [scope, scopeId];
|
||||
}
|
||||
|
||||
/** A clause in Base's filter grammar, with every value quoted. */
|
||||
@@ -222,13 +193,9 @@ function filterFor(scope: MemoryScope, id: string, key?: string) {
|
||||
}
|
||||
|
||||
/**
|
||||
* A single-quoted literal, escaped.
|
||||
*
|
||||
* Base's tokenizer treats a backslash as the escape rune, so a quote preceded
|
||||
* by one does not close the literal. That makes the order 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. A scope id is caller data.
|
||||
* A single-quoted literal. Base's tokenizer reads a backslash as an escape, so
|
||||
* backslashes double before quotes are escaped: the other order lets a value
|
||||
* ending in one escape the closing quote.
|
||||
*/
|
||||
function quote(value: string) {
|
||||
return `'${value.replace(/\\/g, '\\\\').replace(/'/g, "\\'")}'`;
|
||||
@@ -248,11 +215,7 @@ function cosine(a: number[], b: number[]) {
|
||||
return dot / (Math.sqrt(na) * Math.sqrt(nb));
|
||||
}
|
||||
|
||||
/**
|
||||
* Whether a record's metadata satisfies every filter. A filter naming a key
|
||||
* the record lacks excludes it: the caller asked for records where that key
|
||||
* holds a value, and a record without it is not one.
|
||||
*/
|
||||
/** Whether metadata satisfies every filter; a missing key fails its filter. */
|
||||
function matches(metadata: Record<string, any> | null | undefined, filters?: Record<string, any>) {
|
||||
if (!filters) return true;
|
||||
for (const [key, want] of Object.entries(filters)) {
|
||||
|
||||
@@ -6,17 +6,29 @@ import type {
|
||||
} from './MemoryClient.js';
|
||||
|
||||
/**
|
||||
* Where an agent's memory is kept.
|
||||
*
|
||||
* `MemoryInterface` is what a handler talks to; this is what holds the bytes.
|
||||
* It named one class before, so a second store could not be used without
|
||||
* editing the facade — and a class with private fields is not structurally
|
||||
* assignable from another, so having the same methods was not enough.
|
||||
*
|
||||
* The same name as the Go SDK's `MemoryBackend`, for the same concept. One
|
||||
* name per idea across the SDKs is what makes them one SDK in several
|
||||
* languages rather than several SDKs.
|
||||
* Where an agent's memory is kept. `MemoryInterface` is what a handler talks
|
||||
* to; this holds the bytes. The Go SDK names the same contract `MemoryBackend`.
|
||||
*/
|
||||
/**
|
||||
* The scope a request addresses and the id within it, resolved the way the
|
||||
* control plane's memory handler resolves them. An explicit scope takes its id
|
||||
* from the caller or from the execution the request carries; no scope means
|
||||
* the narrowest one that execution names, and global otherwise. Global's id is
|
||||
* "global" in every SDK, so their records meet.
|
||||
*/
|
||||
export function resolveScope(options: MemoryRequestOptions = {}): { scope: MemoryScope; scopeId: string } {
|
||||
const { scope, scopeId, metadata } = options;
|
||||
const ids: Record<MemoryScope, string | undefined> = {
|
||||
workflow: metadata?.workflowId ?? metadata?.runId,
|
||||
session: metadata?.sessionId,
|
||||
actor: metadata?.actorId,
|
||||
global: 'global'
|
||||
};
|
||||
if (scope) return { scope, scopeId: scopeId ?? ids[scope] ?? '' };
|
||||
const named = (['workflow', 'session', 'actor'] as const).find((s) => ids[s]);
|
||||
return named ? { scope: named, scopeId: ids[named]! } : { scope: 'global', scopeId: 'global' };
|
||||
}
|
||||
|
||||
export interface MemoryBackend {
|
||||
set(key: string, data: any, options?: MemoryRequestOptions): Promise<void>;
|
||||
get<T = any>(key: string, options?: MemoryRequestOptions): Promise<T | undefined>;
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import axios, { AxiosInstance, isAxiosError } from 'axios';
|
||||
import type { MemoryScope } from '../types/agent.js';
|
||||
import { httpAgent, httpsAgent } from '../utils/httpAgents.js';
|
||||
import type { MemoryBackend } from './MemoryBackend.js';
|
||||
import { resolveScope, type MemoryBackend } from './MemoryBackend.js';
|
||||
|
||||
export interface MemoryRequestMetadata {
|
||||
workflowId?: string;
|
||||
@@ -106,16 +106,14 @@ export class MemoryClient implements MemoryBackend {
|
||||
if (metadata !== undefined) payload.metadata = metadata;
|
||||
if (options.scope) payload.scope = options.scope;
|
||||
|
||||
await this.http.post('/v1/memory/vector/set', payload, {
|
||||
await this.http.post('/v1/memory/vector', payload, {
|
||||
headers: this.buildHeaders(options)
|
||||
});
|
||||
}
|
||||
|
||||
async deleteVector(key: string, options: MemoryRequestOptions = {}) {
|
||||
const payload: any = { key };
|
||||
if (options.scope) payload.scope = options.scope;
|
||||
|
||||
await this.http.post('/v1/memory/vector/delete', payload, {
|
||||
await this.http.delete(`/v1/memory/vector/${encodeURIComponent(key)}`, {
|
||||
params: options.scope ? { scope: options.scope } : undefined,
|
||||
headers: this.buildHeaders(options)
|
||||
});
|
||||
}
|
||||
@@ -151,7 +149,7 @@ export class MemoryClient implements MemoryBackend {
|
||||
if (metadata?.agentNodeId) headers['X-Agent-Node-ID'] = metadata.agentNodeId;
|
||||
|
||||
const headerName = this.scopeToHeader(scope);
|
||||
const resolvedScopeId = this.resolveScopeId(scope, scopeId, metadata);
|
||||
const resolvedScopeId = scope ? resolveScope({ scope, scopeId, metadata }).scopeId : undefined;
|
||||
if (headerName && resolvedScopeId) {
|
||||
headers[headerName] = resolvedScopeId;
|
||||
}
|
||||
@@ -172,22 +170,6 @@ export class MemoryClient implements MemoryBackend {
|
||||
}
|
||||
}
|
||||
|
||||
private resolveScopeId(scope?: MemoryScope, scopeId?: string, metadata?: MemoryRequestMetadata) {
|
||||
if (scopeId) return scopeId;
|
||||
switch (scope) {
|
||||
case 'workflow':
|
||||
return metadata?.workflowId ?? metadata?.runId;
|
||||
case 'session':
|
||||
return metadata?.sessionId;
|
||||
case 'actor':
|
||||
return metadata?.actorId;
|
||||
case 'global':
|
||||
return 'global';
|
||||
default:
|
||||
return undefined;
|
||||
}
|
||||
}
|
||||
|
||||
private sanitizeHeaders(headers: Record<string, any>): Record<string, string> {
|
||||
const sanitized: Record<string, string> = {};
|
||||
Object.entries(headers).forEach(([key, value]) => {
|
||||
|
||||
@@ -32,14 +32,7 @@ export interface AgentConfig {
|
||||
}
|
||||
|
||||
export interface AIConfig {
|
||||
/**
|
||||
* Which stack answers, defaulting to ours.
|
||||
*
|
||||
* `hanzo` speaks the OpenAI dialect against api.hanzo.ai, so it needs no
|
||||
* separate client — only an address. It was absent from this union, which
|
||||
* meant the type said Hanzo was not a provider of its own SDK, and the
|
||||
* default below was another company.
|
||||
*/
|
||||
/** Which stack answers; Hanzo unless set. */
|
||||
provider?:
|
||||
| 'hanzo'
|
||||
| 'openai'
|
||||
|
||||
@@ -25,13 +25,7 @@ export function toJsonSchema(schema: unknown): Record<string, unknown> {
|
||||
}
|
||||
|
||||
if (isZodSchema(schema)) {
|
||||
// zod's own converter, which v4 ships. zod-to-json-schema reads v3's
|
||||
// internals and cannot type a v4 schema at all — this package declares
|
||||
// zod ^4, so the library it was calling was one major version behind the
|
||||
// schemas handed to it.
|
||||
//
|
||||
// openApi-3.0 for tool calling, as before, and it emits no `$schema` — so
|
||||
// the property this used to strip afterwards is one that never arrives.
|
||||
// zod v4's own converter; zod-to-json-schema reads only v3 schemas.
|
||||
return toJSONSchema(schema as ZodType, {
|
||||
target: 'openApi-3.0',
|
||||
}) as Record<string, unknown>;
|
||||
|
||||
@@ -99,9 +99,7 @@ describe('AIClient', () => {
|
||||
});
|
||||
|
||||
describe('provider selection for text generation', () => {
|
||||
// Our own stack, and our own model. It speaks the OpenAI dialect, so the
|
||||
// client it builds is the same one — what makes it ours is the address and
|
||||
// the model, which is exactly what this asserts.
|
||||
// Hanzo speaks the OpenAI dialect: the same client, our address and model.
|
||||
it('answers from Hanzo by default', async () => {
|
||||
const client = new AIClient({ apiKey: 'test-key' });
|
||||
await client.generate('test prompt');
|
||||
|
||||
@@ -9,6 +9,7 @@ import { BaseMemory } from '../src/memory/BaseMemory.js';
|
||||
*/
|
||||
class FakeBase {
|
||||
records = new Map<string, any>();
|
||||
beforeCreate?: () => void;
|
||||
private next = 0;
|
||||
private server?: http.Server;
|
||||
|
||||
@@ -39,7 +40,12 @@ class FakeBase {
|
||||
const body = await read(req);
|
||||
|
||||
if (req.method === 'GET' && !id) return json(res, this.list(url));
|
||||
if (req.method === 'POST' && !id) return json(res, this.create(body), 201);
|
||||
if (req.method === 'POST' && !id) {
|
||||
const record = this.create(body);
|
||||
return record
|
||||
? json(res, record, 201)
|
||||
: json(res, { message: 'Failed to create record.', data: { mkey: { code: 'validation_not_unique' } } }, 400);
|
||||
}
|
||||
if (req.method === 'PATCH' && id) return json(res, this.patch(id, body));
|
||||
if (req.method === 'DELETE' && id) {
|
||||
this.records.delete(id);
|
||||
@@ -72,7 +78,16 @@ class FakeBase {
|
||||
return { items: hits.slice(from, from + perPage), totalItems: total };
|
||||
}
|
||||
|
||||
/** A create under the unique index over scope, scope_id and mkey. */
|
||||
private create(body: any) {
|
||||
this.beforeCreate?.();
|
||||
const taken = [...this.records.values()].some(
|
||||
(rec) => rec.scope === body.scope && rec.scope_id === body.scope_id && rec.mkey === body.mkey
|
||||
);
|
||||
return taken ? undefined : this.insert(body);
|
||||
}
|
||||
|
||||
insert(body: any) {
|
||||
const record = { ...body, id: `r${++this.next}` };
|
||||
this.records.set(record.id, record);
|
||||
return record;
|
||||
@@ -239,6 +254,33 @@ describe('BaseMemory', () => {
|
||||
expect(await memory.get('doc', where)).toBe('the text');
|
||||
});
|
||||
|
||||
it('takes the scope id from the execution the request carries', async () => {
|
||||
const memory = await backedByFake();
|
||||
await memory.set('step', 'one', { scope: 'workflow', metadata: { workflowId: 'w1' } });
|
||||
await memory.set('step', 'two', { scope: 'workflow', metadata: { workflowId: 'w2' } });
|
||||
|
||||
expect(await memory.get('step', { scope: 'workflow', metadata: { workflowId: 'w1' } })).toBe('one');
|
||||
expect(await memory.get('step', { scope: 'workflow', metadata: { workflowId: 'w2' } })).toBe('two');
|
||||
});
|
||||
|
||||
it('files global under the id every SDK uses', async () => {
|
||||
const memory = await backedByFake();
|
||||
await memory.set('shared', true, { scope: 'global' });
|
||||
expect([...fake!.records.values()][0].scope_id).toBe('global');
|
||||
});
|
||||
|
||||
it('keeps the last write when two writers create one key at once', async () => {
|
||||
const memory = await backedByFake();
|
||||
fake!.beforeCreate = () => {
|
||||
fake!.beforeCreate = undefined;
|
||||
fake!.insert({ scope: 'session', scope_id: 's1', mkey: 'k', value: JSON.stringify('theirs') });
|
||||
};
|
||||
await memory.set('k', 'ours', { scope: 'session', scopeId: 's1' });
|
||||
|
||||
expect(await memory.get('k', { scope: 'session', scopeId: 's1' })).toBe('ours');
|
||||
expect(fake!.records.size).toBe(1);
|
||||
});
|
||||
|
||||
it('names a path Base does not serve, rather than failing to parse it', async () => {
|
||||
const memory = await backedByFake('wrong_collection');
|
||||
await expect(memory.get('anything', { scope: 'session', scopeId: 's1' })).rejects.toThrow(/HTML/);
|
||||
|
||||
@@ -220,7 +220,7 @@ async function createControlPlaneStub() {
|
||||
res.json(memory.filter((entry) => entry.scope === scope).map((entry) => ({ key: entry.key })));
|
||||
});
|
||||
|
||||
app.post('/v1/memory/vector/set', (req, res) => {
|
||||
app.post('/v1/memory/vector', (req, res) => {
|
||||
const scope = req.body?.scope ?? 'workflow';
|
||||
const scopeId = resolveScopeId(scope, req.headers);
|
||||
const existing = vectors.find(
|
||||
@@ -254,11 +254,11 @@ async function createControlPlaneStub() {
|
||||
res.json(matches);
|
||||
});
|
||||
|
||||
app.post('/v1/memory/vector/delete', (req, res) => {
|
||||
const scope = req.body?.scope ?? 'workflow';
|
||||
app.delete('/v1/memory/vector/:key', (req, res) => {
|
||||
const scope = String(req.query.scope ?? 'workflow');
|
||||
const scopeId = resolveScopeId(scope, req.headers);
|
||||
const idx = vectors.findIndex(
|
||||
(entry) => entry.scope === scope && entry.scopeId === scopeId && entry.key === req.body?.key
|
||||
(entry) => entry.scope === scope && entry.scopeId === scopeId && entry.key === req.params.key
|
||||
);
|
||||
if (idx >= 0) vectors.splice(idx, 1);
|
||||
res.json({ ok: true });
|
||||
|
||||
@@ -45,7 +45,7 @@ describe('MemoryClient vector operations', () => {
|
||||
);
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/v1/memory/vector/set',
|
||||
'/v1/memory/vector',
|
||||
{
|
||||
key: 'chunk_1',
|
||||
embedding: [0.1, 0.2],
|
||||
@@ -94,8 +94,8 @@ describe('MemoryClient vector operations', () => {
|
||||
it('deletes vectors with scoped headers', async () => {
|
||||
const client = new MemoryClient('http://localhost:8080');
|
||||
const http = getCreatedClient();
|
||||
const post = vi.fn().mockResolvedValue({ data: {} });
|
||||
http.post = post;
|
||||
const del = vi.fn().mockResolvedValue({ data: {} });
|
||||
http.delete = del;
|
||||
|
||||
await client.deleteVector('chunk_2', {
|
||||
scope: 'session',
|
||||
@@ -103,13 +103,10 @@ describe('MemoryClient vector operations', () => {
|
||||
metadata: { sessionId: 's1' }
|
||||
});
|
||||
|
||||
expect(post).toHaveBeenCalledWith(
|
||||
'/v1/memory/vector/delete',
|
||||
{
|
||||
key: 'chunk_2',
|
||||
scope: 'session'
|
||||
},
|
||||
expect(del).toHaveBeenCalledWith(
|
||||
'/v1/memory/vector/chunk_2',
|
||||
expect.objectContaining({
|
||||
params: { scope: 'session' },
|
||||
headers: expect.objectContaining({
|
||||
'X-Session-ID': 's1'
|
||||
})
|
||||
|
||||
@@ -4,18 +4,8 @@ import vm from 'node:vm';
|
||||
import { Agent } from '../src/agent/Agent.js';
|
||||
|
||||
/**
|
||||
* Collect the garbage, whatever flags this run was started with.
|
||||
*
|
||||
* These checks are about what an agent RETAINS, and the difference between
|
||||
* retained and merely uncollected is a collection. `global.gc` exists only
|
||||
* under --expose-gc, so guarding on it meant that in a normal run nothing was
|
||||
* collected and the numbers were about garbage-collector timing instead: 500
|
||||
* agents nothing holds a reference to read as 30MB of growth, which failed a
|
||||
* 25MB leak threshold whenever this file ran alone and passed whenever another
|
||||
* file had warmed the heap first.
|
||||
*
|
||||
* Asking v8 for the flag here needs no launch flag and no runner config, so
|
||||
* the measurement does not depend on how the suite was invoked.
|
||||
* Collect the garbage whatever flags the run started with, so the leak checks
|
||||
* measure what is retained rather than what is not yet collected.
|
||||
*/
|
||||
const collect: () => void = (() => {
|
||||
if (typeof global.gc === 'function') return global.gc;
|
||||
|
||||
Reference in New Issue
Block a user