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:
2026-09-11 12:12:32 -07:00
co-authored by Hanzo Dev
parent 56add66c20
commit ca8a8eb4b7
46 changed files with 486 additions and 543 deletions
+2 -2
View File
@@ -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
View File
@@ -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.
+3 -3
View File
@@ -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{}
+2 -2
View File
@@ -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"
)
+35 -63
View File
@@ -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 }
+59 -3
View File
@@ -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) {
+15 -20
View File
@@ -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"
+1 -1
View File
@@ -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.
+2 -5
View File
@@ -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")
+3 -6
View File
@@ -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{
+3 -3
View File
@@ -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
View File
@@ -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
View File
@@ -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
+3 -2
View File
@@ -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
View File
@@ -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"
+1 -4
View File
@@ -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
+5 -11
View File
@@ -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
+1 -1
View File
@@ -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.
+1 -1
View File
@@ -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
View File
@@ -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
+1 -1
View File
@@ -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]
```
+1 -1
View File
@@ -80,4 +80,4 @@ __all__ = [
"register_provider",
]
__version__ = "0.1.41-rc.2"
__version__ = "0.2.10"
+7 -12
View File
@@ -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:
+94 -85
View File
@@ -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 {}
-4
View File
@@ -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 (
+5 -7
View File
@@ -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 -9
View File
@@ -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",
+61 -4
View File
@@ -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
+10 -8
View File
@@ -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)
+2 -2
View File
@@ -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))
+3 -13
View File
@@ -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
View File
@@ -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/"
}
}
+2 -8
View File
@@ -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));
+5 -18
View File
@@ -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;
+33 -70
View File
@@ -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)) {
+22 -10
View File
@@ -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>;
+5 -23
View File
@@ -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]) => {
+1 -8
View File
@@ -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'
+1 -7
View File
@@ -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>;
+1 -3
View File
@@ -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');
+43 -1
View File
@@ -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/);
+4 -4
View File
@@ -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;