Merge pull request #1391 from trheyi/main
Refactor Context Management to Use Memory Instead of Space
This commit is contained in:
commit
1757565778
52 changed files with 3053 additions and 1990 deletions
|
|
@ -252,14 +252,14 @@ func (ast *Assistant) BufferUserInput(ctx *agentcontext.Context, inputMessages [
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// UpdateSpaceSnapshot updates the space snapshot in the buffer
|
// UpdateSpaceSnapshot updates the context memory snapshot in the buffer
|
||||||
// Should be called when space data changes
|
// Only captures Context-level memory (request-scoped temporary data) for recovery
|
||||||
func (ast *Assistant) UpdateSpaceSnapshot(ctx *agentcontext.Context) {
|
func (ast *Assistant) UpdateSpaceSnapshot(ctx *agentcontext.Context) {
|
||||||
if ctx.Buffer == nil || ctx.Space == nil {
|
if ctx.Buffer == nil || ctx.Memory == nil || ctx.Memory.Context == nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
snapshot := ctx.Space.Snapshot()
|
snapshot := ctx.Memory.Context.Snapshot()
|
||||||
ctx.Buffer.SetSpaceSnapshot(snapshot)
|
ctx.Buffer.SetSpaceSnapshot(snapshot)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
"github.com/google/uuid"
|
"github.com/google/uuid"
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/yaoapp/gou/plan"
|
|
||||||
"github.com/yaoapp/yao/agent/assistant"
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
agentcontext "github.com/yaoapp/yao/agent/context"
|
agentcontext "github.com/yaoapp/yao/agent/context"
|
||||||
storetypes "github.com/yaoapp/yao/agent/store/types"
|
storetypes "github.com/yaoapp/yao/agent/store/types"
|
||||||
|
|
@ -431,15 +430,16 @@ func TestBufferStepTracking(t *testing.T) {
|
||||||
|
|
||||||
t.Run("BeginAndCompleteStep", func(t *testing.T) {
|
t.Run("BeginAndCompleteStep", func(t *testing.T) {
|
||||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_step_001")
|
ctx := agentcontext.New(context.Background(), nil, "test_chat_step_001")
|
||||||
ctx.Space = plan.NewMemorySharedSpace()
|
|
||||||
|
|
||||||
// Enter stack and init buffer
|
// Enter stack and init buffer
|
||||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||||
defer done()
|
defer done()
|
||||||
ast.InitBuffer(ctx)
|
ast.InitBuffer(ctx)
|
||||||
|
|
||||||
// Set some space data
|
// Set some context memory data
|
||||||
ctx.Space.Set("test_key", "test_value")
|
if ctx.Memory != nil && ctx.Memory.Context != nil {
|
||||||
|
ctx.Memory.Context.Set("test_key", "test_value", 0)
|
||||||
|
}
|
||||||
|
|
||||||
// Begin a step
|
// Begin a step
|
||||||
step := ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{
|
step := ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{
|
||||||
|
|
@ -464,34 +464,34 @@ func TestBufferStepTracking(t *testing.T) {
|
||||||
t.Logf("✓ Step tracking works correctly")
|
t.Logf("✓ Step tracking works correctly")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("SpaceSnapshotCapture", func(t *testing.T) {
|
t.Run("ContextMemorySnapshotCapture", func(t *testing.T) {
|
||||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_space_001")
|
ctx := agentcontext.New(context.Background(), nil, "test_chat_memory_001")
|
||||||
ctx.Space = plan.NewMemorySharedSpace()
|
|
||||||
|
|
||||||
// Enter stack and init buffer
|
// Enter stack and init buffer
|
||||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||||
defer done()
|
defer done()
|
||||||
ast.InitBuffer(ctx)
|
ast.InitBuffer(ctx)
|
||||||
|
|
||||||
// Set space data before step
|
// Set context memory data before step
|
||||||
ctx.Space.Set("key1", "value1")
|
require.NotNil(t, ctx.Memory)
|
||||||
ctx.Space.Set("key2", 123)
|
require.NotNil(t, ctx.Memory.Context)
|
||||||
|
ctx.Memory.Context.Set("key1", "value1", 0)
|
||||||
|
ctx.Memory.Context.Set("key2", 123, 0)
|
||||||
|
|
||||||
// Begin step (should capture space snapshot)
|
// Begin step (should capture context memory snapshot)
|
||||||
ast.BeginStep(ctx, agentcontext.StepTypeHookCreate, nil)
|
ast.BeginStep(ctx, agentcontext.StepTypeHookCreate, nil)
|
||||||
|
|
||||||
// Verify space snapshot was captured
|
// Verify context memory snapshot was captured
|
||||||
steps := ctx.Buffer.GetAllSteps()
|
steps := ctx.Buffer.GetAllSteps()
|
||||||
require.Len(t, steps, 1)
|
require.Len(t, steps, 1)
|
||||||
assert.NotNil(t, steps[0].SpaceSnapshot)
|
assert.NotNil(t, steps[0].SpaceSnapshot)
|
||||||
assert.Equal(t, "value1", steps[0].SpaceSnapshot["key1"])
|
assert.Equal(t, "value1", steps[0].SpaceSnapshot["key1"])
|
||||||
assert.Equal(t, 123, steps[0].SpaceSnapshot["key2"])
|
assert.Equal(t, 123, steps[0].SpaceSnapshot["key2"])
|
||||||
t.Logf("✓ Space snapshot captured: %v", steps[0].SpaceSnapshot)
|
t.Logf("✓ Context memory snapshot captured: %v", steps[0].SpaceSnapshot)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("MultipleSteps", func(t *testing.T) {
|
t.Run("MultipleSteps", func(t *testing.T) {
|
||||||
ctx := agentcontext.New(context.Background(), nil, "test_chat_multi_step")
|
ctx := agentcontext.New(context.Background(), nil, "test_chat_multi_step")
|
||||||
ctx.Space = plan.NewMemorySharedSpace()
|
|
||||||
|
|
||||||
// Enter stack and init buffer
|
// Enter stack and init buffer
|
||||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||||
|
|
@ -536,7 +536,6 @@ func TestFlushBuffer(t *testing.T) {
|
||||||
t.Run("FlushOnSuccess", func(t *testing.T) {
|
t.Run("FlushOnSuccess", func(t *testing.T) {
|
||||||
chatID := fmt.Sprintf("test_flush_success_%s", uuid.New().String()[:8])
|
chatID := fmt.Sprintf("test_flush_success_%s", uuid.New().String()[:8])
|
||||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||||
ctx.Space = plan.NewMemorySharedSpace()
|
|
||||||
|
|
||||||
// Enter stack and init buffer
|
// Enter stack and init buffer
|
||||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||||
|
|
@ -584,7 +583,6 @@ func TestFlushBuffer(t *testing.T) {
|
||||||
t.Run("FlushOnFailure", func(t *testing.T) {
|
t.Run("FlushOnFailure", func(t *testing.T) {
|
||||||
chatID := fmt.Sprintf("test_flush_fail_%s", uuid.New().String()[:8])
|
chatID := fmt.Sprintf("test_flush_fail_%s", uuid.New().String()[:8])
|
||||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||||
ctx.Space = plan.NewMemorySharedSpace()
|
|
||||||
|
|
||||||
// Enter stack and init buffer
|
// Enter stack and init buffer
|
||||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||||
|
|
@ -633,7 +631,6 @@ func TestFlushBuffer(t *testing.T) {
|
||||||
t.Run("FlushOnInterrupt", func(t *testing.T) {
|
t.Run("FlushOnInterrupt", func(t *testing.T) {
|
||||||
chatID := fmt.Sprintf("test_flush_interrupt_%s", uuid.New().String()[:8])
|
chatID := fmt.Sprintf("test_flush_interrupt_%s", uuid.New().String()[:8])
|
||||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||||
ctx.Space = plan.NewMemorySharedSpace()
|
|
||||||
|
|
||||||
// Enter stack and init buffer
|
// Enter stack and init buffer
|
||||||
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
|
||||||
|
|
@ -673,7 +670,6 @@ func TestFlushBuffer(t *testing.T) {
|
||||||
t.Run("FlushWithModeAndConnector", func(t *testing.T) {
|
t.Run("FlushWithModeAndConnector", func(t *testing.T) {
|
||||||
chatID := fmt.Sprintf("test_flush_mode_%s", uuid.New().String()[:8])
|
chatID := fmt.Sprintf("test_flush_mode_%s", uuid.New().String()[:8])
|
||||||
ctx := agentcontext.New(context.Background(), nil, chatID)
|
ctx := agentcontext.New(context.Background(), nil, chatID)
|
||||||
ctx.Space = plan.NewMemorySharedSpace()
|
|
||||||
|
|
||||||
// Enter stack with connector and mode options
|
// Enter stack with connector and mode options
|
||||||
opts := &agentcontext.Options{
|
opts := &agentcontext.Options{
|
||||||
|
|
|
||||||
|
|
@ -93,10 +93,10 @@ func TestMemoryLeakStandardMode(t *testing.T) {
|
||||||
|
|
||||||
// Check for memory leak
|
// Check for memory leak
|
||||||
// Standard mode creates/disposes isolates per request, so some overhead is expected
|
// Standard mode creates/disposes isolates per request, so some overhead is expected
|
||||||
// Allow up to 15KB growth per iteration as threshold (increased from 10KB)
|
// Allow up to 20KB growth per iteration as threshold
|
||||||
// This accounts for V8 isolate creation/disposal overhead and bridge management
|
// This accounts for V8 isolate creation/disposal overhead and bridge management
|
||||||
// Significant leaks would show much higher growth rates (50KB+)
|
// Significant leaks would show much higher growth rates (50KB+)
|
||||||
maxGrowthPerIteration := 15360.0 // 15 KB
|
maxGrowthPerIteration := 20480.0 // 20 KB
|
||||||
if growthPerIteration > maxGrowthPerIteration {
|
if growthPerIteration > maxGrowthPerIteration {
|
||||||
t.Errorf("Possible memory leak detected: %.2f bytes/iteration (threshold: %.2f bytes/iteration)",
|
t.Errorf("Possible memory leak detected: %.2f bytes/iteration (threshold: %.2f bytes/iteration)",
|
||||||
growthPerIteration, maxGrowthPerIteration)
|
growthPerIteration, maxGrowthPerIteration)
|
||||||
|
|
@ -267,8 +267,10 @@ func TestMemoryLeakBusinessScenarios(t *testing.T) {
|
||||||
t.Logf(" Growth/iteration: %.2f bytes", growthPerIteration)
|
t.Logf(" Growth/iteration: %.2f bytes", growthPerIteration)
|
||||||
|
|
||||||
// Business scenarios may have more memory usage due to complex operations
|
// Business scenarios may have more memory usage due to complex operations
|
||||||
// Allow up to 15KB per iteration as threshold
|
// Allow up to 20KB per iteration as threshold
|
||||||
maxGrowthPerIteration := 15360.0
|
// Note: Some scenarios like ContextAdjustment generate dynamic timestamps,
|
||||||
|
// causing slightly higher memory usage. Real leaks would show 50KB+ growth.
|
||||||
|
maxGrowthPerIteration := 20480.0
|
||||||
if growthPerIteration > maxGrowthPerIteration {
|
if growthPerIteration > maxGrowthPerIteration {
|
||||||
t.Errorf("Possible memory leak: %.2f bytes/iteration (threshold: %.2f)",
|
t.Errorf("Possible memory leak: %.2f bytes/iteration (threshold: %.2f)",
|
||||||
growthPerIteration, maxGrowthPerIteration)
|
growthPerIteration, maxGrowthPerIteration)
|
||||||
|
|
|
||||||
|
|
@ -21,11 +21,6 @@ import (
|
||||||
// ========== Test Constants ==========
|
// ========== Test Constants ==========
|
||||||
|
|
||||||
const (
|
const (
|
||||||
// Collection IDs for auth testing
|
|
||||||
AuthTestCollectionTeam1 = "auth_test_team1"
|
|
||||||
AuthTestCollectionTeam2 = "auth_test_team2"
|
|
||||||
AuthTestCollectionPublic = "auth_test_public"
|
|
||||||
|
|
||||||
// Test users and teams
|
// Test users and teams
|
||||||
TestUserA = "user_a"
|
TestUserA = "user_a"
|
||||||
TestUserB = "user_b"
|
TestUserB = "user_b"
|
||||||
|
|
@ -33,75 +28,31 @@ const (
|
||||||
TestTeam2 = "team_2"
|
TestTeam2 = "team_2"
|
||||||
)
|
)
|
||||||
|
|
||||||
// ========== Setup Test ==========
|
// authTestCollections holds dynamically generated collection IDs for a test run
|
||||||
|
type authTestCollections struct {
|
||||||
// TestAuthSearchSetup creates test collections with different permissions.
|
Team1 string
|
||||||
// Run once before running auth tests:
|
Team2 string
|
||||||
//
|
Public string
|
||||||
// go test -v -run "TestAuthSearchSetup" ./agent/assistant/...
|
|
||||||
func TestAuthSearchSetup(t *testing.T) {
|
|
||||||
testutils.Prepare(t)
|
|
||||||
defer testutils.Clean(t)
|
|
||||||
|
|
||||||
if kb.API == nil {
|
|
||||||
t.Fatal("KB API not initialized")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Check if collections already exist
|
|
||||||
team1Exists := collectionReady(ctx, AuthTestCollectionTeam1, 2)
|
|
||||||
team2Exists := collectionReady(ctx, AuthTestCollectionTeam2, 2)
|
|
||||||
publicExists := collectionReady(ctx, AuthTestCollectionPublic, 2)
|
|
||||||
|
|
||||||
if team1Exists && team2Exists && publicExists {
|
|
||||||
t.Log("✓ All auth test collections already exist")
|
|
||||||
t.Log(" Run TestAuthSearchCleanup to recreate")
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Cleanup existing
|
|
||||||
t.Log("Cleaning up existing collections...")
|
|
||||||
cleanupAuthCollections(ctx, t)
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
|
|
||||||
// Create Team1 collection (owned by UserA, Team1)
|
|
||||||
t.Log("Creating Team1 collection...")
|
|
||||||
createAuthCollection(ctx, t, AuthTestCollectionTeam1, TestUserA, TestTeam1, false, "team")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionTeam1, "Team1 Doc1", "Team1 private document about quantum physics and relativity theory.")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionTeam1, "Team1 Doc2", "Team1 shared document about machine learning and neural networks.")
|
|
||||||
|
|
||||||
// Create Team2 collection (owned by UserB, Team2)
|
|
||||||
t.Log("Creating Team2 collection...")
|
|
||||||
createAuthCollection(ctx, t, AuthTestCollectionTeam2, TestUserB, TestTeam2, false, "team")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionTeam2, "Team2 Doc1", "Team2 private document about deep learning algorithms.")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionTeam2, "Team2 Doc2", "Team2 shared document about computer vision techniques.")
|
|
||||||
|
|
||||||
// Create Public collection
|
|
||||||
t.Log("Creating Public collection...")
|
|
||||||
createAuthCollection(ctx, t, AuthTestCollectionPublic, TestUserA, TestTeam1, true, "")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionPublic, "Public Doc1", "Public document about artificial intelligence and robotics.")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionPublic, "Public Doc2", "Public document about natural language processing.")
|
|
||||||
|
|
||||||
// Wait for indexing
|
|
||||||
t.Log("Waiting for indexing...")
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
|
|
||||||
t.Log("✓ Auth test setup complete!")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestAuthSearchCleanup removes auth test collections.
|
// newAuthTestCollections creates unique collection IDs for a test run
|
||||||
func TestAuthSearchCleanup(t *testing.T) {
|
func newAuthTestCollections() *authTestCollections {
|
||||||
testutils.Prepare(t)
|
suffix := fmt.Sprintf("%d", time.Now().UnixNano())
|
||||||
defer testutils.Clean(t)
|
return &authTestCollections{
|
||||||
|
Team1: fmt.Sprintf("auth_test_team1_%s", suffix),
|
||||||
if kb.API == nil {
|
Team2: fmt.Sprintf("auth_test_team2_%s", suffix),
|
||||||
t.Fatal("KB API not initialized")
|
Public: fmt.Sprintf("auth_test_public_%s", suffix),
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
|
||||||
ctx := context.Background()
|
// cleanup removes all test collections
|
||||||
cleanupAuthCollections(ctx, t)
|
func (c *authTestCollections) cleanup(ctx context.Context, t *testing.T) {
|
||||||
t.Log("✓ Auth test cleanup complete!")
|
collections := []string{c.Team1, c.Team2, c.Public}
|
||||||
|
for _, id := range collections {
|
||||||
|
if result, err := kb.API.RemoveCollection(ctx, id); err == nil && result.Removed {
|
||||||
|
t.Logf(" Removed: %s", id)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// ========== KB Collection-Level Auth Filter Tests ==========
|
// ========== KB Collection-Level Auth Filter Tests ==========
|
||||||
|
|
@ -114,37 +65,48 @@ func TestKBCollectionAuthFilter(t *testing.T) {
|
||||||
testutils.Prepare(t)
|
testutils.Prepare(t)
|
||||||
defer testutils.Clean(t)
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
// Ensure test data exists (auto-creates if not)
|
if kb.API == nil {
|
||||||
ensureAuthTestData(t)
|
t.Fatal("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cols := newAuthTestCollections()
|
||||||
|
defer cols.cleanup(ctx, t)
|
||||||
|
|
||||||
|
// Create test collections
|
||||||
|
t.Log("Creating test collections...")
|
||||||
|
createAuthCollection(ctx, t, cols.Team1, TestUserA, TestTeam1, false, "team")
|
||||||
|
createAuthCollection(ctx, t, cols.Team2, TestUserB, TestTeam2, false, "team")
|
||||||
|
createAuthCollection(ctx, t, cols.Public, TestUserA, TestTeam1, true, "")
|
||||||
|
|
||||||
t.Run("TeamMemberCanAccessTeamCollection", func(t *testing.T) {
|
t.Run("TeamMemberCanAccessTeamCollection", func(t *testing.T) {
|
||||||
// UserA from Team1 should access Team1 collection
|
// UserA from Team1 should access Team1 collection
|
||||||
ctx := createAuthContext(TestUserA, TestTeam1, true, false)
|
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
|
||||||
collections := []string{AuthTestCollectionTeam1, AuthTestCollectionTeam2}
|
collections := []string{cols.Team1, cols.Team2}
|
||||||
|
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(ctx, collections)
|
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||||
assert.Contains(t, allowed, AuthTestCollectionTeam1, "Team1 member should access Team1 collection")
|
assert.Contains(t, allowed, cols.Team1, "Team1 member should access Team1 collection")
|
||||||
t.Logf(" Allowed collections: %v", allowed)
|
t.Logf(" Allowed collections: %v", allowed)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("TeamMemberCannotAccessOtherTeamCollection", func(t *testing.T) {
|
t.Run("TeamMemberCannotAccessOtherTeamCollection", func(t *testing.T) {
|
||||||
// UserA from Team1 should NOT access Team2 collection
|
// UserA from Team1 should NOT access Team2 collection
|
||||||
ctx := createAuthContext(TestUserA, TestTeam1, true, false)
|
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
|
||||||
collections := []string{AuthTestCollectionTeam2}
|
collections := []string{cols.Team2}
|
||||||
|
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(ctx, collections)
|
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||||
assert.NotContains(t, allowed, AuthTestCollectionTeam2, "Team1 member should NOT access Team2 collection")
|
assert.NotContains(t, allowed, cols.Team2, "Team1 member should NOT access Team2 collection")
|
||||||
t.Logf(" Allowed collections: %v (expected empty)", allowed)
|
t.Logf(" Allowed collections: %v (expected empty)", allowed)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("OwnerCanAccessOwnCollection", func(t *testing.T) {
|
t.Run("OwnerCanAccessOwnCollection", func(t *testing.T) {
|
||||||
// UserA with OwnerOnly should access collections they created
|
// UserA with OwnerOnly should access collections they created
|
||||||
ctx := createAuthContext(TestUserA, "", false, true)
|
authCtx := createAuthContext(TestUserA, "", false, true)
|
||||||
collections := []string{AuthTestCollectionTeam1, AuthTestCollectionTeam2}
|
collections := []string{cols.Team1, cols.Team2}
|
||||||
|
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(ctx, collections)
|
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||||
assert.Contains(t, allowed, AuthTestCollectionTeam1, "Owner should access own collection")
|
assert.Contains(t, allowed, cols.Team1, "Owner should access own collection")
|
||||||
assert.NotContains(t, allowed, AuthTestCollectionTeam2, "Owner should NOT access other's collection")
|
assert.NotContains(t, allowed, cols.Team2, "Owner should NOT access other's collection")
|
||||||
t.Logf(" Allowed collections: %v", allowed)
|
t.Logf(" Allowed collections: %v", allowed)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|
@ -154,8 +116,7 @@ func TestKBCollectionAuthFilter(t *testing.T) {
|
||||||
// When public=true is properly set in DB, this should pass.
|
// When public=true is properly set in DB, this should pass.
|
||||||
|
|
||||||
// First, check the collection metadata
|
// First, check the collection metadata
|
||||||
bgCtx := context.Background()
|
collection, err := kb.API.GetCollection(ctx, cols.Public)
|
||||||
collection, err := kb.API.GetCollection(bgCtx, AuthTestCollectionPublic)
|
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
// Check if public is set correctly
|
// Check if public is set correctly
|
||||||
|
|
@ -164,26 +125,26 @@ func TestKBCollectionAuthFilter(t *testing.T) {
|
||||||
|
|
||||||
// If public is not set (0 or false), the test documents current behavior
|
// If public is not set (0 or false), the test documents current behavior
|
||||||
// The collection should be accessible via owner check since UserA created it
|
// The collection should be accessible via owner check since UserA created it
|
||||||
ctx := createAuthContext(TestUserA, TestTeam1, false, true) // Owner check
|
authCtx := createAuthContext(TestUserA, TestTeam1, false, true) // Owner check
|
||||||
collections := []string{AuthTestCollectionPublic}
|
collections := []string{cols.Public}
|
||||||
|
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(ctx, collections)
|
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||||
assert.Contains(t, allowed, AuthTestCollectionPublic, "Owner should access their collection")
|
assert.Contains(t, allowed, cols.Public, "Owner should access their collection")
|
||||||
t.Logf(" Allowed collections (owner check): %v", allowed)
|
t.Logf(" Allowed collections (owner check): %v", allowed)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("NoConstraintsMeansFullAccess", func(t *testing.T) {
|
t.Run("NoConstraintsMeansFullAccess", func(t *testing.T) {
|
||||||
// User with no constraints should access all collections
|
// User with no constraints should access all collections
|
||||||
ctx := createAuthContext(TestUserA, TestTeam1, false, false)
|
authCtx := createAuthContext(TestUserA, TestTeam1, false, false)
|
||||||
collections := []string{AuthTestCollectionTeam1, AuthTestCollectionTeam2, AuthTestCollectionPublic}
|
collections := []string{cols.Team1, cols.Team2, cols.Public}
|
||||||
|
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(ctx, collections)
|
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||||
assert.Len(t, allowed, 3, "No constraints should allow all collections")
|
assert.Len(t, allowed, 3, "No constraints should allow all collections")
|
||||||
t.Logf(" Allowed collections: %v", allowed)
|
t.Logf(" Allowed collections: %v", allowed)
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("NilContextMeansFullAccess", func(t *testing.T) {
|
t.Run("NilContextMeansFullAccess", func(t *testing.T) {
|
||||||
collections := []string{AuthTestCollectionTeam1, AuthTestCollectionTeam2}
|
collections := []string{cols.Team1, cols.Team2}
|
||||||
|
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(nil, collections)
|
allowed := assistant.FilterKBCollectionsByAuth(nil, collections)
|
||||||
assert.Len(t, allowed, 2, "Nil context should allow all collections")
|
assert.Len(t, allowed, 2, "Nil context should allow all collections")
|
||||||
|
|
@ -301,7 +262,7 @@ func TestDBAuthWheresFilter(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("NilAuthorizedReturnsNil", func(t *testing.T) {
|
t.Run("NilAuthorizedReturnsNil", func(t *testing.T) {
|
||||||
ctx := &agentContext.Context{Authorized: nil}
|
ctx := agentContext.New(context.Background(), nil, "test-chat")
|
||||||
wheres := assistant.BuildDBAuthWheres(ctx)
|
wheres := assistant.BuildDBAuthWheres(ctx)
|
||||||
|
|
||||||
assert.Nil(t, wheres, "Nil Authorized should return nil")
|
assert.Nil(t, wheres, "Nil Authorized should return nil")
|
||||||
|
|
@ -315,20 +276,43 @@ func TestKBSearchIntegration(t *testing.T) {
|
||||||
testutils.Prepare(t)
|
testutils.Prepare(t)
|
||||||
defer testutils.Clean(t)
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
// Ensure test data exists (auto-creates if not)
|
if kb.API == nil {
|
||||||
ensureAuthTestData(t)
|
t.Fatal("KB API not initialized")
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.Background()
|
||||||
|
cols := newAuthTestCollections()
|
||||||
|
defer cols.cleanup(ctx, t)
|
||||||
|
|
||||||
|
// Create test collections with documents
|
||||||
|
t.Log("Creating test collections with documents...")
|
||||||
|
createAuthCollection(ctx, t, cols.Team1, TestUserA, TestTeam1, false, "team")
|
||||||
|
addAuthDocument(ctx, t, cols.Team1, "Team1 Doc1", "Team1 private document about quantum physics and relativity theory.")
|
||||||
|
addAuthDocument(ctx, t, cols.Team1, "Team1 Doc2", "Team1 shared document about machine learning and neural networks.")
|
||||||
|
|
||||||
|
createAuthCollection(ctx, t, cols.Team2, TestUserB, TestTeam2, false, "team")
|
||||||
|
addAuthDocument(ctx, t, cols.Team2, "Team2 Doc1", "Team2 private document about deep learning algorithms.")
|
||||||
|
addAuthDocument(ctx, t, cols.Team2, "Team2 Doc2", "Team2 shared document about computer vision techniques.")
|
||||||
|
|
||||||
|
createAuthCollection(ctx, t, cols.Public, TestUserA, TestTeam1, true, "")
|
||||||
|
addAuthDocument(ctx, t, cols.Public, "Public Doc1", "Public document about artificial intelligence and robotics.")
|
||||||
|
addAuthDocument(ctx, t, cols.Public, "Public Doc2", "Public document about natural language processing.")
|
||||||
|
|
||||||
|
// Wait for indexing
|
||||||
|
t.Log("Waiting for indexing...")
|
||||||
|
time.Sleep(2 * time.Second)
|
||||||
|
|
||||||
t.Run("TeamMemberSearchOnlyFindsTeamData", func(t *testing.T) {
|
t.Run("TeamMemberSearchOnlyFindsTeamData", func(t *testing.T) {
|
||||||
// UserA from Team1 searches - should ONLY find Team1 data
|
// UserA from Team1 searches - should ONLY find Team1 data
|
||||||
ctx := createAuthContext(TestUserA, TestTeam1, true, false)
|
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
|
||||||
|
|
||||||
// Filter collections first
|
// Filter collections first
|
||||||
allCollections := []string{AuthTestCollectionTeam1, AuthTestCollectionTeam2}
|
allCollections := []string{cols.Team1, cols.Team2}
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(ctx, allCollections)
|
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
|
||||||
|
|
||||||
// Should only allow Team1
|
// Should only allow Team1
|
||||||
assert.Contains(t, allowed, AuthTestCollectionTeam1)
|
assert.Contains(t, allowed, cols.Team1)
|
||||||
assert.NotContains(t, allowed, AuthTestCollectionTeam2)
|
assert.NotContains(t, allowed, cols.Team2)
|
||||||
assert.Len(t, allowed, 1, "Should only have 1 allowed collection")
|
assert.Len(t, allowed, 1, "Should only have 1 allowed collection")
|
||||||
|
|
||||||
// Search on allowed collections
|
// Search on allowed collections
|
||||||
|
|
@ -337,7 +321,7 @@ func TestKBSearchIntegration(t *testing.T) {
|
||||||
|
|
||||||
// Verify ALL results are from Team1 collection only
|
// Verify ALL results are from Team1 collection only
|
||||||
for _, item := range result.Items {
|
for _, item := range result.Items {
|
||||||
assert.Equal(t, AuthTestCollectionTeam1, item.Collection,
|
assert.Equal(t, cols.Team1, item.Collection,
|
||||||
"All results should be from Team1 collection, got: %s", item.Collection)
|
"All results should be from Team1 collection, got: %s", item.Collection)
|
||||||
}
|
}
|
||||||
t.Logf(" ✓ Team1 member found %d items, all from Team1 collection", len(result.Items))
|
t.Logf(" ✓ Team1 member found %d items, all from Team1 collection", len(result.Items))
|
||||||
|
|
@ -345,11 +329,11 @@ func TestKBSearchIntegration(t *testing.T) {
|
||||||
|
|
||||||
t.Run("TeamMemberCannotAccessOtherTeamData", func(t *testing.T) {
|
t.Run("TeamMemberCannotAccessOtherTeamData", func(t *testing.T) {
|
||||||
// UserA from Team1 tries to access Team2 - should be blocked
|
// UserA from Team1 tries to access Team2 - should be blocked
|
||||||
ctx := createAuthContext(TestUserA, TestTeam1, true, false)
|
authCtx := createAuthContext(TestUserA, TestTeam1, true, false)
|
||||||
|
|
||||||
// Try to filter Team2 collection
|
// Try to filter Team2 collection
|
||||||
collections := []string{AuthTestCollectionTeam2}
|
collections := []string{cols.Team2}
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(ctx, collections)
|
allowed := assistant.FilterKBCollectionsByAuth(authCtx, collections)
|
||||||
|
|
||||||
// Should be empty - no access
|
// Should be empty - no access
|
||||||
assert.Empty(t, allowed, "Team1 member should NOT have access to Team2 collection")
|
assert.Empty(t, allowed, "Team1 member should NOT have access to Team2 collection")
|
||||||
|
|
@ -358,16 +342,16 @@ func TestKBSearchIntegration(t *testing.T) {
|
||||||
|
|
||||||
t.Run("OwnerSearchOnlyFindsOwnData", func(t *testing.T) {
|
t.Run("OwnerSearchOnlyFindsOwnData", func(t *testing.T) {
|
||||||
// UserA with OwnerOnly - should only find collections they created
|
// UserA with OwnerOnly - should only find collections they created
|
||||||
ctx := createAuthContext(TestUserA, "", false, true)
|
authCtx := createAuthContext(TestUserA, "", false, true)
|
||||||
|
|
||||||
// Filter all collections
|
// Filter all collections
|
||||||
allCollections := []string{AuthTestCollectionTeam1, AuthTestCollectionTeam2, AuthTestCollectionPublic}
|
allCollections := []string{cols.Team1, cols.Team2, cols.Public}
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(ctx, allCollections)
|
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
|
||||||
|
|
||||||
// UserA created Team1 and Public, not Team2
|
// UserA created Team1 and Public, not Team2
|
||||||
assert.Contains(t, allowed, AuthTestCollectionTeam1, "Owner should access Team1 (created by UserA)")
|
assert.Contains(t, allowed, cols.Team1, "Owner should access Team1 (created by UserA)")
|
||||||
assert.Contains(t, allowed, AuthTestCollectionPublic, "Owner should access Public (created by UserA)")
|
assert.Contains(t, allowed, cols.Public, "Owner should access Public (created by UserA)")
|
||||||
assert.NotContains(t, allowed, AuthTestCollectionTeam2, "Owner should NOT access Team2 (created by UserB)")
|
assert.NotContains(t, allowed, cols.Team2, "Owner should NOT access Team2 (created by UserB)")
|
||||||
|
|
||||||
// Search and verify results
|
// Search and verify results
|
||||||
result := executeKBSearchOnCollections(t, allowed, "quantum artificial intelligence")
|
result := executeKBSearchOnCollections(t, allowed, "quantum artificial intelligence")
|
||||||
|
|
@ -375,7 +359,7 @@ func TestKBSearchIntegration(t *testing.T) {
|
||||||
|
|
||||||
// Verify NO results from Team2
|
// Verify NO results from Team2
|
||||||
for _, item := range result.Items {
|
for _, item := range result.Items {
|
||||||
assert.NotEqual(t, AuthTestCollectionTeam2, item.Collection,
|
assert.NotEqual(t, cols.Team2, item.Collection,
|
||||||
"Should NOT have results from Team2, got: %s", item.Collection)
|
"Should NOT have results from Team2, got: %s", item.Collection)
|
||||||
}
|
}
|
||||||
t.Logf(" ✓ Owner found %d items, none from Team2", len(result.Items))
|
t.Logf(" ✓ Owner found %d items, none from Team2", len(result.Items))
|
||||||
|
|
@ -383,11 +367,11 @@ func TestKBSearchIntegration(t *testing.T) {
|
||||||
|
|
||||||
t.Run("NoConstraintsSearchFindsAllData", func(t *testing.T) {
|
t.Run("NoConstraintsSearchFindsAllData", func(t *testing.T) {
|
||||||
// User with no constraints - should find all data
|
// User with no constraints - should find all data
|
||||||
ctx := createAuthContext(TestUserA, TestTeam1, false, false)
|
authCtx := createAuthContext(TestUserA, TestTeam1, false, false)
|
||||||
|
|
||||||
// Filter all collections
|
// Filter all collections
|
||||||
allCollections := []string{AuthTestCollectionTeam1, AuthTestCollectionTeam2, AuthTestCollectionPublic}
|
allCollections := []string{cols.Team1, cols.Team2, cols.Public}
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(ctx, allCollections)
|
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
|
||||||
|
|
||||||
// Should have access to all
|
// Should have access to all
|
||||||
assert.Len(t, allowed, 3, "No constraints should allow all collections")
|
assert.Len(t, allowed, 3, "No constraints should allow all collections")
|
||||||
|
|
@ -406,14 +390,14 @@ func TestKBSearchIntegration(t *testing.T) {
|
||||||
|
|
||||||
t.Run("SearchResultsMatchCollectionFilter", func(t *testing.T) {
|
t.Run("SearchResultsMatchCollectionFilter", func(t *testing.T) {
|
||||||
// Verify that search results ONLY come from allowed collections
|
// Verify that search results ONLY come from allowed collections
|
||||||
ctx := createAuthContext(TestUserB, TestTeam2, true, false)
|
authCtx := createAuthContext(TestUserB, TestTeam2, true, false)
|
||||||
|
|
||||||
// UserB from Team2 - should only access Team2
|
// UserB from Team2 - should only access Team2
|
||||||
allCollections := []string{AuthTestCollectionTeam1, AuthTestCollectionTeam2, AuthTestCollectionPublic}
|
allCollections := []string{cols.Team1, cols.Team2, cols.Public}
|
||||||
allowed := assistant.FilterKBCollectionsByAuth(ctx, allCollections)
|
allowed := assistant.FilterKBCollectionsByAuth(authCtx, allCollections)
|
||||||
|
|
||||||
assert.Contains(t, allowed, AuthTestCollectionTeam2, "Team2 member should access Team2")
|
assert.Contains(t, allowed, cols.Team2, "Team2 member should access Team2")
|
||||||
assert.NotContains(t, allowed, AuthTestCollectionTeam1, "Team2 member should NOT access Team1")
|
assert.NotContains(t, allowed, cols.Team1, "Team2 member should NOT access Team1")
|
||||||
|
|
||||||
// Search
|
// Search
|
||||||
result := executeKBSearchOnCollections(t, allowed, "deep learning computer vision")
|
result := executeKBSearchOnCollections(t, allowed, "deep learning computer vision")
|
||||||
|
|
@ -434,101 +418,16 @@ func TestKBSearchIntegration(t *testing.T) {
|
||||||
|
|
||||||
// ========== Helper Functions ==========
|
// ========== Helper Functions ==========
|
||||||
|
|
||||||
// ensureAuthTestData checks if auth test data exists, creates if not.
|
|
||||||
// This is a utility function that can be called from any test.
|
|
||||||
// Note: testutils.Prepare must be called before this function.
|
|
||||||
func ensureAuthTestData(t *testing.T) {
|
|
||||||
if kb.API == nil {
|
|
||||||
t.Fatal("KB API not initialized - call testutils.Prepare first")
|
|
||||||
}
|
|
||||||
|
|
||||||
ctx := context.Background()
|
|
||||||
|
|
||||||
// Check if all collections already exist with required documents
|
|
||||||
team1Ready := collectionReady(ctx, AuthTestCollectionTeam1, 2)
|
|
||||||
team2Ready := collectionReady(ctx, AuthTestCollectionTeam2, 2)
|
|
||||||
publicReady := collectionReady(ctx, AuthTestCollectionPublic, 2)
|
|
||||||
|
|
||||||
if team1Ready && team2Ready && publicReady {
|
|
||||||
// All data exists, skip creation
|
|
||||||
return
|
|
||||||
}
|
|
||||||
|
|
||||||
// Data not ready, create it
|
|
||||||
t.Log("Auth test data not found, creating...")
|
|
||||||
createAuthTestData(t, ctx)
|
|
||||||
}
|
|
||||||
|
|
||||||
// createAuthTestData creates the test collections and documents
|
|
||||||
func createAuthTestData(t *testing.T, ctx context.Context) {
|
|
||||||
// Cleanup existing
|
|
||||||
t.Log("Cleaning up existing collections...")
|
|
||||||
cleanupAuthCollections(ctx, t)
|
|
||||||
time.Sleep(1 * time.Second)
|
|
||||||
|
|
||||||
// Create Team1 collection (owned by UserA, Team1)
|
|
||||||
t.Log("Creating Team1 collection...")
|
|
||||||
createAuthCollection(ctx, t, AuthTestCollectionTeam1, TestUserA, TestTeam1, false, "team")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionTeam1, "Team1 Doc1", "Team1 private document about quantum physics and relativity theory.")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionTeam1, "Team1 Doc2", "Team1 shared document about machine learning and neural networks.")
|
|
||||||
|
|
||||||
// Create Team2 collection (owned by UserB, Team2)
|
|
||||||
t.Log("Creating Team2 collection...")
|
|
||||||
createAuthCollection(ctx, t, AuthTestCollectionTeam2, TestUserB, TestTeam2, false, "team")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionTeam2, "Team2 Doc1", "Team2 private document about deep learning algorithms.")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionTeam2, "Team2 Doc2", "Team2 shared document about computer vision techniques.")
|
|
||||||
|
|
||||||
// Create Public collection
|
|
||||||
t.Log("Creating Public collection...")
|
|
||||||
createAuthCollection(ctx, t, AuthTestCollectionPublic, TestUserA, TestTeam1, true, "")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionPublic, "Public Doc1", "Public document about artificial intelligence and robotics.")
|
|
||||||
addAuthDocument(ctx, t, AuthTestCollectionPublic, "Public Doc2", "Public document about natural language processing.")
|
|
||||||
|
|
||||||
// Wait for indexing
|
|
||||||
t.Log("Waiting for indexing...")
|
|
||||||
time.Sleep(2 * time.Second)
|
|
||||||
|
|
||||||
t.Log("✓ Auth test data created!")
|
|
||||||
}
|
|
||||||
|
|
||||||
func createAuthContext(userID, teamID string, teamOnly, ownerOnly bool) *agentContext.Context {
|
func createAuthContext(userID, teamID string, teamOnly, ownerOnly bool) *agentContext.Context {
|
||||||
return &agentContext.Context{
|
authorized := &oauthtypes.AuthorizedInfo{
|
||||||
Authorized: &oauthtypes.AuthorizedInfo{
|
UserID: userID,
|
||||||
UserID: userID,
|
TeamID: teamID,
|
||||||
TeamID: teamID,
|
Constraints: oauthtypes.DataConstraints{
|
||||||
Constraints: oauthtypes.DataConstraints{
|
TeamOnly: teamOnly,
|
||||||
TeamOnly: teamOnly,
|
OwnerOnly: ownerOnly,
|
||||||
OwnerOnly: ownerOnly,
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
return agentContext.New(context.Background(), authorized, "test-chat")
|
||||||
|
|
||||||
func collectionReady(ctx context.Context, collectionID string, minDocs int) bool {
|
|
||||||
collection, err := kb.API.GetCollection(ctx, collectionID)
|
|
||||||
if err != nil || collection == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
docs, err := kb.API.ListDocuments(ctx, &api.ListDocumentsFilter{
|
|
||||||
Page: 1,
|
|
||||||
PageSize: 20,
|
|
||||||
CollectionID: collectionID,
|
|
||||||
})
|
|
||||||
if err != nil || docs == nil {
|
|
||||||
return false
|
|
||||||
}
|
|
||||||
|
|
||||||
return len(docs.Data) >= minDocs
|
|
||||||
}
|
|
||||||
|
|
||||||
func cleanupAuthCollections(ctx context.Context, t *testing.T) {
|
|
||||||
collections := []string{AuthTestCollectionTeam1, AuthTestCollectionTeam2, AuthTestCollectionPublic}
|
|
||||||
for _, id := range collections {
|
|
||||||
if result, err := kb.API.RemoveCollection(ctx, id); err == nil && result.Removed {
|
|
||||||
t.Logf(" Removed: %s", id)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
func createAuthCollection(ctx context.Context, t *testing.T, id, userID, teamID string, public bool, share string) {
|
func createAuthCollection(ctx context.Context, t *testing.T, id, userID, teamID string, public bool, share string) {
|
||||||
|
|
@ -597,10 +496,6 @@ func sanitizeForID(s string) string {
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
func executeKBSearch(t *testing.T, collectionID, query string, metadata map[string]interface{}) *searchTypes.Result {
|
|
||||||
return executeKBSearchOnCollections(t, []string{collectionID}, query)
|
|
||||||
}
|
|
||||||
|
|
||||||
func executeKBSearchOnCollections(t *testing.T, collections []string, query string) *searchTypes.Result {
|
func executeKBSearchOnCollections(t *testing.T, collections []string, query string) *searchTypes.Result {
|
||||||
if len(collections) == 0 {
|
if len(collections) == 0 {
|
||||||
return &searchTypes.Result{Items: []*searchTypes.ResultItem{}}
|
return &searchTypes.Result{Items: []*searchTypes.ResultItem{}}
|
||||||
|
|
|
||||||
|
|
@ -457,8 +457,8 @@ func TestVision_CachedContent(t *testing.T) {
|
||||||
t.Logf("✓ Content successfully cached and reused: %d characters", len(cachedText))
|
t.Logf("✓ Content successfully cached and reused: %d characters", len(cachedText))
|
||||||
}
|
}
|
||||||
|
|
||||||
// TestVision_FileMetadataInSpace tests that file metadata is correctly passed to vision agent via ctx.Space
|
// TestVision_FileMetadataInMemory tests that file metadata is correctly passed to vision agent via ctx.Memory.Context
|
||||||
func TestVision_FileMetadataInSpace(t *testing.T) {
|
func TestVision_FileMetadataInMemory(t *testing.T) {
|
||||||
testutils.Prepare(t)
|
testutils.Prepare(t)
|
||||||
defer testutils.Clean(t)
|
defer testutils.Clean(t)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -8,7 +8,6 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
"github.com/yaoapp/gou/connector/openai"
|
||||||
"github.com/yaoapp/gou/plan"
|
|
||||||
agentContext "github.com/yaoapp/yao/agent/context"
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
|
@ -27,31 +26,29 @@ func TestMain(m *testing.M) {
|
||||||
|
|
||||||
// newTestContext creates a Context for testing with commonly used fields pre-populated
|
// newTestContext creates a Context for testing with commonly used fields pre-populated
|
||||||
func newTestContext(capabilities *openai.Capabilities) *agentContext.Context {
|
func newTestContext(capabilities *openai.Capabilities) *agentContext.Context {
|
||||||
return &agentContext.Context{
|
authorized := &types.AuthorizedInfo{
|
||||||
Context: stdContext.Background(),
|
Subject: "test-user",
|
||||||
Space: plan.NewMemorySharedSpace(),
|
ClientID: "test-client-id",
|
||||||
ChatID: "test-chat",
|
UserID: "test-user-123",
|
||||||
AssistantID: "test-assistant",
|
TeamID: "test-team-456",
|
||||||
Locale: "en-us",
|
TenantID: "test-tenant-789",
|
||||||
Theme: "light",
|
|
||||||
Client: agentContext.Client{
|
|
||||||
Type: "web",
|
|
||||||
UserAgent: "TestAgent/1.0",
|
|
||||||
IP: "127.0.0.1",
|
|
||||||
},
|
|
||||||
Referer: agentContext.RefererAPI,
|
|
||||||
Accept: agentContext.AcceptWebCUI,
|
|
||||||
Route: "",
|
|
||||||
Metadata: make(map[string]interface{}),
|
|
||||||
Capabilities: capabilities,
|
|
||||||
Authorized: &types.AuthorizedInfo{
|
|
||||||
Subject: "test-user",
|
|
||||||
ClientID: "test-client-id",
|
|
||||||
UserID: "test-user-123",
|
|
||||||
TeamID: "test-team-456",
|
|
||||||
TenantID: "test-tenant-789",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
ctx := agentContext.New(stdContext.Background(), authorized, "test-chat")
|
||||||
|
ctx.AssistantID = "test-assistant"
|
||||||
|
ctx.Locale = "en-us"
|
||||||
|
ctx.Theme = "light"
|
||||||
|
ctx.Client = agentContext.Client{
|
||||||
|
Type: "web",
|
||||||
|
UserAgent: "TestAgent/1.0",
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
}
|
||||||
|
ctx.Referer = agentContext.RefererAPI
|
||||||
|
ctx.Accept = agentContext.AcceptWebCUI
|
||||||
|
ctx.Route = ""
|
||||||
|
ctx.Metadata = make(map[string]interface{})
|
||||||
|
ctx.Capabilities = capabilities
|
||||||
|
return ctx
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestImageHandler_CanHandle(t *testing.T) {
|
func TestImageHandler_CanHandle(t *testing.T) {
|
||||||
|
|
|
||||||
|
|
@ -49,15 +49,15 @@ func CallAgent(ctx *agentContext.Context, agentID string, message agentContext.M
|
||||||
}
|
}
|
||||||
|
|
||||||
// CallAgentWithFileInfo calls an agent to process content with file metadata
|
// CallAgentWithFileInfo calls an agent to process content with file metadata
|
||||||
// The file metadata is passed via ctx.Space for access by hooks (especially Next hook)
|
// The file metadata is passed via ctx.Memory.Context for access by hooks (especially Next hook)
|
||||||
// Uses Space instead of Metadata to avoid creating context copies and ensure proper cleanup
|
// Uses Memory.Context (request-scoped) to avoid creating context copies and ensure proper cleanup
|
||||||
//
|
//
|
||||||
// Space Keys (with agent ID as namespace prefix to avoid conflicts between different agents):
|
// Memory Keys (with agent ID as namespace prefix to avoid conflicts between different agents):
|
||||||
// - {agentID}:files_info - List of all files being processed by this agent (array)
|
// - {agentID}:files_info - List of all files being processed by this agent (array)
|
||||||
// - {agentID}:current_file - Currently processing file (single object)
|
// - {agentID}:current_file - Currently processing file (single object)
|
||||||
func CallAgentWithFileInfo(ctx *agentContext.Context, agentID string, message agentContext.Message, info *Info) (string, error) {
|
func CallAgentWithFileInfo(ctx *agentContext.Context, agentID string, message agentContext.Message, info *Info) (string, error) {
|
||||||
// Store file information in Space if available
|
// Store file information in Memory.Context if available
|
||||||
if info != nil && ctx.Space != nil {
|
if info != nil && ctx.Memory != nil && ctx.Memory.Context != nil {
|
||||||
fileInfo := map[string]interface{}{
|
fileInfo := map[string]interface{}{
|
||||||
"url": info.URL,
|
"url": info.URL,
|
||||||
"filename": info.Filename,
|
"filename": info.Filename,
|
||||||
|
|
@ -74,14 +74,14 @@ func CallAgentWithFileInfo(ctx *agentContext.Context, agentID string, message ag
|
||||||
fileInfo["file_id"] = info.FileID
|
fileInfo["file_id"] = info.FileID
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use agent ID as namespace prefix for Space keys
|
// Use agent ID as namespace prefix for Memory keys
|
||||||
filesListKey := agentID + ":files_info"
|
filesListKey := agentID + ":files_info"
|
||||||
currentFileKey := agentID + ":current_file"
|
currentFileKey := agentID + ":current_file"
|
||||||
|
|
||||||
// Thread-safe: append current file to files list
|
// Thread-safe: append current file to files list
|
||||||
fileInfoMutex.Lock()
|
fileInfoMutex.Lock()
|
||||||
var filesList []map[string]interface{}
|
var filesList []map[string]interface{}
|
||||||
if existing, err := ctx.Space.Get(filesListKey); err == nil {
|
if existing, ok := ctx.Memory.Context.Get(filesListKey); ok {
|
||||||
// Convert existing data to []map[string]interface{}
|
// Convert existing data to []map[string]interface{}
|
||||||
if existingList, ok := existing.([]interface{}); ok {
|
if existingList, ok := existing.([]interface{}); ok {
|
||||||
for _, item := range existingList {
|
for _, item := range existingList {
|
||||||
|
|
@ -95,23 +95,23 @@ func CallAgentWithFileInfo(ctx *agentContext.Context, agentID string, message ag
|
||||||
}
|
}
|
||||||
// Append current file to list
|
// Append current file to list
|
||||||
filesList = append(filesList, fileInfo)
|
filesList = append(filesList, fileInfo)
|
||||||
ctx.Space.Set(filesListKey, filesList)
|
ctx.Memory.Context.Set(filesListKey, filesList, 0)
|
||||||
fileInfoMutex.Unlock()
|
fileInfoMutex.Unlock()
|
||||||
|
|
||||||
// Store current file in Space
|
// Store current file in Memory.Context
|
||||||
if err := ctx.Space.Set(currentFileKey, fileInfo); err != nil {
|
if err := ctx.Memory.Context.Set(currentFileKey, fileInfo, 0); err != nil {
|
||||||
log.Trace("[Content] Failed to set current file info in Space: %v", err)
|
log.Trace("[Content] Failed to set current file info in Memory.Context: %v", err)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Ensure cleanup after agent call completes
|
// Ensure cleanup after agent call completes
|
||||||
defer func() {
|
defer func() {
|
||||||
// Clean up current file
|
// Clean up current file
|
||||||
if err := ctx.Space.Delete(currentFileKey); err != nil {
|
if err := ctx.Memory.Context.Del(currentFileKey); err != nil {
|
||||||
log.Trace("[Content] Failed to delete current file info from Space: %v", err)
|
log.Trace("[Content] Failed to delete current file info from Memory.Context: %v", err)
|
||||||
}
|
}
|
||||||
// Clean up files list (reset for next call)
|
// Clean up files list (reset for next call)
|
||||||
if err := ctx.Space.Delete(filesListKey); err != nil {
|
if err := ctx.Memory.Context.Del(filesListKey); err != nil {
|
||||||
log.Trace("[Content] Failed to delete files list from Space: %v", err)
|
log.Trace("[Content] Failed to delete files list from Memory.Context: %v", err)
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,7 +35,7 @@ interface Context {
|
||||||
authorized: Record<string, any>; // Authorization data (empty object if not set)
|
authorized: Record<string, any>; // Authorization data (empty object if not set)
|
||||||
|
|
||||||
// Objects
|
// Objects
|
||||||
space: Space; // Shared data space for passing data between requests
|
memory: Memory; // Agent memory with four namespaces: user, team, chat, context
|
||||||
trace: Trace; // Trace object for debugging and monitoring
|
trace: Trace; // Trace object for debugging and monitoring
|
||||||
mcp: MCP; // MCP object for external tool/resource access
|
mcp: MCP; // MCP object for external tool/resource access
|
||||||
}
|
}
|
||||||
|
|
@ -1210,7 +1210,7 @@ Releases trace resources.
|
||||||
|
|
||||||
Trace spaces are visual containers for organizing trace nodes in the frontend UI. They help group related operations together for better presentation to users.
|
Trace spaces are visual containers for organizing trace nodes in the frontend UI. They help group related operations together for better presentation to users.
|
||||||
|
|
||||||
> **Note:** Trace spaces are purely for visual organization and presentation. They do not store data - use `ctx.space` for data storage between hooks.
|
> **Note:** Trace spaces are purely for visual organization and presentation. They do not store data - use `ctx.memory` for data storage between hooks.
|
||||||
|
|
||||||
#### `ctx.trace.CreateSpace(option)`
|
#### `ctx.trace.CreateSpace(option)`
|
||||||
|
|
||||||
|
|
@ -1248,138 +1248,372 @@ Retrieves a trace space by ID.
|
||||||
const search_space = ctx.trace.GetSpace("search-space-id");
|
const search_space = ctx.trace.GetSpace("search-space-id");
|
||||||
```
|
```
|
||||||
|
|
||||||
## Space API
|
## Memory API
|
||||||
|
|
||||||
The `ctx.space` object provides a shared data space for passing data between requests and agent calls. This is useful for storing temporary data that needs to be accessed across different hooks or nested agent calls.
|
The `ctx.memory` object provides a four-level hierarchical memory system for agent state management. Each level has different persistence and scope characteristics.
|
||||||
|
|
||||||
### Methods Summary
|
### Memory Namespaces
|
||||||
|
|
||||||
| Method | Description |
|
| Namespace | Scope | Persistence | Use Case |
|
||||||
| ----------------- | ------------------------------------- |
|
| -------------------- | ------------------- | ----------- | ------------------------------------------- |
|
||||||
| `Get(key)` | Get a value from the space |
|
| `ctx.memory.user` | Per user | Persistent | User preferences, settings, long-term state |
|
||||||
| `Set(key, value)` | Set a value in the space |
|
| `ctx.memory.team` | Per team | Persistent | Team-wide settings, shared configurations |
|
||||||
| `Delete(key)` | Delete a key from the space |
|
| `ctx.memory.chat` | Per chat session | Persistent | Chat-specific context, conversation state |
|
||||||
| `GetDel(key)` | Get a value and immediately delete it |
|
| `ctx.memory.context` | Per request context | Temporary | Request-scoped data, cleared on release |
|
||||||
|
|
||||||
### Methods
|
### Namespace Interface
|
||||||
|
|
||||||
#### `ctx.space.Get(key): any`
|
Each namespace (`user`, `team`, `chat`, `context`) provides the same interface:
|
||||||
|
|
||||||
Gets a value from the space.
|
```typescript
|
||||||
|
interface MemoryNamespace {
|
||||||
|
// Basic KV operations
|
||||||
|
Get(key: string): any; // Get a value
|
||||||
|
Set(key: string, value: any, ttl?: number): void; // Set a value with optional TTL (seconds)
|
||||||
|
Del(key: string): void; // Delete a key (supports wildcards: "prefix:*")
|
||||||
|
Has(key: string): boolean; // Check if key exists
|
||||||
|
GetDel(key: string): any; // Get and delete atomically
|
||||||
|
|
||||||
**Parameters:**
|
// Collection operations
|
||||||
|
Keys(): string[]; // Get all keys
|
||||||
|
Len(): number; // Get number of keys
|
||||||
|
Clear(): void; // Delete all keys
|
||||||
|
|
||||||
- `key`: String - The key to retrieve
|
// Atomic counter operations
|
||||||
|
Incr(key: string, delta?: number): number; // Increment (default delta=1)
|
||||||
|
Decr(key: string, delta?: number): number; // Decrement (default delta=1)
|
||||||
|
|
||||||
**Returns:**
|
// List operations
|
||||||
|
Push(key: string, values: any[]): number; // Append to list, returns new length
|
||||||
|
Pop(key: string): any; // Remove and return last element
|
||||||
|
Pull(key: string, count: number): any[]; // Remove and return last N elements
|
||||||
|
PullAll(key: string): any[]; // Remove and return all elements
|
||||||
|
AddToSet(key: string, values: any[]): number; // Add unique values to set
|
||||||
|
|
||||||
- `any`: The value, or `null` if not found
|
// Array access operations
|
||||||
|
ArrayLen(key: string): number; // Get array length
|
||||||
|
ArrayGet(key: string, index: number): any; // Get element at index
|
||||||
|
ArraySet(key: string, index: number, value: any): void; // Set element at index
|
||||||
|
ArraySlice(key: string, start: number, end: number): any[]; // Get slice
|
||||||
|
ArrayPage(key: string, page: number, size: number): any[]; // Paginated access
|
||||||
|
ArrayAll(key: string): any[]; // Get all elements
|
||||||
|
|
||||||
**Example:**
|
// Metadata
|
||||||
|
id: string; // Namespace ID
|
||||||
```javascript
|
space: string; // Space type: "user", "team", "chat", or "context"
|
||||||
const user_data = ctx.space.Get("user_data");
|
|
||||||
if (user_data) {
|
|
||||||
console.log("Found user:", user_data.name);
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `ctx.space.Set(key, value): void`
|
### Basic KV Operations
|
||||||
|
|
||||||
Sets a value in the space.
|
#### `Get(key): any`
|
||||||
|
|
||||||
**Parameters:**
|
Gets a value from the namespace.
|
||||||
|
|
||||||
- `key`: String - The key to set
|
|
||||||
- `value`: Any - The value to store
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
ctx.space.Set("user_data", { name: "John", id: 123 });
|
// User preferences
|
||||||
ctx.space.Set("processing_status", "started");
|
const theme = ctx.memory.user.Get("theme");
|
||||||
|
if (theme) {
|
||||||
|
console.log("User prefers:", theme);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Chat context
|
||||||
|
const topic = ctx.memory.chat.Get("current_topic");
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `ctx.space.Delete(key): void`
|
#### `Set(key, value, ttl?): void`
|
||||||
|
|
||||||
Deletes a key from the space.
|
Sets a value with optional TTL (time-to-live in seconds).
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
|
|
||||||
- `key`: String - The key to delete
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
ctx.space.Delete("temp_data");
|
// Persistent user setting
|
||||||
|
ctx.memory.user.Set("language", "en");
|
||||||
|
|
||||||
|
// Team configuration
|
||||||
|
ctx.memory.team.Set("api_key", "sk-xxx");
|
||||||
|
|
||||||
|
// Chat state
|
||||||
|
ctx.memory.chat.Set("last_query", "What is AI?");
|
||||||
|
|
||||||
|
// Temporary context data with 5 minute TTL
|
||||||
|
ctx.memory.context.Set("temp_result", { data: "..." }, 300);
|
||||||
```
|
```
|
||||||
|
|
||||||
#### `ctx.space.GetDel(key): any`
|
#### `Del(key): void`
|
||||||
|
|
||||||
Gets a value and immediately deletes it. Convenient for one-time use data.
|
Deletes a key. Supports wildcard patterns with `*`.
|
||||||
|
|
||||||
**Parameters:**
|
|
||||||
|
|
||||||
- `key`: String - The key to retrieve and delete
|
|
||||||
|
|
||||||
**Returns:**
|
|
||||||
|
|
||||||
- `any`: The value, or `null` if not found
|
|
||||||
|
|
||||||
**Example:**
|
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Store file metadata in parent agent
|
// Delete single key
|
||||||
ctx.space.Set("file_metadata", { name: "report.pdf", size: 1024 });
|
ctx.memory.user.Del("old_setting");
|
||||||
|
|
||||||
// In child agent, get and consume the data
|
// Delete with wildcard pattern
|
||||||
const metadata = ctx.space.GetDel("file_metadata");
|
ctx.memory.chat.Del("cache:*"); // Deletes all keys starting with "cache:"
|
||||||
// metadata is now deleted from space
|
```
|
||||||
|
|
||||||
|
#### `Has(key): boolean`
|
||||||
|
|
||||||
|
Checks if a key exists.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
if (ctx.memory.user.Has("onboarding_complete")) {
|
||||||
|
// Skip onboarding
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `GetDel(key): any`
|
||||||
|
|
||||||
|
Atomically gets and deletes a value. Useful for one-time tokens.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const token = ctx.memory.context.GetDel("one_time_token");
|
||||||
|
if (token) {
|
||||||
|
// Use token (it's now deleted)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Collection Operations
|
||||||
|
|
||||||
|
#### `Keys(): string[]`
|
||||||
|
|
||||||
|
Returns all keys in the namespace.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const userKeys = ctx.memory.user.Keys();
|
||||||
|
console.log("User has", userKeys.length, "stored values");
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Len(): number`
|
||||||
|
|
||||||
|
Returns the number of keys.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const count = ctx.memory.chat.Len();
|
||||||
|
console.log("Chat has", count, "stored values");
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Clear(): void`
|
||||||
|
|
||||||
|
Deletes all keys in the namespace.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Clear temporary context data
|
||||||
|
ctx.memory.context.Clear();
|
||||||
|
```
|
||||||
|
|
||||||
|
### Atomic Counter Operations
|
||||||
|
|
||||||
|
#### `Incr(key, delta?): number`
|
||||||
|
|
||||||
|
Atomically increments a counter. Returns the new value.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
// Simple counter
|
||||||
|
const views = ctx.memory.user.Incr("page_views");
|
||||||
|
console.log("Total views:", views);
|
||||||
|
|
||||||
|
// Increment by custom amount
|
||||||
|
const points = ctx.memory.user.Incr("points", 10);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Decr(key, delta?): number`
|
||||||
|
|
||||||
|
Atomically decrements a counter. Returns the new value.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const remaining = ctx.memory.user.Decr("credits");
|
||||||
|
if (remaining < 0) {
|
||||||
|
throw new Error("Insufficient credits");
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### List Operations
|
||||||
|
|
||||||
|
#### `Push(key, values): number`
|
||||||
|
|
||||||
|
Appends values to a list. Returns new length.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const len = ctx.memory.chat.Push("history", [
|
||||||
|
{ role: "user", content: "Hello" },
|
||||||
|
{ role: "assistant", content: "Hi there!" },
|
||||||
|
]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Pop(key): any`
|
||||||
|
|
||||||
|
Removes and returns the last element.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const lastItem = ctx.memory.chat.Pop("pending_tasks");
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `Pull(key, count): any[]`
|
||||||
|
|
||||||
|
Removes and returns the last N elements.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const recentItems = ctx.memory.chat.Pull("notifications", 5);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `PullAll(key): any[]`
|
||||||
|
|
||||||
|
Removes and returns all elements.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const allTasks = ctx.memory.context.PullAll("batch_queue");
|
||||||
|
// Process all tasks, queue is now empty
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `AddToSet(key, values): number`
|
||||||
|
|
||||||
|
Adds unique values to a set (no duplicates). Returns new size.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
ctx.memory.user.AddToSet("visited_pages", ["/home", "/about"]);
|
||||||
|
ctx.memory.user.AddToSet("visited_pages", ["/home", "/contact"]); // "/home" not added again
|
||||||
|
```
|
||||||
|
|
||||||
|
### Array Access Operations
|
||||||
|
|
||||||
|
#### `ArrayLen(key): number`
|
||||||
|
|
||||||
|
Gets the length of an array.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const historyLen = ctx.memory.chat.ArrayLen("messages");
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `ArrayGet(key, index): any`
|
||||||
|
|
||||||
|
Gets an element at a specific index.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const firstMessage = ctx.memory.chat.ArrayGet("messages", 0);
|
||||||
|
const lastMessage = ctx.memory.chat.ArrayGet("messages", -1); // Negative index
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `ArraySet(key, index, value): void`
|
||||||
|
|
||||||
|
Sets an element at a specific index.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
ctx.memory.chat.ArraySet("messages", 0, { role: "system", content: "Updated" });
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `ArraySlice(key, start, end): any[]`
|
||||||
|
|
||||||
|
Gets a slice of the array.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const recent = ctx.memory.chat.ArraySlice("messages", -10, -1); // Last 10 messages
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `ArrayPage(key, page, size): any[]`
|
||||||
|
|
||||||
|
Gets a page of elements (1-indexed pages).
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const page1 = ctx.memory.chat.ArrayPage("messages", 1, 20); // First 20 messages
|
||||||
|
const page2 = ctx.memory.chat.ArrayPage("messages", 2, 20); // Next 20 messages
|
||||||
|
```
|
||||||
|
|
||||||
|
#### `ArrayAll(key): any[]`
|
||||||
|
|
||||||
|
Gets all elements of the array.
|
||||||
|
|
||||||
|
```javascript
|
||||||
|
const allMessages = ctx.memory.chat.ArrayAll("messages");
|
||||||
```
|
```
|
||||||
|
|
||||||
### Use Cases
|
### Use Cases
|
||||||
|
|
||||||
```javascript
|
```javascript
|
||||||
// Use case 1: Pass data between hooks
|
// Use case 1: User preferences (persistent across sessions)
|
||||||
function Create(ctx, messages) {
|
function Create(ctx, messages) {
|
||||||
// Store data for later use
|
// Load user preferences
|
||||||
ctx.space.Set("original_query", messages[0].content);
|
const locale = ctx.memory.user.Get("preferred_locale") || "en";
|
||||||
|
const style = ctx.memory.user.Get("response_style") || "concise";
|
||||||
|
|
||||||
|
return {
|
||||||
|
messages,
|
||||||
|
locale: locale,
|
||||||
|
metadata: { style: style },
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use case 2: Chat context (persistent within chat session)
|
||||||
|
function Next(ctx, payload) {
|
||||||
|
// Track conversation topics
|
||||||
|
const topics = ctx.memory.chat.Get("discussed_topics") || [];
|
||||||
|
const newTopic = extractTopic(payload.completion.content);
|
||||||
|
|
||||||
|
if (newTopic && !topics.includes(newTopic)) {
|
||||||
|
topics.push(newTopic);
|
||||||
|
ctx.memory.chat.Set("discussed_topics", topics);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use case 3: Request-scoped data (cleared on context release)
|
||||||
|
function Create(ctx, messages) {
|
||||||
|
// Store temporary processing data
|
||||||
|
ctx.memory.context.Set("request_start", Date.now());
|
||||||
|
ctx.memory.context.Set("original_query", messages[0]?.content);
|
||||||
|
|
||||||
return { messages };
|
return { messages };
|
||||||
}
|
}
|
||||||
|
|
||||||
function Next(ctx, payload) {
|
function Next(ctx, payload) {
|
||||||
// Retrieve data from Create hook
|
// Retrieve temporary data
|
||||||
const query = ctx.space.Get("original_query");
|
const startTime = ctx.memory.context.Get("request_start");
|
||||||
console.log("Original query was:", query);
|
const duration = Date.now() - startTime;
|
||||||
|
console.log("Request took", duration, "ms");
|
||||||
|
|
||||||
|
// context memory is automatically cleared when ctx.Release() is called
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use case 2: Pass data to nested agent calls
|
// Use case 4: Team-wide settings
|
||||||
function Create(ctx, messages) {
|
function Create(ctx, messages) {
|
||||||
// Prepare context for child agent
|
// Check team quota
|
||||||
ctx.space.Set("parent_context", {
|
const used = ctx.memory.team.Incr("monthly_requests");
|
||||||
user_id: ctx.authorized.user_id,
|
const limit = ctx.memory.team.Get("monthly_limit") || 10000;
|
||||||
session_start: Date.now(),
|
|
||||||
});
|
|
||||||
|
|
||||||
// Call child agent...
|
if (used > limit) {
|
||||||
|
throw new Error("Team quota exceeded");
|
||||||
|
}
|
||||||
|
|
||||||
|
return { messages };
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use case 3: One-time data consumption
|
// Use case 5: Rate limiting with counters
|
||||||
function Next(ctx, payload) {
|
function Create(ctx, messages) {
|
||||||
// Get and delete in one operation
|
const key = `rate:${new Date().toISOString().slice(0, 13)}`; // Hourly bucket
|
||||||
const temp_data = ctx.space.GetDel("temp_processing_data");
|
const count = ctx.memory.user.Incr(key);
|
||||||
if (temp_data) {
|
|
||||||
// Process and discard
|
if (count > 100) {
|
||||||
|
throw new Error("Rate limit exceeded");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
return { messages };
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Memory Lifecycle
|
||||||
|
|
||||||
|
| Namespace | Created When | Cleared When |
|
||||||
|
| --------- | ---------------- | --------------- |
|
||||||
|
| `user` | First access | Manual only |
|
||||||
|
| `team` | First access | Manual only |
|
||||||
|
| `chat` | First access | Manual only |
|
||||||
|
| `context` | Context creation | `ctx.Release()` |
|
||||||
|
|
||||||
**Notes:**
|
**Notes:**
|
||||||
|
|
||||||
- Space is shared across all hooks within the same request
|
- `user`, `team`, `chat` namespaces are persistent (backed by database)
|
||||||
- Space persists across nested agent calls (A2A)
|
- `context` namespace is temporary and cleared when the request context is released
|
||||||
- Values can be any JSON-serializable data
|
- All namespaces support TTL for automatic expiration
|
||||||
- Use `GetDel` for data that should only be consumed once
|
- Wildcard deletion (`Del("prefix:*")`) works on all namespaces
|
||||||
|
- Counter operations (`Incr`, `Decr`) are atomic
|
||||||
|
|
||||||
## MCP API
|
## MCP API
|
||||||
|
|
||||||
|
|
@ -1697,7 +1931,7 @@ interface UsesConfig {
|
||||||
```javascript
|
```javascript
|
||||||
function Create(ctx, messages) {
|
function Create(ctx, messages) {
|
||||||
// Store data for Next hook
|
// Store data for Next hook
|
||||||
ctx.space.Set("user_query", messages[0]?.content);
|
ctx.memory.context.Set("user_query", messages[0]?.content);
|
||||||
|
|
||||||
// Modify messages
|
// Modify messages
|
||||||
const enhanced_messages = messages.map((msg) => ({
|
const enhanced_messages = messages.map((msg) => ({
|
||||||
|
|
@ -1873,7 +2107,7 @@ See the [Agent Execution Lifecycle](#agent-execution-lifecycle) diagram above fo
|
||||||
- **Hooks can send messages directly** via `ctx.Send()`, `ctx.SendStream()`, etc.
|
- **Hooks can send messages directly** via `ctx.Send()`, `ctx.SendStream()`, etc.
|
||||||
- **Create Hook** runs before LLM call (if any), can modify messages and configure the request
|
- **Create Hook** runs before LLM call (if any), can modify messages and configure the request
|
||||||
- **Next Hook** runs after LLM call and tool execution (if any), can post-process or delegate
|
- **Next Hook** runs after LLM call and tool execution (if any), can post-process or delegate
|
||||||
- Use `ctx.space` to pass data between Create and Next hooks
|
- Use `ctx.memory.context` to pass data between Create and Next hooks within a request
|
||||||
|
|
||||||
## Complete Example
|
## Complete Example
|
||||||
|
|
||||||
|
|
@ -1891,9 +2125,9 @@ function Create(ctx, messages) {
|
||||||
// Extract user query from the last message
|
// Extract user query from the last message
|
||||||
const user_query = messages[messages.length - 1]?.content || "";
|
const user_query = messages[messages.length - 1]?.content || "";
|
||||||
|
|
||||||
// Store data in space for use in Next hook
|
// Store data in context memory for use in Next hook
|
||||||
ctx.space.Set("original_query", user_query);
|
ctx.memory.context.Set("original_query", user_query);
|
||||||
ctx.space.Set("request_time", Date.now());
|
ctx.memory.context.Set("request_time", Date.now());
|
||||||
|
|
||||||
// Add trace node to show processing in UI
|
// Add trace node to show processing in UI
|
||||||
const create_node = ctx.trace.Add(
|
const create_node = ctx.trace.Add(
|
||||||
|
|
@ -1943,9 +2177,9 @@ function Create(ctx, messages) {
|
||||||
function Next(ctx, payload) {
|
function Next(ctx, payload) {
|
||||||
const { messages, completion, tools, error } = payload;
|
const { messages, completion, tools, error } = payload;
|
||||||
|
|
||||||
// Retrieve data from Create hook via space
|
// Retrieve data from Create hook via context memory
|
||||||
const original_query = ctx.space.Get("original_query");
|
const original_query = ctx.memory.context.Get("original_query");
|
||||||
const request_time = ctx.space.Get("request_time");
|
const request_time = ctx.memory.context.Get("request_time");
|
||||||
const duration = Date.now() - request_time;
|
const duration = Date.now() - request_time;
|
||||||
|
|
||||||
// Create trace node for Next hook processing
|
// Create trace node for Next hook processing
|
||||||
|
|
@ -2038,7 +2272,7 @@ function Next(ctx, payload) {
|
||||||
4. **Logging Levels**: Use appropriate log levels (Debug for development, Info for progress, Error for failures)
|
4. **Logging Levels**: Use appropriate log levels (Debug for development, Info for progress, Error for failures)
|
||||||
5. **Message IDs**: Let the system auto-generate message IDs unless you need specific tracking
|
5. **Message IDs**: Let the system auto-generate message IDs unless you need specific tracking
|
||||||
6. **Parallel Operations**: Use `Trace.Parallel()` for concurrent operations to maintain trace clarity
|
6. **Parallel Operations**: Use `Trace.Parallel()` for concurrent operations to maintain trace clarity
|
||||||
7. **Space Usage**: Use `ctx.space` for passing data between hooks and nested agent calls
|
7. **Memory Usage**: Use `ctx.memory.context` for request-scoped data, `ctx.memory.chat` for chat state, `ctx.memory.user` for user preferences
|
||||||
8. **Streaming Messages**: Use `SendStream()` + `Append()` + `End()` for streaming output; use `Send()` for complete messages
|
8. **Streaming Messages**: Use `SendStream()` + `Append()` + `End()` for streaming output; use `Send()` for complete messages
|
||||||
9. **Block Grouping**: Only use Block IDs when you need to group multiple messages together (e.g., LLM output + follow-up card)
|
9. **Block Grouping**: Only use Block IDs when you need to group multiple messages together (e.g., LLM output + follow-up card)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/plan"
|
"github.com/yaoapp/yao/agent/memory"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
|
@ -27,11 +27,21 @@ func New(parent context.Context, authorized *types.AuthorizedInfo, chatID string
|
||||||
|
|
||||||
contextID := generateContextID()
|
contextID := generateContextID()
|
||||||
|
|
||||||
|
// Extract user and team IDs from authorized info
|
||||||
|
var userID, teamID string
|
||||||
|
if authorized != nil {
|
||||||
|
userID = authorized.UserID
|
||||||
|
teamID = authorized.TeamID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create memory instance using global manager
|
||||||
|
mem, _ := memory.GetMemory(userID, teamID, chatID, contextID)
|
||||||
|
|
||||||
ctx := &Context{
|
ctx := &Context{
|
||||||
Context: parent,
|
Context: parent,
|
||||||
ID: contextID, // Generate unique ID for the context
|
ID: contextID, // Generate unique ID for the context
|
||||||
Authorized: authorized, // Set authorized info
|
Authorized: authorized, // Set authorized info
|
||||||
Space: plan.NewMemorySharedSpace(),
|
Memory: mem,
|
||||||
ChatID: chatID,
|
ChatID: chatID,
|
||||||
IDGenerator: message.NewIDGenerator(), // Initialize ID generator for this context
|
IDGenerator: message.NewIDGenerator(), // Initialize ID generator for this context
|
||||||
messageMetadata: newMessageMetadataStore(), // Initialize message metadata store
|
messageMetadata: newMessageMetadataStore(), // Initialize message metadata store
|
||||||
|
|
@ -78,14 +88,15 @@ func (ctx *Context) Release() {
|
||||||
ctx.trace = nil
|
ctx.trace = nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Clear space
|
// Clear context-level memory only (request-scoped temporary data)
|
||||||
if ctx.Space != nil {
|
// User, Team, Chat level memory is persistent and should NOT be cleared
|
||||||
|
if ctx.Memory != nil && ctx.Memory.Context != nil {
|
||||||
if ctx.Logger != nil {
|
if ctx.Logger != nil {
|
||||||
ctx.Logger.Cleanup("Space")
|
ctx.Logger.Cleanup("Memory.Context")
|
||||||
}
|
}
|
||||||
ctx.Space.Clear()
|
ctx.Memory.Context.Clear()
|
||||||
ctx.Space = nil
|
|
||||||
}
|
}
|
||||||
|
ctx.Memory = nil
|
||||||
|
|
||||||
// Clear stacks
|
// Clear stacks
|
||||||
if ctx.Stacks != nil {
|
if ctx.Stacks != nil {
|
||||||
|
|
@ -379,9 +390,9 @@ func (ctx *Context) BeginStep(stepType string, input map[string]interface{}) *Bu
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Update space snapshot before starting step
|
// Update context memory snapshot before starting step (for recovery)
|
||||||
if ctx.Space != nil {
|
if ctx.Memory != nil && ctx.Memory.Context != nil {
|
||||||
ctx.Buffer.SetSpaceSnapshot(ctx.Space.Snapshot())
|
ctx.Buffer.SetSpaceSnapshot(ctx.Memory.Context.Snapshot())
|
||||||
}
|
}
|
||||||
|
|
||||||
return ctx.Buffer.BeginStep(stepType, input, ctx.Stack)
|
return ctx.Buffer.BeginStep(stepType, input, ctx.Stack)
|
||||||
|
|
|
||||||
|
|
@ -211,7 +211,7 @@ func TestGetCompletionRequest(t *testing.T) {
|
||||||
assert.Equal(t, tt.expectedReferer, ctx.Referer)
|
assert.Equal(t, tt.expectedReferer, ctx.Referer)
|
||||||
assert.Equal(t, tt.expectedAccept, ctx.Accept)
|
assert.Equal(t, tt.expectedAccept, ctx.Accept)
|
||||||
assert.Equal(t, tt.expectedAssistantID, ctx.AssistantID)
|
assert.Equal(t, tt.expectedAssistantID, ctx.AssistantID)
|
||||||
assert.NotNil(t, ctx.Space)
|
assert.NotNil(t, ctx.Memory)
|
||||||
assert.NotNil(t, ctx.Cache)
|
assert.NotNil(t, ctx.Cache)
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -227,7 +227,7 @@ func TestContextNew_WithAuthorized(t *testing.T) {
|
||||||
|
|
||||||
assert.NotNil(t, ctx)
|
assert.NotNil(t, ctx)
|
||||||
assert.Equal(t, "test-chat-id", ctx.ChatID)
|
assert.Equal(t, "test-chat-id", ctx.ChatID)
|
||||||
assert.NotNil(t, ctx.Space)
|
assert.NotNil(t, ctx.Memory)
|
||||||
assert.NotNil(t, ctx.IDGenerator)
|
assert.NotNil(t, ctx.IDGenerator)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,10 @@
|
||||||
package context
|
package context
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/runtime/v8/bridge"
|
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||||
|
"github.com/yaoapp/yao/agent/memory"
|
||||||
"github.com/yaoapp/yao/agent/output"
|
"github.com/yaoapp/yao/agent/output"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
traceJsapi "github.com/yaoapp/yao/trace/jsapi"
|
traceJsapi "github.com/yaoapp/yao/trace/jsapi"
|
||||||
|
|
@ -136,11 +139,11 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Space object - create a JavaScript object with Get/Set/Delete methods
|
// Memory object - create a JavaScript object with User/Team/Chat/Context namespaces
|
||||||
if ctx.Space != nil {
|
if ctx.Memory != nil {
|
||||||
spaceObj := ctx.createSpaceObject(v8ctx)
|
memoryObj := ctx.createMemoryObject(v8ctx)
|
||||||
obj.Set("space", spaceObj)
|
obj.Set("memory", memoryObj)
|
||||||
spaceObj.Release()
|
memoryObj.Release()
|
||||||
}
|
}
|
||||||
|
|
||||||
return instance.Value, nil
|
return instance.Value, nil
|
||||||
|
|
@ -718,26 +721,57 @@ func (ctx *Context) endBlockMethod(iso *v8go.Isolate) *v8go.FunctionTemplate {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
// createSpaceObject creates a Space object for JavaScript access
|
// createMemoryObject creates a Memory object for JavaScript access
|
||||||
// Space is a shared data space for passing data between requests and calls
|
// Memory provides four namespaces: User, Team, Chat, Context
|
||||||
func (ctx *Context) createSpaceObject(v8ctx *v8go.Context) *v8go.Value {
|
// Each namespace supports: Get, Set, Del, Has, Keys, Len, Clear, Incr, Decr
|
||||||
|
func (ctx *Context) createMemoryObject(v8ctx *v8go.Context) *v8go.Value {
|
||||||
iso := v8ctx.Isolate()
|
iso := v8ctx.Isolate()
|
||||||
spaceObj, _ := v8ctx.RunScript("({})", "space-init")
|
objTpl := v8go.NewObjectTemplate(iso)
|
||||||
obj, _ := spaceObj.AsObject()
|
obj, _ := objTpl.NewInstance(v8ctx)
|
||||||
|
|
||||||
// Get method: space.Get(key)
|
// Create namespace accessors
|
||||||
|
if ctx.Memory.User != nil {
|
||||||
|
userObj := ctx.createNamespaceObject(v8ctx, ctx.Memory.User)
|
||||||
|
obj.Set("user", userObj)
|
||||||
|
userObj.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Memory.Team != nil {
|
||||||
|
teamObj := ctx.createNamespaceObject(v8ctx, ctx.Memory.Team)
|
||||||
|
obj.Set("team", teamObj)
|
||||||
|
teamObj.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Memory.Chat != nil {
|
||||||
|
chatObj := ctx.createNamespaceObject(v8ctx, ctx.Memory.Chat)
|
||||||
|
obj.Set("chat", chatObj)
|
||||||
|
chatObj.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
if ctx.Memory.Context != nil {
|
||||||
|
contextObj := ctx.createNamespaceObject(v8ctx, ctx.Memory.Context)
|
||||||
|
obj.Set("context", contextObj)
|
||||||
|
contextObj.Release()
|
||||||
|
}
|
||||||
|
|
||||||
|
return obj.Value
|
||||||
|
}
|
||||||
|
|
||||||
|
// createNamespaceObject creates a namespace object with KV store methods
|
||||||
|
func (ctx *Context) createNamespaceObject(v8ctx *v8go.Context, ns *memory.Namespace) *v8go.Value {
|
||||||
|
iso := v8ctx.Isolate()
|
||||||
|
objTpl := v8go.NewObjectTemplate(iso)
|
||||||
|
obj, _ := objTpl.NewInstance(v8ctx)
|
||||||
|
|
||||||
|
// Get method: ns.Get(key)
|
||||||
getFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
getFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
if ctx.Space == nil {
|
|
||||||
return v8go.Null(iso)
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(info.Args()) < 1 {
|
if len(info.Args()) < 1 {
|
||||||
return bridge.JsException(info.Context(), "Get requires a key argument")
|
return bridge.JsException(info.Context(), "Get requires a key argument")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := info.Args()[0].String()
|
key := info.Args()[0].String()
|
||||||
value, err := ctx.Space.Get(key)
|
value, ok := ns.Get(key)
|
||||||
if err != nil {
|
if !ok {
|
||||||
return v8go.Null(iso)
|
return v8go.Null(iso)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -751,12 +785,8 @@ func (ctx *Context) createSpaceObject(v8ctx *v8go.Context) *v8go.Value {
|
||||||
getFuncVal := getFunc.GetFunction(v8ctx)
|
getFuncVal := getFunc.GetFunction(v8ctx)
|
||||||
obj.Set("Get", getFuncVal.Value)
|
obj.Set("Get", getFuncVal.Value)
|
||||||
|
|
||||||
// Set method: space.Set(key, value)
|
// Set method: ns.Set(key, value, ttl?)
|
||||||
setFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
setFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
if ctx.Space == nil {
|
|
||||||
return bridge.JsException(info.Context(), "Space is not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(info.Args()) < 2 {
|
if len(info.Args()) < 2 {
|
||||||
return bridge.JsException(info.Context(), "Set requires key and value arguments")
|
return bridge.JsException(info.Context(), "Set requires key and value arguments")
|
||||||
}
|
}
|
||||||
|
|
@ -767,7 +797,14 @@ func (ctx *Context) createSpaceObject(v8ctx *v8go.Context) *v8go.Value {
|
||||||
return bridge.JsException(info.Context(), "Failed to convert value: "+err.Error())
|
return bridge.JsException(info.Context(), "Failed to convert value: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
if err := ctx.Space.Set(key, value); err != nil {
|
// Optional TTL in milliseconds (third argument)
|
||||||
|
var ttl time.Duration
|
||||||
|
if len(info.Args()) >= 3 && info.Args()[2].IsNumber() {
|
||||||
|
ttlMs := info.Args()[2].Integer()
|
||||||
|
ttl = time.Duration(ttlMs) * time.Millisecond
|
||||||
|
}
|
||||||
|
|
||||||
|
if err := ns.Set(key, value, ttl); err != nil {
|
||||||
return bridge.JsException(info.Context(), "Failed to set value: "+err.Error())
|
return bridge.JsException(info.Context(), "Failed to set value: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -776,50 +813,128 @@ func (ctx *Context) createSpaceObject(v8ctx *v8go.Context) *v8go.Value {
|
||||||
setFuncVal := setFunc.GetFunction(v8ctx)
|
setFuncVal := setFunc.GetFunction(v8ctx)
|
||||||
obj.Set("Set", setFuncVal.Value)
|
obj.Set("Set", setFuncVal.Value)
|
||||||
|
|
||||||
// Delete method: space.Delete(key)
|
// Del method: ns.Del(key)
|
||||||
delFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
delFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
if ctx.Space == nil {
|
|
||||||
return bridge.JsException(info.Context(), "Space is not available")
|
|
||||||
}
|
|
||||||
|
|
||||||
if len(info.Args()) < 1 {
|
if len(info.Args()) < 1 {
|
||||||
return bridge.JsException(info.Context(), "Delete requires a key argument")
|
return bridge.JsException(info.Context(), "Del requires a key argument")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := info.Args()[0].String()
|
key := info.Args()[0].String()
|
||||||
if err := ctx.Space.Delete(key); err != nil {
|
if err := ns.Del(key); err != nil {
|
||||||
return bridge.JsException(info.Context(), "Failed to delete key: "+err.Error())
|
return bridge.JsException(info.Context(), "Failed to delete key: "+err.Error())
|
||||||
}
|
}
|
||||||
|
|
||||||
return v8go.Undefined(iso)
|
return v8go.Undefined(iso)
|
||||||
})
|
})
|
||||||
delFuncVal := delFunc.GetFunction(v8ctx)
|
delFuncVal := delFunc.GetFunction(v8ctx)
|
||||||
obj.Set("Delete", delFuncVal.Value)
|
obj.Set("Del", delFuncVal.Value)
|
||||||
|
|
||||||
// GetDel method: space.GetDel(key) - Get value and delete immediately
|
// Has method: ns.Has(key)
|
||||||
// Convenient for one-time use data (e.g., file metadata passed between agents)
|
hasFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
getDelFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
if len(info.Args()) < 1 {
|
||||||
if ctx.Space == nil {
|
return bridge.JsException(info.Context(), "Has requires a key argument")
|
||||||
return v8go.Null(iso)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
key := info.Args()[0].String()
|
||||||
|
exists := ns.Has(key)
|
||||||
|
|
||||||
|
jsValue, _ := v8go.NewValue(iso, exists)
|
||||||
|
return jsValue
|
||||||
|
})
|
||||||
|
hasFuncVal := hasFunc.GetFunction(v8ctx)
|
||||||
|
obj.Set("Has", hasFuncVal.Value)
|
||||||
|
|
||||||
|
// Keys method: ns.Keys()
|
||||||
|
keysFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
keys := ns.Keys()
|
||||||
|
jsValue, err := bridge.JsValue(info.Context(), keys)
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(info.Context(), "Failed to get keys: "+err.Error())
|
||||||
|
}
|
||||||
|
return jsValue
|
||||||
|
})
|
||||||
|
keysFuncVal := keysFunc.GetFunction(v8ctx)
|
||||||
|
obj.Set("Keys", keysFuncVal.Value)
|
||||||
|
|
||||||
|
// Len method: ns.Len()
|
||||||
|
lenFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
length := ns.Len()
|
||||||
|
// Use int32 for JavaScript Number (int64 becomes BigInt which is incompatible)
|
||||||
|
jsValue, _ := v8go.NewValue(iso, int32(length))
|
||||||
|
return jsValue
|
||||||
|
})
|
||||||
|
lenFuncVal := lenFunc.GetFunction(v8ctx)
|
||||||
|
obj.Set("Len", lenFuncVal.Value)
|
||||||
|
|
||||||
|
// Clear method: ns.Clear()
|
||||||
|
clearFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
ns.Clear()
|
||||||
|
return v8go.Undefined(iso)
|
||||||
|
})
|
||||||
|
clearFuncVal := clearFunc.GetFunction(v8ctx)
|
||||||
|
obj.Set("Clear", clearFuncVal.Value)
|
||||||
|
|
||||||
|
// Incr method: ns.Incr(key, delta?)
|
||||||
|
incrFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
if len(info.Args()) < 1 {
|
||||||
|
return bridge.JsException(info.Context(), "Incr requires a key argument")
|
||||||
|
}
|
||||||
|
|
||||||
|
key := info.Args()[0].String()
|
||||||
|
delta := int64(1)
|
||||||
|
if len(info.Args()) >= 2 && info.Args()[1].IsNumber() {
|
||||||
|
delta = info.Args()[1].Integer()
|
||||||
|
}
|
||||||
|
|
||||||
|
newValue, err := ns.Incr(key, delta)
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(info.Context(), "Failed to increment: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use int32 for JavaScript Number (int64 becomes BigInt which is incompatible with ===)
|
||||||
|
// For counters, int32 range (-2^31 to 2^31-1) is sufficient
|
||||||
|
jsValue, _ := v8go.NewValue(iso, int32(newValue))
|
||||||
|
return jsValue
|
||||||
|
})
|
||||||
|
incrFuncVal := incrFunc.GetFunction(v8ctx)
|
||||||
|
obj.Set("Incr", incrFuncVal.Value)
|
||||||
|
|
||||||
|
// Decr method: ns.Decr(key, delta?)
|
||||||
|
decrFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
|
if len(info.Args()) < 1 {
|
||||||
|
return bridge.JsException(info.Context(), "Decr requires a key argument")
|
||||||
|
}
|
||||||
|
|
||||||
|
key := info.Args()[0].String()
|
||||||
|
delta := int64(1)
|
||||||
|
if len(info.Args()) >= 2 && info.Args()[1].IsNumber() {
|
||||||
|
delta = info.Args()[1].Integer()
|
||||||
|
}
|
||||||
|
|
||||||
|
newValue, err := ns.Decr(key, delta)
|
||||||
|
if err != nil {
|
||||||
|
return bridge.JsException(info.Context(), "Failed to decrement: "+err.Error())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Use int32 for JavaScript Number (int64 becomes BigInt which is incompatible with ===)
|
||||||
|
jsValue, _ := v8go.NewValue(iso, int32(newValue))
|
||||||
|
return jsValue
|
||||||
|
})
|
||||||
|
decrFuncVal := decrFunc.GetFunction(v8ctx)
|
||||||
|
obj.Set("Decr", decrFuncVal.Value)
|
||||||
|
|
||||||
|
// GetDel method: ns.GetDel(key) - Get value and delete immediately
|
||||||
|
getDelFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||||
if len(info.Args()) < 1 {
|
if len(info.Args()) < 1 {
|
||||||
return bridge.JsException(info.Context(), "GetDel requires a key argument")
|
return bridge.JsException(info.Context(), "GetDel requires a key argument")
|
||||||
}
|
}
|
||||||
|
|
||||||
key := info.Args()[0].String()
|
key := info.Args()[0].String()
|
||||||
|
value, ok := ns.GetDel(key)
|
||||||
// Get value first
|
if !ok {
|
||||||
value, err := ctx.Space.Get(key)
|
|
||||||
if err != nil {
|
|
||||||
return v8go.Null(iso)
|
return v8go.Null(iso)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Delete immediately after getting
|
|
||||||
// Ignore delete errors (key might not exist)
|
|
||||||
ctx.Space.Delete(key)
|
|
||||||
|
|
||||||
// Convert to JavaScript value
|
|
||||||
jsValue, err := bridge.JsValue(info.Context(), value)
|
jsValue, err := bridge.JsValue(info.Context(), value)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return v8go.Null(iso)
|
return v8go.Null(iso)
|
||||||
|
|
@ -830,7 +945,7 @@ func (ctx *Context) createSpaceObject(v8ctx *v8go.Context) *v8go.Value {
|
||||||
getDelFuncVal := getDelFunc.GetFunction(v8ctx)
|
getDelFuncVal := getDelFunc.GetFunction(v8ctx)
|
||||||
obj.Set("GetDel", getDelFuncVal.Value)
|
obj.Set("GetDel", getDelFuncVal.Value)
|
||||||
|
|
||||||
return spaceObj
|
return obj.Value
|
||||||
}
|
}
|
||||||
|
|
||||||
// sendGroupMethod implements ctx.SendGroup(group)
|
// sendGroupMethod implements ctx.SendGroup(group)
|
||||||
|
|
|
||||||
|
|
@ -11,21 +11,23 @@ import (
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// newMCPTestContext creates a test context for MCP testing
|
||||||
|
func newMCPTestContext() *context.Context {
|
||||||
|
ctx := context.New(stdContext.Background(), nil, "test-chat-id")
|
||||||
|
ctx.AssistantID = "test-assistant-id"
|
||||||
|
ctx.Locale = "en"
|
||||||
|
ctx.Referer = context.RefererAPI
|
||||||
|
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
||||||
|
ctx.Stack = stack
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
// TestMCPListResources tests MCP.ListResources from JavaScript
|
// TestMCPListResources tests MCP.ListResources from JavaScript
|
||||||
func TestMCPListResources(t *testing.T) {
|
func TestMCPListResources(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
// Initialize context with trace
|
ctx := newMCPTestContext()
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -62,15 +64,7 @@ func TestMCPReadResource(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
ctx := &context.Context{
|
ctx := newMCPTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -105,15 +99,7 @@ func TestMCPListTools(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
ctx := &context.Context{
|
ctx := newMCPTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -152,15 +138,7 @@ func TestMCPCallTool(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
ctx := &context.Context{
|
ctx := newMCPTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -195,15 +173,7 @@ func TestMCPCallTools(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
ctx := &context.Context{
|
ctx := newMCPTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -243,15 +213,7 @@ func TestMCPCallToolsParallel(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
ctx := &context.Context{
|
ctx := newMCPTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -291,15 +253,7 @@ func TestMCPListPrompts(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
ctx := &context.Context{
|
ctx := newMCPTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -336,15 +290,7 @@ func TestMCPGetPrompt(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
ctx := &context.Context{
|
ctx := newMCPTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -379,15 +325,7 @@ func TestMCPListSamples(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
ctx := &context.Context{
|
ctx := newMCPTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -422,15 +360,7 @@ func TestMCPGetSample(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
ctx := &context.Context{
|
ctx := newMCPTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -467,15 +397,7 @@ func TestMCPJsApiWithTrace(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
ctx := &context.Context{
|
ctx := newMCPTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
|
||||||
ctx.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
|
||||||
561
agent/context/jsapi_memory_test.go
Normal file
561
agent/context/jsapi_memory_test.go
Normal file
|
|
@ -0,0 +1,561 @@
|
||||||
|
package context_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
stdContext "context"
|
||||||
|
"testing"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||||
|
"github.com/yaoapp/yao/agent/context"
|
||||||
|
"github.com/yaoapp/yao/agent/memory"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemoryUserNamespace(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := &context.Context{
|
||||||
|
ChatID: "test-chat-id",
|
||||||
|
AssistantID: "test-assistant-id",
|
||||||
|
Locale: "en",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
// Set values in user namespace
|
||||||
|
ctx.memory.user.Set("name", "John");
|
||||||
|
ctx.memory.user.Set("age", 30);
|
||||||
|
ctx.memory.user.Set("active", true);
|
||||||
|
|
||||||
|
// Get values back
|
||||||
|
const name = ctx.memory.user.Get("name");
|
||||||
|
const age = ctx.memory.user.Get("age");
|
||||||
|
const active = ctx.memory.user.Get("active");
|
||||||
|
|
||||||
|
// Verify
|
||||||
|
if (name !== "John") throw new Error("Name mismatch");
|
||||||
|
if (age !== 30) throw new Error("Age mismatch");
|
||||||
|
if (active !== true) throw new Error("Active mismatch");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
name: name,
|
||||||
|
age: age,
|
||||||
|
active: active
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
assert.True(t, result["success"].(bool))
|
||||||
|
assert.Equal(t, "John", result["name"])
|
||||||
|
assert.Equal(t, float64(30), result["age"])
|
||||||
|
assert.Equal(t, true, result["active"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryTeamNamespace(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := &context.Context{
|
||||||
|
ChatID: "test-chat-id",
|
||||||
|
AssistantID: "test-assistant-id",
|
||||||
|
Locale: "en",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
// Set team-wide settings
|
||||||
|
ctx.memory.team.Set("settings", { theme: "dark", language: "en" });
|
||||||
|
|
||||||
|
// Get back
|
||||||
|
const settings = ctx.memory.team.Get("settings");
|
||||||
|
|
||||||
|
if (settings.theme !== "dark") throw new Error("Theme mismatch");
|
||||||
|
if (settings.language !== "en") throw new Error("Language mismatch");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
settings: settings
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
assert.True(t, result["success"].(bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryChatNamespace(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := &context.Context{
|
||||||
|
ChatID: "test-chat-id",
|
||||||
|
AssistantID: "test-assistant-id",
|
||||||
|
Locale: "en",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
// Set chat context
|
||||||
|
ctx.memory.chat.Set("topic", "AI Discussion");
|
||||||
|
ctx.memory.chat.Set("participants", ["Alice", "Bob"]);
|
||||||
|
|
||||||
|
// Get back
|
||||||
|
const topic = ctx.memory.chat.Get("topic");
|
||||||
|
const participants = ctx.memory.chat.Get("participants");
|
||||||
|
|
||||||
|
if (topic !== "AI Discussion") throw new Error("Topic mismatch");
|
||||||
|
if (participants.length !== 2) throw new Error("Participants mismatch");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
topic: topic,
|
||||||
|
participants: participants
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
assert.True(t, result["success"].(bool))
|
||||||
|
assert.Equal(t, "AI Discussion", result["topic"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryContextNamespace(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := &context.Context{
|
||||||
|
ChatID: "test-chat-id",
|
||||||
|
AssistantID: "test-assistant-id",
|
||||||
|
Locale: "en",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
// Set temporary context data
|
||||||
|
ctx.memory.context.Set("temp_result", { step: 1, data: "processing" });
|
||||||
|
|
||||||
|
// Get back
|
||||||
|
const result = ctx.memory.context.Get("temp_result");
|
||||||
|
|
||||||
|
if (result.step !== 1) throw new Error("Step mismatch");
|
||||||
|
if (result.data !== "processing") throw new Error("Data mismatch");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
result: result
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
assert.True(t, result["success"].(bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryHasAndDel(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := &context.Context{
|
||||||
|
ChatID: "test-chat-id",
|
||||||
|
AssistantID: "test-assistant-id",
|
||||||
|
Locale: "en",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
// Set a value
|
||||||
|
ctx.memory.user.Set("key", "value");
|
||||||
|
|
||||||
|
// Check Has
|
||||||
|
const hasBefore = ctx.memory.user.Has("key");
|
||||||
|
if (!hasBefore) throw new Error("Should have key before delete");
|
||||||
|
|
||||||
|
// Delete
|
||||||
|
ctx.memory.user.Del("key");
|
||||||
|
|
||||||
|
// Check Has again
|
||||||
|
const hasAfter = ctx.memory.user.Has("key");
|
||||||
|
if (hasAfter) throw new Error("Should not have key after delete");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
hasBefore: hasBefore,
|
||||||
|
hasAfter: hasAfter
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
assert.True(t, result["success"].(bool))
|
||||||
|
assert.True(t, result["hasBefore"].(bool))
|
||||||
|
assert.False(t, result["hasAfter"].(bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryIncrDecr(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := &context.Context{
|
||||||
|
ChatID: "test-chat-id",
|
||||||
|
AssistantID: "test-assistant-id",
|
||||||
|
Locale: "en",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
// Incr on non-existent key
|
||||||
|
const v1 = ctx.memory.user.Incr("counter");
|
||||||
|
if (v1 !== 1) throw new Error("First incr should be 1, got " + v1);
|
||||||
|
|
||||||
|
// Incr with delta
|
||||||
|
const v2 = ctx.memory.user.Incr("counter", 5);
|
||||||
|
if (v2 !== 6) throw new Error("Second incr should be 6, got " + v2);
|
||||||
|
|
||||||
|
// Decr
|
||||||
|
const v3 = ctx.memory.user.Decr("counter", 2);
|
||||||
|
if (v3 !== 4) throw new Error("Decr should be 4, got " + v3);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
v1: v1,
|
||||||
|
v2: v2,
|
||||||
|
v3: v3
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
assert.True(t, result["success"].(bool))
|
||||||
|
assert.Equal(t, float64(1), result["v1"])
|
||||||
|
assert.Equal(t, float64(6), result["v2"])
|
||||||
|
assert.Equal(t, float64(4), result["v3"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryKeysAndLen(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Use unique IDs to avoid data pollution from other tests
|
||||||
|
mem, err := memory.New(nil, "user-keys-len", "team-keys-len", "chat-keys-len", "ctx-keys-len")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := &context.Context{
|
||||||
|
ChatID: "test-chat-id",
|
||||||
|
AssistantID: "test-assistant-id",
|
||||||
|
Locale: "en",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
// Set multiple values
|
||||||
|
ctx.memory.user.Set("a", 1);
|
||||||
|
ctx.memory.user.Set("b", 2);
|
||||||
|
ctx.memory.user.Set("c", 3);
|
||||||
|
|
||||||
|
// Get keys
|
||||||
|
const keys = ctx.memory.user.Keys();
|
||||||
|
if (keys.length !== 3) throw new Error("Should have 3 keys, got " + keys.length);
|
||||||
|
|
||||||
|
// Get len
|
||||||
|
const len = ctx.memory.user.Len();
|
||||||
|
if (len !== 3) throw new Error("Len should be 3, got " + len);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
keys: keys,
|
||||||
|
len: len
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
if !result["success"].(bool) {
|
||||||
|
t.Fatalf("Test failed: %v", result["error"])
|
||||||
|
}
|
||||||
|
assert.Equal(t, float64(3), result["len"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryClear(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := &context.Context{
|
||||||
|
ChatID: "test-chat-id",
|
||||||
|
AssistantID: "test-assistant-id",
|
||||||
|
Locale: "en",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
// Set values
|
||||||
|
ctx.memory.user.Set("a", 1);
|
||||||
|
ctx.memory.user.Set("b", 2);
|
||||||
|
|
||||||
|
// Clear
|
||||||
|
ctx.memory.user.Clear();
|
||||||
|
|
||||||
|
// Check len
|
||||||
|
const len = ctx.memory.user.Len();
|
||||||
|
if (len !== 0) throw new Error("Len should be 0 after clear, got " + len);
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
len: len
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
assert.True(t, result["success"].(bool))
|
||||||
|
assert.Equal(t, float64(0), result["len"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryGetDel(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx := &context.Context{
|
||||||
|
ChatID: "test-chat-id",
|
||||||
|
AssistantID: "test-assistant-id",
|
||||||
|
Locale: "en",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem,
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
// Set a one-time value
|
||||||
|
ctx.memory.user.Set("token", "secret123");
|
||||||
|
|
||||||
|
// GetDel
|
||||||
|
const value = ctx.memory.user.GetDel("token");
|
||||||
|
if (value !== "secret123") throw new Error("Value mismatch");
|
||||||
|
|
||||||
|
// Should be deleted
|
||||||
|
const after = ctx.memory.user.Get("token");
|
||||||
|
if (after !== null) throw new Error("Should be null after GetDel");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
value: value,
|
||||||
|
after: after
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
assert.True(t, result["success"].(bool))
|
||||||
|
assert.Equal(t, "secret123", result["value"])
|
||||||
|
assert.Nil(t, result["after"])
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryIsolation(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Create two different memory instances
|
||||||
|
mem1, err := memory.New(nil, "user1", "", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mem2, err := memory.New(nil, "user2", "", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ctx1 := &context.Context{
|
||||||
|
ChatID: "chat1",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem1,
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx2 := &context.Context{
|
||||||
|
ChatID: "chat2",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: mem2,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set value in user1
|
||||||
|
res1, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
ctx.memory.user.Set("key", "user1_value");
|
||||||
|
return ctx.memory.user.Get("key");
|
||||||
|
}`, ctx1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "user1_value", res1)
|
||||||
|
|
||||||
|
// Set value in user2
|
||||||
|
res2, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
ctx.memory.user.Set("key", "user2_value");
|
||||||
|
return ctx.memory.user.Get("key");
|
||||||
|
}`, ctx2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "user2_value", res2)
|
||||||
|
|
||||||
|
// Verify user1 still has its own value
|
||||||
|
res3, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
return ctx.memory.user.Get("key");
|
||||||
|
}`, ctx1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "user1_value", res3)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryNoMemory(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
ctx := &context.Context{
|
||||||
|
ChatID: "test-chat-id",
|
||||||
|
Context: stdContext.Background(),
|
||||||
|
Memory: nil, // No memory
|
||||||
|
}
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
const hasMemory = ctx.memory !== undefined && ctx.memory !== null;
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
hasMemory: hasMemory
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
assert.True(t, result["success"].(bool))
|
||||||
|
assert.False(t, result["hasMemory"].(bool))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryWithAuthorizedInfo(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Use context.New to create context with authorized info
|
||||||
|
authorized := &types.AuthorizedInfo{
|
||||||
|
UserID: "user123",
|
||||||
|
TeamID: "team456",
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx := context.New(stdContext.Background(), authorized, "chat789")
|
||||||
|
defer ctx.Release()
|
||||||
|
|
||||||
|
// Verify memory was created with correct IDs
|
||||||
|
require.NotNil(t, ctx.Memory)
|
||||||
|
require.NotNil(t, ctx.Memory.User)
|
||||||
|
require.NotNil(t, ctx.Memory.Team)
|
||||||
|
require.NotNil(t, ctx.Memory.Chat)
|
||||||
|
require.NotNil(t, ctx.Memory.Context)
|
||||||
|
|
||||||
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
function test(ctx) {
|
||||||
|
try {
|
||||||
|
// Set values in different namespaces
|
||||||
|
ctx.memory.user.Set("pref", "dark");
|
||||||
|
ctx.memory.team.Set("setting", "shared");
|
||||||
|
ctx.memory.chat.Set("topic", "test");
|
||||||
|
ctx.memory.context.Set("temp", "data");
|
||||||
|
|
||||||
|
return {
|
||||||
|
success: true,
|
||||||
|
user: ctx.memory.user.Get("pref"),
|
||||||
|
team: ctx.memory.team.Get("setting"),
|
||||||
|
chat: ctx.memory.chat.Get("topic"),
|
||||||
|
context: ctx.memory.context.Get("temp")
|
||||||
|
};
|
||||||
|
} catch (error) {
|
||||||
|
return { success: false, error: error.message };
|
||||||
|
}
|
||||||
|
}`, ctx)
|
||||||
|
|
||||||
|
require.NoError(t, err)
|
||||||
|
result := res.(map[string]interface{})
|
||||||
|
assert.True(t, result["success"].(bool))
|
||||||
|
assert.Equal(t, "dark", result["user"])
|
||||||
|
assert.Equal(t, "shared", result["team"])
|
||||||
|
assert.Equal(t, "test", result["chat"])
|
||||||
|
assert.Equal(t, "data", result["context"])
|
||||||
|
}
|
||||||
|
|
@ -7,27 +7,26 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// newReleaseTestContext creates a test context for release testing
|
||||||
|
func newReleaseTestContext() *context.Context {
|
||||||
|
ctx := context.New(stdContext.Background(), nil, "test-chat-id")
|
||||||
|
ctx.AssistantID = "test-assistant-id"
|
||||||
|
ctx.Referer = context.RefererAPI
|
||||||
|
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
||||||
|
ctx.Stack = stack
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
// TestContextRelease tests explicit Release() method on Context
|
// TestContextRelease tests explicit Release() method on Context
|
||||||
func TestContextRelease(t *testing.T) {
|
func TestContextRelease(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := newReleaseTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stack and trace
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -71,17 +70,7 @@ func TestTraceRelease(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := newReleaseTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stack and trace
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -134,17 +123,7 @@ func TestContextReleaseWithTrace(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := newReleaseTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stack and trace
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -183,17 +162,7 @@ func TestTryFinallyPattern(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := newReleaseTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stack and trace
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -237,12 +206,8 @@ func TestNoOpTraceRelease(t *testing.T) {
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
// Context without trace initialization
|
// Context without trace initialization
|
||||||
cxt := &context.Context{
|
cxt := context.New(stdContext.Background(), nil, "test-chat-id")
|
||||||
ChatID: "test-chat-id",
|
cxt.AssistantID = "test-assistant-id"
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -286,17 +251,7 @@ func TestTryFinallyPatternWithError(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := newReleaseTestContext()
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stack and trace
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
_, err := v8.Call(v8.CallOptions{}, `
|
_, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
|
||||||
|
|
@ -1,817 +0,0 @@
|
||||||
package context_test
|
|
||||||
|
|
||||||
import (
|
|
||||||
stdContext "context"
|
|
||||||
"testing"
|
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
|
||||||
"github.com/yaoapp/gou/plan"
|
|
||||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
|
||||||
"github.com/yaoapp/yao/agent/context"
|
|
||||||
"github.com/yaoapp/yao/config"
|
|
||||||
"github.com/yaoapp/yao/test"
|
|
||||||
)
|
|
||||||
|
|
||||||
// TestSpaceSetAndGet tests ctx.space.Set and ctx.space.Get
|
|
||||||
func TestSpaceSetAndGet(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Set various types of values
|
|
||||||
ctx.space.Set("string_key", "hello world");
|
|
||||||
ctx.space.Set("number_key", 42);
|
|
||||||
ctx.space.Set("boolean_key", true);
|
|
||||||
ctx.space.Set("object_key", { name: "test", value: 123 });
|
|
||||||
ctx.space.Set("array_key", [1, 2, 3, 4, 5]);
|
|
||||||
|
|
||||||
// Get values back
|
|
||||||
const str = ctx.space.Get("string_key");
|
|
||||||
const num = ctx.space.Get("number_key");
|
|
||||||
const bool = ctx.space.Get("boolean_key");
|
|
||||||
const obj = ctx.space.Get("object_key");
|
|
||||||
const arr = ctx.space.Get("array_key");
|
|
||||||
|
|
||||||
// Verify values
|
|
||||||
if (str !== "hello world") throw new Error("String mismatch");
|
|
||||||
if (num !== 42) throw new Error("Number mismatch");
|
|
||||||
if (bool !== true) throw new Error("Boolean mismatch");
|
|
||||||
if (obj.name !== "test" || obj.value !== 123) throw new Error("Object mismatch");
|
|
||||||
if (arr.length !== 5 || arr[0] !== 1) throw new Error("Array mismatch");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
str: str,
|
|
||||||
num: num,
|
|
||||||
bool: bool,
|
|
||||||
obj: obj,
|
|
||||||
arr: arr
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result["success"].(bool) {
|
|
||||||
t.Fatalf("Test failed: %v", result["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Space Set/Get should succeed")
|
|
||||||
assert.Equal(t, "hello world", result["str"], "String should match")
|
|
||||||
assert.Equal(t, float64(42), result["num"], "Number should match")
|
|
||||||
assert.Equal(t, true, result["bool"], "Boolean should match")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceGetNonExistentKey tests getting a non-existent key
|
|
||||||
func TestSpaceGetNonExistentKey(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Get non-existent key should return null/undefined
|
|
||||||
const value = ctx.space.Get("non_existent_key");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
value: value,
|
|
||||||
is_null: value === null,
|
|
||||||
is_undefined: value === undefined
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Get non-existent key should succeed")
|
|
||||||
// JavaScript null is returned as nil in Go
|
|
||||||
assert.True(t, result["is_null"].(bool) || result["is_undefined"].(bool), "Non-existent key should return null or undefined")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceDelete tests ctx.space.Delete
|
|
||||||
func TestSpaceDelete(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Set a value
|
|
||||||
ctx.space.Set("delete_me", "temporary value");
|
|
||||||
|
|
||||||
// Verify it exists
|
|
||||||
const before = ctx.space.Get("delete_me");
|
|
||||||
if (before !== "temporary value") throw new Error("Value not set correctly");
|
|
||||||
|
|
||||||
// Delete it
|
|
||||||
ctx.space.Delete("delete_me");
|
|
||||||
|
|
||||||
// Verify it's gone
|
|
||||||
const after = ctx.space.Get("delete_me");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
before: before,
|
|
||||||
after: after,
|
|
||||||
is_deleted: after === null || after === undefined
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result["success"].(bool) {
|
|
||||||
t.Fatalf("Test failed: %v", result["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Space Delete should succeed")
|
|
||||||
assert.Equal(t, "temporary value", result["before"], "Value should exist before delete")
|
|
||||||
assert.Equal(t, true, result["is_deleted"], "Value should be deleted")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceDeleteNonExistentKey tests deleting a non-existent key
|
|
||||||
func TestSpaceDeleteNonExistentKey(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Delete non-existent key should not throw error
|
|
||||||
ctx.space.Delete("non_existent_key");
|
|
||||||
|
|
||||||
return { success: true };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Delete non-existent key should not throw error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceWithNamespace tests using Space with namespace prefixes (like agent IDs)
|
|
||||||
func TestSpaceWithNamespace(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Simulate namespace pattern used in voucher assistant
|
|
||||||
const agentID = "workers.voucher";
|
|
||||||
|
|
||||||
// Set files_info with namespace
|
|
||||||
const filesInfo = [
|
|
||||||
{
|
|
||||||
file_id: "abc123",
|
|
||||||
filename: "test.png",
|
|
||||||
content_type: "image/png",
|
|
||||||
file_type: "image",
|
|
||||||
source: "uploader"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
ctx.space.Set(agentID + ":files_info", filesInfo);
|
|
||||||
|
|
||||||
// Set current_file with namespace
|
|
||||||
const currentFile = {
|
|
||||||
file_id: "abc123",
|
|
||||||
filename: "test.png",
|
|
||||||
content_type: "image/png"
|
|
||||||
};
|
|
||||||
ctx.space.Set(agentID + ":current_file", currentFile);
|
|
||||||
|
|
||||||
// Read back with namespace
|
|
||||||
const retrievedFiles = ctx.space.Get(agentID + ":files_info");
|
|
||||||
const retrievedCurrent = ctx.space.Get(agentID + ":current_file");
|
|
||||||
|
|
||||||
// Verify
|
|
||||||
if (!Array.isArray(retrievedFiles)) throw new Error("files_info should be array");
|
|
||||||
if (retrievedFiles.length !== 1) throw new Error("files_info length mismatch");
|
|
||||||
if (retrievedFiles[0].file_id !== "abc123") throw new Error("file_id mismatch");
|
|
||||||
if (retrievedCurrent.filename !== "test.png") throw new Error("filename mismatch");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
files_count: retrievedFiles.length,
|
|
||||||
file_id: retrievedFiles[0].file_id,
|
|
||||||
current_filename: retrievedCurrent.filename
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result["success"].(bool) {
|
|
||||||
t.Fatalf("Test failed: %v", result["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Namespace operations should succeed")
|
|
||||||
assert.Equal(t, float64(1), result["files_count"], "Should have 1 file")
|
|
||||||
assert.Equal(t, "abc123", result["file_id"], "File ID should match")
|
|
||||||
assert.Equal(t, "test.png", result["current_filename"], "Filename should match")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceComplexData tests Space with complex nested data structures
|
|
||||||
func TestSpaceComplexData(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Complex nested structure
|
|
||||||
const complexData = {
|
|
||||||
metadata: {
|
|
||||||
assistant_id: "tests.vision-helper",
|
|
||||||
has_files_info: true,
|
|
||||||
files_count: 2
|
|
||||||
},
|
|
||||||
files_info: [
|
|
||||||
{
|
|
||||||
file_id: "file1",
|
|
||||||
filename: "image1.png",
|
|
||||||
content_type: "image/png",
|
|
||||||
metadata: {
|
|
||||||
size: 1024,
|
|
||||||
created: Date.now()
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file_id: "file2",
|
|
||||||
filename: "image2.jpg",
|
|
||||||
content_type: "image/jpeg",
|
|
||||||
metadata: {
|
|
||||||
size: 2048,
|
|
||||||
created: Date.now()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
],
|
|
||||||
tags: ["vision", "test", "multi-file"]
|
|
||||||
};
|
|
||||||
|
|
||||||
ctx.space.Set("complex_data", complexData);
|
|
||||||
|
|
||||||
// Retrieve and verify
|
|
||||||
const retrieved = ctx.space.Get("complex_data");
|
|
||||||
|
|
||||||
if (!retrieved) throw new Error("Data not retrieved");
|
|
||||||
if (!retrieved.metadata) throw new Error("Metadata missing");
|
|
||||||
if (retrieved.metadata.files_count !== 2) throw new Error("Files count mismatch");
|
|
||||||
if (!Array.isArray(retrieved.files_info)) throw new Error("files_info not array");
|
|
||||||
if (retrieved.files_info.length !== 2) throw new Error("files_info length mismatch");
|
|
||||||
if (!Array.isArray(retrieved.tags)) throw new Error("tags not array");
|
|
||||||
if (retrieved.tags[0] !== "vision") throw new Error("tags mismatch");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
files_count: retrieved.files_info.length,
|
|
||||||
first_file_id: retrieved.files_info[0].file_id,
|
|
||||||
second_filename: retrieved.files_info[1].filename,
|
|
||||||
tags: retrieved.tags
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result["success"].(bool) {
|
|
||||||
t.Fatalf("Test failed: %v", result["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Complex data operations should succeed")
|
|
||||||
assert.Equal(t, float64(2), result["files_count"], "Should have 2 files")
|
|
||||||
assert.Equal(t, "file1", result["first_file_id"], "First file ID should match")
|
|
||||||
assert.Equal(t, "image2.jpg", result["second_filename"], "Second filename should match")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceOverwrite tests overwriting existing values
|
|
||||||
func TestSpaceOverwrite(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Set initial value
|
|
||||||
ctx.space.Set("counter", 1);
|
|
||||||
const first = ctx.space.Get("counter");
|
|
||||||
|
|
||||||
// Overwrite with new value
|
|
||||||
ctx.space.Set("counter", 2);
|
|
||||||
const second = ctx.space.Get("counter");
|
|
||||||
|
|
||||||
// Overwrite again
|
|
||||||
ctx.space.Set("counter", 3);
|
|
||||||
const third = ctx.space.Get("counter");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
first: first,
|
|
||||||
second: second,
|
|
||||||
third: third
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result["success"].(bool) {
|
|
||||||
t.Fatalf("Test failed: %v", result["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Overwrite operations should succeed")
|
|
||||||
assert.Equal(t, float64(1), result["first"], "First value should be 1")
|
|
||||||
assert.Equal(t, float64(2), result["second"], "Second value should be 2")
|
|
||||||
assert.Equal(t, float64(3), result["third"], "Third value should be 3")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceNoSpace tests behavior when Space is nil
|
|
||||||
func TestSpaceNoSpace(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: nil, // No Space
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// ctx.space should be undefined when Space is nil
|
|
||||||
const hasSpace = ctx.space !== undefined && ctx.space !== null;
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
has_space: hasSpace
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Should handle nil Space gracefully")
|
|
||||||
assert.Equal(t, false, result["has_space"], "Should not have space when Space is nil")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceErrorHandling tests error handling in Space methods
|
|
||||||
func TestSpaceErrorHandling(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Test Set without key
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Set without proper arguments should throw
|
|
||||||
ctx.space.Set();
|
|
||||||
return { success: false, error: "Should have thrown" };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: true, caught_error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Should catch Set error")
|
|
||||||
|
|
||||||
// Test Get without key
|
|
||||||
res, err = v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Get without key should throw
|
|
||||||
ctx.space.Get();
|
|
||||||
return { success: false, error: "Should have thrown" };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: true, caught_error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok = res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Should catch Get error")
|
|
||||||
|
|
||||||
// Test Delete without key
|
|
||||||
res, err = v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Delete without key should throw
|
|
||||||
ctx.space.Delete();
|
|
||||||
return { success: false, error: "Should have thrown" };
|
|
||||||
} catch (error) {
|
|
||||||
return { success: true, caught_error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok = res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Should catch Delete error")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceGetDel tests ctx.space.GetDel method
|
|
||||||
func TestSpaceGetDel(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Set a one-time use value
|
|
||||||
ctx.space.Set("one_time_token", "secret_token_12345");
|
|
||||||
|
|
||||||
// Verify it exists before GetDel
|
|
||||||
const before = ctx.space.Get("one_time_token");
|
|
||||||
if (before !== "secret_token_12345") throw new Error("Value not set");
|
|
||||||
|
|
||||||
// Use GetDel - should get value and delete automatically
|
|
||||||
const value = ctx.space.GetDel("one_time_token");
|
|
||||||
|
|
||||||
// Verify value was retrieved
|
|
||||||
if (value !== "secret_token_12345") throw new Error("GetDel returned wrong value");
|
|
||||||
|
|
||||||
// Verify key was deleted
|
|
||||||
const after = ctx.space.Get("one_time_token");
|
|
||||||
if (after !== null && after !== undefined) throw new Error("Key should be deleted after GetDel");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
value: value,
|
|
||||||
is_deleted: after === null || after === undefined
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result["success"].(bool) {
|
|
||||||
t.Fatalf("Test failed: %v", result["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "GetDel should succeed")
|
|
||||||
assert.Equal(t, "secret_token_12345", result["value"], "GetDel should return correct value")
|
|
||||||
assert.Equal(t, true, result["is_deleted"], "Key should be deleted after GetDel")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceGetDelNonExistentKey tests GetDel on non-existent key
|
|
||||||
func TestSpaceGetDelNonExistentKey(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// GetDel on non-existent key should return null/undefined
|
|
||||||
const value = ctx.space.GetDel("non_existent_key");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
value: value,
|
|
||||||
is_null: value === null || value === undefined
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "GetDel on non-existent key should not throw")
|
|
||||||
assert.Equal(t, true, result["is_null"], "GetDel on non-existent key should return null")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceGetDelComplexData tests GetDel with complex data structures
|
|
||||||
func TestSpaceGetDelComplexData(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Set complex file info data (like voucher assistant use case)
|
|
||||||
const filesInfo = [
|
|
||||||
{
|
|
||||||
file_id: "file123",
|
|
||||||
filename: "invoice.pdf",
|
|
||||||
content_type: "application/pdf",
|
|
||||||
file_type: "pdf",
|
|
||||||
source: "uploader",
|
|
||||||
uploader_name: "__yao.attachment"
|
|
||||||
},
|
|
||||||
{
|
|
||||||
file_id: "file456",
|
|
||||||
filename: "receipt.png",
|
|
||||||
content_type: "image/png",
|
|
||||||
file_type: "image",
|
|
||||||
source: "uploader",
|
|
||||||
uploader_name: "__yao.attachment"
|
|
||||||
}
|
|
||||||
];
|
|
||||||
|
|
||||||
ctx.space.Set("workers.voucher:files_info", filesInfo);
|
|
||||||
|
|
||||||
// Use GetDel to retrieve and clean up
|
|
||||||
const retrieved = ctx.space.GetDel("workers.voucher:files_info");
|
|
||||||
|
|
||||||
// Verify data integrity
|
|
||||||
if (!Array.isArray(retrieved)) throw new Error("Should be array");
|
|
||||||
if (retrieved.length !== 2) throw new Error("Length mismatch");
|
|
||||||
if (retrieved[0].file_id !== "file123") throw new Error("First file_id mismatch");
|
|
||||||
if (retrieved[1].filename !== "receipt.png") throw new Error("Second filename mismatch");
|
|
||||||
|
|
||||||
// Verify it's deleted
|
|
||||||
const after = ctx.space.Get("workers.voucher:files_info");
|
|
||||||
if (after !== null && after !== undefined) throw new Error("Should be deleted");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
files_count: retrieved.length,
|
|
||||||
first_file_id: retrieved[0].file_id,
|
|
||||||
is_deleted: after === null || after === undefined
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result["success"].(bool) {
|
|
||||||
t.Fatalf("Test failed: %v", result["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "GetDel with complex data should succeed")
|
|
||||||
assert.Equal(t, float64(2), result["files_count"], "Should have 2 files")
|
|
||||||
assert.Equal(t, "file123", result["first_file_id"], "File ID should match")
|
|
||||||
assert.Equal(t, true, result["is_deleted"], "Should be deleted after GetDel")
|
|
||||||
}
|
|
||||||
|
|
||||||
// TestSpaceGetDelMultipleCalls tests that GetDel only works once
|
|
||||||
func TestSpaceGetDelMultipleCalls(t *testing.T) {
|
|
||||||
test.Prepare(t, config.Conf)
|
|
||||||
defer test.Clean()
|
|
||||||
|
|
||||||
ctx := &context.Context{
|
|
||||||
ChatID: "test-chat-id",
|
|
||||||
AssistantID: "test-assistant-id",
|
|
||||||
Locale: "en",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
Space: plan.NewMemorySharedSpace(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
|
||||||
function test(ctx) {
|
|
||||||
try {
|
|
||||||
// Set a value
|
|
||||||
ctx.space.Set("single_use", "use_me_once");
|
|
||||||
|
|
||||||
// First GetDel should work
|
|
||||||
const first = ctx.space.GetDel("single_use");
|
|
||||||
if (first !== "use_me_once") throw new Error("First GetDel failed");
|
|
||||||
|
|
||||||
// Second GetDel should return null (already deleted)
|
|
||||||
const second = ctx.space.GetDel("single_use");
|
|
||||||
if (second !== null && second !== undefined) throw new Error("Second GetDel should return null");
|
|
||||||
|
|
||||||
// Third GetDel should also return null
|
|
||||||
const third = ctx.space.GetDel("single_use");
|
|
||||||
if (third !== null && third !== undefined) throw new Error("Third GetDel should return null");
|
|
||||||
|
|
||||||
return {
|
|
||||||
success: true,
|
|
||||||
first: first,
|
|
||||||
second_is_null: second === null || second === undefined,
|
|
||||||
third_is_null: third === null || third === undefined
|
|
||||||
};
|
|
||||||
} catch (error) {
|
|
||||||
return { success: false, error: error.message };
|
|
||||||
}
|
|
||||||
}`, ctx)
|
|
||||||
|
|
||||||
if err != nil {
|
|
||||||
t.Fatalf("Call failed: %v", err)
|
|
||||||
}
|
|
||||||
|
|
||||||
result, ok := res.(map[string]interface{})
|
|
||||||
if !ok {
|
|
||||||
t.Fatalf("Expected map result, got %T", res)
|
|
||||||
}
|
|
||||||
|
|
||||||
if !result["success"].(bool) {
|
|
||||||
t.Fatalf("Test failed: %v", result["error"])
|
|
||||||
}
|
|
||||||
|
|
||||||
assert.Equal(t, true, result["success"], "Multiple GetDel calls should work correctly")
|
|
||||||
assert.Equal(t, "use_me_once", result["first"], "First GetDel should return value")
|
|
||||||
assert.Equal(t, true, result["second_is_null"], "Second GetDel should return null")
|
|
||||||
assert.Equal(t, true, result["third_is_null"], "Third GetDel should return null")
|
|
||||||
}
|
|
||||||
|
|
@ -12,11 +12,20 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
// newStressTestContext creates a test context for stress testing
|
||||||
|
func newStressTestContext(chatID string) *context.Context {
|
||||||
|
ctx := context.New(stdContext.Background(), nil, chatID)
|
||||||
|
ctx.AssistantID = "test-assistant"
|
||||||
|
ctx.Referer = context.RefererAPI
|
||||||
|
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
||||||
|
ctx.Stack = stack
|
||||||
|
return ctx
|
||||||
|
}
|
||||||
|
|
||||||
// TestStressContextCreationAndRelease tests massive context creation and cleanup
|
// TestStressContextCreationAndRelease tests massive context creation and cleanup
|
||||||
func TestStressContextCreationAndRelease(t *testing.T) {
|
func TestStressContextCreationAndRelease(t *testing.T) {
|
||||||
if testing.Short() {
|
if testing.Short() {
|
||||||
|
|
@ -30,17 +39,7 @@ func TestStressContextCreationAndRelease(t *testing.T) {
|
||||||
startMemory := getMemStats()
|
startMemory := getMemStats()
|
||||||
|
|
||||||
for i := 0; i < iterations; i++ {
|
for i := 0; i < iterations; i++ {
|
||||||
cxt := &context.Context{
|
cxt := newStressTestContext(fmt.Sprintf("chat-%d", i))
|
||||||
ChatID: fmt.Sprintf("chat-%d", i),
|
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stack and trace
|
|
||||||
cxt.Referer = context.RefererAPI
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
_, err := v8.Call(v8.CallOptions{}, `
|
_, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -107,17 +106,7 @@ func TestStressTraceOperations(t *testing.T) {
|
||||||
|
|
||||||
for i := 0; i < iterations; i++ {
|
for i := 0; i < iterations; i++ {
|
||||||
// Create new context for each iteration to avoid context cancellation issues
|
// Create new context for each iteration to avoid context cancellation issues
|
||||||
cxt := &context.Context{
|
cxt := newStressTestContext(fmt.Sprintf("stress-test-chat-%d", i))
|
||||||
ChatID: fmt.Sprintf("stress-test-chat-%d", i),
|
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stack and trace
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
_, err := v8.Call(v8.CallOptions{}, fmt.Sprintf(`
|
_, err := v8.Call(v8.CallOptions{}, fmt.Sprintf(`
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
const trace = ctx.trace
|
const trace = ctx.trace
|
||||||
|
|
@ -185,16 +174,7 @@ func TestStressMCPOperations(t *testing.T) {
|
||||||
|
|
||||||
iterations := 500
|
iterations := 500
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := newStressTestContext("mcp-stress-test")
|
||||||
ChatID: "mcp-stress-test",
|
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
startMemory := getMemStats()
|
startMemory := getMemStats()
|
||||||
|
|
||||||
|
|
@ -269,16 +249,7 @@ func TestStressConcurrentContexts(t *testing.T) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
for i := 0; i < iterationsPerGoroutine; i++ {
|
for i := 0; i < iterationsPerGoroutine; i++ {
|
||||||
cxt := &context.Context{
|
cxt := newStressTestContext(fmt.Sprintf("chat-%d-%d", goroutineID, i))
|
||||||
ChatID: fmt.Sprintf("chat-%d-%d", goroutineID, i),
|
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
_, err := v8.Call(v8.CallOptions{}, `
|
_, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -345,12 +316,8 @@ func TestStressNoOpTracePerformance(t *testing.T) {
|
||||||
iterations := 1000
|
iterations := 1000
|
||||||
|
|
||||||
// Context without trace initialization (no-op trace)
|
// Context without trace initialization (no-op trace)
|
||||||
cxt := &context.Context{
|
cxt := context.New(stdContext.Background(), nil, "noop-stress-test")
|
||||||
ChatID: "noop-stress-test",
|
cxt.AssistantID = "test-assistant"
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
}
|
|
||||||
|
|
||||||
startMemory := getMemStats()
|
startMemory := getMemStats()
|
||||||
startTime := time.Now()
|
startTime := time.Now()
|
||||||
|
|
@ -420,16 +387,7 @@ func TestStressReleasePatterns(t *testing.T) {
|
||||||
startMemory := getMemStats()
|
startMemory := getMemStats()
|
||||||
|
|
||||||
for i := 0; i < iterations; i++ {
|
for i := 0; i < iterations; i++ {
|
||||||
cxt := &context.Context{
|
cxt := newStressTestContext(fmt.Sprintf("manual-%d", i))
|
||||||
ChatID: fmt.Sprintf("manual-%d", i),
|
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
_, err := v8.Call(v8.CallOptions{}, `
|
_, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -459,16 +417,7 @@ func TestStressReleasePatterns(t *testing.T) {
|
||||||
startMemory := getMemStats()
|
startMemory := getMemStats()
|
||||||
|
|
||||||
for i := 0; i < iterations; i++ {
|
for i := 0; i < iterations; i++ {
|
||||||
cxt := &context.Context{
|
cxt := newStressTestContext(fmt.Sprintf("gc-%d", i))
|
||||||
ChatID: fmt.Sprintf("gc-%d", i),
|
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
_, err := v8.Call(v8.CallOptions{}, `
|
_, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -500,16 +449,7 @@ func TestStressReleasePatterns(t *testing.T) {
|
||||||
startMemory := getMemStats()
|
startMemory := getMemStats()
|
||||||
|
|
||||||
for i := 0; i < iterations; i++ {
|
for i := 0; i < iterations; i++ {
|
||||||
cxt := &context.Context{
|
cxt := newStressTestContext(fmt.Sprintf("separate-%d", i))
|
||||||
ChatID: fmt.Sprintf("separate-%d", i),
|
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
_, err := v8.Call(v8.CallOptions{}, `
|
_, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(ctx) {
|
function test(ctx) {
|
||||||
|
|
@ -546,16 +486,7 @@ func TestStressLongRunningTrace(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := newStressTestContext("long-running-test")
|
||||||
ChatID: "long-running-test",
|
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
stack, _, _ := context.EnterStack(cxt, "test-assistant", &context.Options{})
|
|
||||||
cxt.Stack = stack
|
|
||||||
|
|
||||||
startMemory := getMemStats()
|
startMemory := getMemStats()
|
||||||
operations := 100
|
operations := 100
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,6 @@ import (
|
||||||
v8 "github.com/yaoapp/gou/runtime/v8"
|
v8 "github.com/yaoapp/gou/runtime/v8"
|
||||||
"github.com/yaoapp/gou/runtime/v8/bridge"
|
"github.com/yaoapp/gou/runtime/v8/bridge"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
|
|
@ -23,12 +22,8 @@ func TestJsValue(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := context.New(stdContext.Background(), nil, "ChatID-123456")
|
||||||
ChatID: "ChatID-123456",
|
cxt.AssistantID = "AssistantID-1234"
|
||||||
AssistantID: "AssistantID-1234",
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
}
|
|
||||||
|
|
||||||
v8.RegisterFunction("testContextJsvalue", testContextJsvalueEmbed)
|
v8.RegisterFunction("testContextJsvalue", testContextJsvalueEmbed)
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
|
@ -91,12 +86,8 @@ func TestJsValueConcurrent(t *testing.T) {
|
||||||
chatID := fmt.Sprintf("ChatID-%d-%d", routineID, j)
|
chatID := fmt.Sprintf("ChatID-%d-%d", routineID, j)
|
||||||
assistantID := fmt.Sprintf("AssistantID-%d-%d", routineID, j)
|
assistantID := fmt.Sprintf("AssistantID-%d-%d", routineID, j)
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := context.New(stdContext.Background(), nil, chatID)
|
||||||
ChatID: chatID,
|
cxt.AssistantID = assistantID
|
||||||
AssistantID: assistantID,
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(cxt) {
|
function test(cxt) {
|
||||||
|
|
@ -150,12 +141,8 @@ func TestJsValueRegistrationAndCleanup(t *testing.T) {
|
||||||
// Create multiple contexts and verify registration
|
// Create multiple contexts and verify registration
|
||||||
contextCount := 5
|
contextCount := 5
|
||||||
for i := 0; i < contextCount; i++ {
|
for i := 0; i < contextCount; i++ {
|
||||||
cxt := &context.Context{
|
cxt := context.New(stdContext.Background(), nil, fmt.Sprintf("ChatID-%d", i))
|
||||||
ChatID: fmt.Sprintf("ChatID-%d", i),
|
cxt.AssistantID = fmt.Sprintf("AssistantID-%d", i)
|
||||||
AssistantID: fmt.Sprintf("AssistantID-%d", i),
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
}
|
|
||||||
|
|
||||||
_, err := v8.Call(v8.CallOptions{}, `
|
_, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(cxt) {
|
function test(cxt) {
|
||||||
|
|
@ -219,43 +206,41 @@ func TestJsValueAllFields(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
authInfo := &types.AuthorizedInfo{
|
||||||
ChatID: "test-chat-id",
|
Subject: "test-user",
|
||||||
AssistantID: "test-assistant-id",
|
ClientID: "test-client",
|
||||||
Locale: "zh-cn",
|
UserID: "user-123",
|
||||||
Theme: "dark",
|
TeamID: "team-456",
|
||||||
Context: stdContext.Background(),
|
TenantID: "tenant-789",
|
||||||
Client: context.Client{
|
Constraints: types.DataConstraints{
|
||||||
Type: "web",
|
OwnerOnly: true,
|
||||||
UserAgent: "Mozilla/5.0",
|
CreatorOnly: false,
|
||||||
IP: "127.0.0.1",
|
TeamOnly: true,
|
||||||
},
|
Extra: map[string]interface{}{
|
||||||
Referer: "api",
|
"department": "engineering",
|
||||||
Accept: "cui-web",
|
"region": "us-west",
|
||||||
Route: "/dashboard/home",
|
|
||||||
Metadata: map[string]interface{}{
|
|
||||||
"key1": "value1",
|
|
||||||
"key2": 123,
|
|
||||||
"key3": true,
|
|
||||||
},
|
|
||||||
Authorized: &types.AuthorizedInfo{
|
|
||||||
Subject: "test-user",
|
|
||||||
ClientID: "test-client",
|
|
||||||
UserID: "user-123",
|
|
||||||
TeamID: "team-456",
|
|
||||||
TenantID: "tenant-789",
|
|
||||||
Constraints: types.DataConstraints{
|
|
||||||
OwnerOnly: true,
|
|
||||||
CreatorOnly: false,
|
|
||||||
TeamOnly: true,
|
|
||||||
Extra: map[string]interface{}{
|
|
||||||
"department": "engineering",
|
|
||||||
"region": "us-west",
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cxt := context.New(stdContext.Background(), authInfo, "test-chat-id")
|
||||||
|
cxt.AssistantID = "test-assistant-id"
|
||||||
|
cxt.Locale = "zh-cn"
|
||||||
|
cxt.Theme = "dark"
|
||||||
|
cxt.Client = context.Client{
|
||||||
|
Type: "web",
|
||||||
|
UserAgent: "Mozilla/5.0",
|
||||||
|
IP: "127.0.0.1",
|
||||||
|
}
|
||||||
|
cxt.Referer = "api"
|
||||||
|
cxt.Accept = "cui-web"
|
||||||
|
cxt.Route = "/dashboard/home"
|
||||||
|
cxt.Metadata = map[string]interface{}{
|
||||||
|
"key1": "value1",
|
||||||
|
"key2": 123,
|
||||||
|
"key3": true,
|
||||||
|
}
|
||||||
|
|
||||||
v8.RegisterFunction("testAllFields", testAllFieldsEmbed)
|
v8.RegisterFunction("testAllFields", testAllFieldsEmbed)
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(cxt) {
|
function test(cxt) {
|
||||||
|
|
@ -404,14 +389,10 @@ func TestJsValueTrace(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := context.New(stdContext.Background(), nil, "test-chat-id")
|
||||||
ChatID: "test-chat-id",
|
cxt.AssistantID = "test-assistant-id"
|
||||||
AssistantID: "test-assistant-id",
|
cxt.Stack = &context.Stack{
|
||||||
Stack: &context.Stack{
|
TraceID: "test-trace-id",
|
||||||
TraceID: "test-trace-id",
|
|
||||||
},
|
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
}
|
}
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
|
|
@ -470,21 +451,17 @@ func TestJsValueAuthorizedAndMetadata(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
authInfo := &types.AuthorizedInfo{
|
||||||
ChatID: "test-chat-id",
|
UserID: "user-123",
|
||||||
AssistantID: "test-assistant-id",
|
TenantID: "tenant-456",
|
||||||
Context: stdContext.Background(),
|
ClientID: "client-789",
|
||||||
IDGenerator: message.NewIDGenerator(),
|
}
|
||||||
Authorized: &types.AuthorizedInfo{
|
cxt := context.New(stdContext.Background(), authInfo, "test-chat-id")
|
||||||
UserID: "user-123",
|
cxt.AssistantID = "test-assistant-id"
|
||||||
TenantID: "tenant-456",
|
cxt.Metadata = map[string]interface{}{
|
||||||
ClientID: "client-789",
|
"request_id": "req-001",
|
||||||
},
|
"source": "api",
|
||||||
Metadata: map[string]interface{}{
|
"version": "1.0.0",
|
||||||
"request_id": "req-001",
|
|
||||||
"source": "api",
|
|
||||||
"version": "1.0.0",
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
|
|
||||||
v8.RegisterFunction("testAuthorizedMetadata", testAuthorizedMetadataEmbed)
|
v8.RegisterFunction("testAuthorizedMetadata", testAuthorizedMetadataEmbed)
|
||||||
|
|
@ -573,14 +550,9 @@ func TestJsValueAuthorizedNil(t *testing.T) {
|
||||||
test.Prepare(t, config.Conf)
|
test.Prepare(t, config.Conf)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
||||||
cxt := &context.Context{
|
cxt := context.New(stdContext.Background(), nil, "test-chat-id")
|
||||||
ChatID: "test-chat-id",
|
cxt.AssistantID = "test-assistant-id"
|
||||||
AssistantID: "test-assistant-id",
|
cxt.Metadata = nil // Explicitly nil (should be empty object)
|
||||||
Context: stdContext.Background(),
|
|
||||||
IDGenerator: message.NewIDGenerator(),
|
|
||||||
Authorized: nil, // Explicitly nil
|
|
||||||
Metadata: nil, // Explicitly nil (should be empty object)
|
|
||||||
}
|
|
||||||
|
|
||||||
res, err := v8.Call(v8.CallOptions{}, `
|
res, err := v8.Call(v8.CallOptions{}, `
|
||||||
function test(cxt) {
|
function test(cxt) {
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/mcp/types"
|
"github.com/yaoapp/gou/mcp/types"
|
||||||
"github.com/yaoapp/gou/plan"
|
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
|
|
@ -13,15 +12,10 @@ import (
|
||||||
|
|
||||||
// newTestMCPContext creates a test context
|
// newTestMCPContext creates a test context
|
||||||
func newTestMCPContext() *context.Context {
|
func newTestMCPContext() *context.Context {
|
||||||
ctx := &context.Context{
|
ctx := context.New(stdContext.Background(), nil, "test-chat")
|
||||||
Context: stdContext.Background(),
|
ctx.AssistantID = "test-assistant"
|
||||||
Space: plan.NewMemorySharedSpace(),
|
ctx.Locale = "en"
|
||||||
ID: "test-context",
|
ctx.Referer = context.RefererAPI
|
||||||
ChatID: "test-chat",
|
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Locale: "en",
|
|
||||||
Referer: context.RefererAPI,
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize stack and trace
|
// Initialize stack and trace
|
||||||
stack, traceID, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
stack, traceID, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
|
||||||
|
|
|
||||||
|
|
@ -6,8 +6,8 @@ import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
"github.com/yaoapp/gou/connector/openai"
|
||||||
"github.com/yaoapp/gou/plan"
|
|
||||||
"github.com/yaoapp/gou/store"
|
"github.com/yaoapp/gou/store"
|
||||||
|
"github.com/yaoapp/yao/agent/memory"
|
||||||
"github.com/yaoapp/yao/agent/output"
|
"github.com/yaoapp/yao/agent/output"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
|
@ -226,7 +226,7 @@ type Context struct {
|
||||||
|
|
||||||
// External
|
// External
|
||||||
ID string `json:"id"` // Context ID for external interrupt identification
|
ID string `json:"id"` // Context ID for external interrupt identification
|
||||||
Space plan.Space `json:"-"` // Shared data space, it will be used to share data between the request and the call
|
Memory *memory.Memory `json:"-"` // Agent memory with four spaces: User, Team, Chat, Context
|
||||||
Cache store.Store `json:"-"` // Cache store, it will be used to store the message cache, default is "__yao.agent.cache"
|
Cache store.Store `json:"-"` // Cache store, it will be used to store the message cache, default is "__yao.agent.cache"
|
||||||
Stack *Stack `json:"-"` // Stack, current active stack of the request
|
Stack *Stack `json:"-"` // Stack, current active stack of the request
|
||||||
Stacks map[string]*Stack `json:"-"` // Stacks, all stacks in this request (for trace logging)
|
Stacks map[string]*Stack `json:"-"` // Stacks, all stacks in this request (for trace logging)
|
||||||
|
|
|
||||||
58
agent/memory/interfaces.go
Normal file
58
agent/memory/interfaces.go
Normal file
|
|
@ -0,0 +1,58 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import "github.com/yaoapp/gou/store"
|
||||||
|
|
||||||
|
// Manager defines the interface for managing agent memory
|
||||||
|
type Manager interface {
|
||||||
|
// Memory returns the memory instance for given identifiers
|
||||||
|
Memory(userID, teamID, chatID, contextID string) (*Memory, error)
|
||||||
|
|
||||||
|
// Close closes all stores and releases resources
|
||||||
|
Close() error
|
||||||
|
}
|
||||||
|
|
||||||
|
// Accessor defines the interface for accessing memory from agent context
|
||||||
|
// This is the primary interface used by agent hooks and tools
|
||||||
|
type Accessor interface {
|
||||||
|
// User returns the user-level memory namespace
|
||||||
|
User() NamespaceAccessor
|
||||||
|
|
||||||
|
// Team returns the team-level memory namespace
|
||||||
|
Team() NamespaceAccessor
|
||||||
|
|
||||||
|
// Chat returns the chat-level memory namespace
|
||||||
|
Chat() NamespaceAccessor
|
||||||
|
|
||||||
|
// Context returns the context-level memory namespace
|
||||||
|
Context() NamespaceAccessor
|
||||||
|
|
||||||
|
// Space returns a memory namespace by space type
|
||||||
|
Space(space Space) NamespaceAccessor
|
||||||
|
|
||||||
|
// Stats returns memory statistics
|
||||||
|
Stats() *Stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamespaceAccessor defines the interface for accessing a single memory namespace
|
||||||
|
// Embeds store.Store for all KV and list operations
|
||||||
|
type NamespaceAccessor interface {
|
||||||
|
store.Store
|
||||||
|
|
||||||
|
// GetID returns the namespace identifier (user_id, team_id, chat_id, or context_id)
|
||||||
|
GetID() string
|
||||||
|
|
||||||
|
// GetSpace returns the space type of this namespace
|
||||||
|
GetSpace() Space
|
||||||
|
|
||||||
|
// Stats returns statistics for this namespace
|
||||||
|
Stats() *NamespaceStats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Factory defines the interface for creating memory instances
|
||||||
|
type Factory interface {
|
||||||
|
// Create creates a new memory instance with the given configuration
|
||||||
|
Create(config *Config) (Manager, error)
|
||||||
|
|
||||||
|
// CreateWithDefaults creates a new memory instance with default configuration
|
||||||
|
CreateWithDefaults() (Manager, error)
|
||||||
|
}
|
||||||
98
agent/memory/manager.go
Normal file
98
agent/memory/manager.go
Normal file
|
|
@ -0,0 +1,98 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"sync"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Global manager instance
|
||||||
|
var globalManager Manager
|
||||||
|
|
||||||
|
// Init initializes the global memory manager with the given configuration
|
||||||
|
// Called by agent.Load() after loading agent DSL
|
||||||
|
func Init(config *Config) {
|
||||||
|
globalManager = NewManager(config)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMemory returns a memory instance for the given identifiers using the global manager
|
||||||
|
// This is the main entry point for creating Memory instances from agent/context
|
||||||
|
func GetMemory(userID, teamID, chatID, contextID string) (*Memory, error) {
|
||||||
|
if globalManager == nil {
|
||||||
|
// Initialize with defaults if not configured
|
||||||
|
globalManager = NewManagerWithDefaults()
|
||||||
|
}
|
||||||
|
return globalManager.Memory(userID, teamID, chatID, contextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes the global manager and releases resources
|
||||||
|
func Close() error {
|
||||||
|
if globalManager != nil {
|
||||||
|
err := globalManager.Close()
|
||||||
|
globalManager = nil
|
||||||
|
return err
|
||||||
|
}
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultManager is the default memory manager implementation
|
||||||
|
type DefaultManager struct {
|
||||||
|
config *Config
|
||||||
|
memories sync.Map // map[string]*Memory, key is composite of userID:teamID:chatID:contextID
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManager creates a new memory manager with the given configuration
|
||||||
|
func NewManager(config *Config) Manager {
|
||||||
|
if config == nil {
|
||||||
|
config = &Config{}
|
||||||
|
}
|
||||||
|
return &DefaultManager{
|
||||||
|
config: config,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// NewManagerWithDefaults creates a new memory manager with default configuration
|
||||||
|
func NewManagerWithDefaults() Manager {
|
||||||
|
return NewManager(&Config{
|
||||||
|
User: DefaultUserStore,
|
||||||
|
Team: DefaultTeamStore,
|
||||||
|
Chat: DefaultChatStore,
|
||||||
|
Context: DefaultContextStore,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
// memoryKey generates a unique key for the memory instance
|
||||||
|
func memoryKey(userID, teamID, chatID, contextID string) string {
|
||||||
|
return userID + ":" + teamID + ":" + chatID + ":" + contextID
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory returns the memory instance for given identifiers
|
||||||
|
func (m *DefaultManager) Memory(userID, teamID, chatID, contextID string) (*Memory, error) {
|
||||||
|
key := memoryKey(userID, teamID, chatID, contextID)
|
||||||
|
|
||||||
|
// Check if memory already exists
|
||||||
|
if val, ok := m.memories.Load(key); ok {
|
||||||
|
return val.(*Memory), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create new memory instance
|
||||||
|
mem, err := New(m.config, userID, teamID, chatID, contextID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, err
|
||||||
|
}
|
||||||
|
|
||||||
|
// Store and return (use LoadOrStore for thread safety)
|
||||||
|
actual, _ := m.memories.LoadOrStore(key, mem)
|
||||||
|
return actual.(*Memory), nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close closes all stores and releases resources
|
||||||
|
func (m *DefaultManager) Close() error {
|
||||||
|
// Clear all cached memory instances
|
||||||
|
m.memories.Range(func(key, value interface{}) bool {
|
||||||
|
m.memories.Delete(key)
|
||||||
|
return true
|
||||||
|
})
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// Ensure DefaultManager implements Manager
|
||||||
|
var _ Manager = (*DefaultManager)(nil)
|
||||||
185
agent/memory/memory.go
Normal file
185
agent/memory/memory.go
Normal file
|
|
@ -0,0 +1,185 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"fmt"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Default TTL values for each memory space
|
||||||
|
const (
|
||||||
|
DefaultUserTTL = 0 // No expiration for user-level memory
|
||||||
|
DefaultTeamTTL = 0 // No expiration for team-level memory
|
||||||
|
DefaultChatTTL = 24 * time.Hour // 24 hours for chat-level memory
|
||||||
|
DefaultContextTTL = 30 * time.Minute // 30 minutes for context-level memory
|
||||||
|
)
|
||||||
|
|
||||||
|
// New creates a new Memory instance with the given configuration and identifiers
|
||||||
|
func New(cfg *Config, userID, teamID, chatID, contextID string) (*Memory, error) {
|
||||||
|
if cfg == nil {
|
||||||
|
cfg = &Config{}
|
||||||
|
}
|
||||||
|
|
||||||
|
m := &Memory{
|
||||||
|
UserID: userID,
|
||||||
|
TeamID: teamID,
|
||||||
|
ChatID: chatID,
|
||||||
|
ContextID: contextID,
|
||||||
|
Config: cfg,
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize user namespace
|
||||||
|
if userID != "" {
|
||||||
|
ns, err := newNamespace(SpaceUser, userID, cfg.User, DefaultUserTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create user namespace: %w", err)
|
||||||
|
}
|
||||||
|
m.User = ns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize team namespace
|
||||||
|
if teamID != "" {
|
||||||
|
ns, err := newNamespace(SpaceTeam, teamID, cfg.Team, DefaultTeamTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create team namespace: %w", err)
|
||||||
|
}
|
||||||
|
m.Team = ns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize chat namespace
|
||||||
|
if chatID != "" {
|
||||||
|
ns, err := newNamespace(SpaceChat, chatID, cfg.Chat, DefaultChatTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create chat namespace: %w", err)
|
||||||
|
}
|
||||||
|
m.Chat = ns
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initialize context namespace
|
||||||
|
if contextID != "" {
|
||||||
|
ns, err := newNamespace(SpaceContext, contextID, cfg.Context, DefaultContextTTL)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to create context namespace: %w", err)
|
||||||
|
}
|
||||||
|
m.Context = ns
|
||||||
|
}
|
||||||
|
|
||||||
|
return m, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// newNamespace creates a new Namespace with the given parameters
|
||||||
|
func newNamespace(space Space, id, storeID string, defaultTTL time.Duration) (*Namespace, error) {
|
||||||
|
// Use default store ID if not specified
|
||||||
|
if storeID == "" {
|
||||||
|
switch space {
|
||||||
|
case SpaceUser:
|
||||||
|
storeID = DefaultUserStore
|
||||||
|
case SpaceTeam:
|
||||||
|
storeID = DefaultTeamStore
|
||||||
|
case SpaceChat:
|
||||||
|
storeID = DefaultChatStore
|
||||||
|
case SpaceContext:
|
||||||
|
storeID = DefaultContextStore
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get store instance
|
||||||
|
s, err := store.Get(storeID)
|
||||||
|
if err != nil {
|
||||||
|
return nil, fmt.Errorf("failed to get store %s: %w", storeID, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
return &Namespace{
|
||||||
|
Space: space,
|
||||||
|
ID: id,
|
||||||
|
Store: s,
|
||||||
|
StoreID: storeID,
|
||||||
|
Prefix: fmt.Sprintf("%s:%s:", space, id),
|
||||||
|
Default: defaultTTL,
|
||||||
|
}, nil
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetUser returns the user-level memory namespace accessor
|
||||||
|
func (m *Memory) GetUser() NamespaceAccessor {
|
||||||
|
if m.User == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.User
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetTeam returns the team-level memory namespace accessor
|
||||||
|
func (m *Memory) GetTeam() NamespaceAccessor {
|
||||||
|
if m.Team == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.Team
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetChat returns the chat-level memory namespace accessor
|
||||||
|
func (m *Memory) GetChat() NamespaceAccessor {
|
||||||
|
if m.Chat == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.Chat
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetContext returns the context-level memory namespace accessor
|
||||||
|
func (m *Memory) GetContext() NamespaceAccessor {
|
||||||
|
if m.Context == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
return m.Context
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpace returns a memory namespace by space type
|
||||||
|
func (m *Memory) GetSpace(space Space) NamespaceAccessor {
|
||||||
|
switch space {
|
||||||
|
case SpaceUser:
|
||||||
|
return m.GetUser()
|
||||||
|
case SpaceTeam:
|
||||||
|
return m.GetTeam()
|
||||||
|
case SpaceChat:
|
||||||
|
return m.GetChat()
|
||||||
|
case SpaceContext:
|
||||||
|
return m.GetContext()
|
||||||
|
default:
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetStats returns memory statistics for all namespaces
|
||||||
|
func (m *Memory) GetStats() *Stats {
|
||||||
|
stats := &Stats{}
|
||||||
|
|
||||||
|
if m.User != nil {
|
||||||
|
stats.User = m.User.Stats()
|
||||||
|
}
|
||||||
|
if m.Team != nil {
|
||||||
|
stats.Team = m.Team.Stats()
|
||||||
|
}
|
||||||
|
if m.Chat != nil {
|
||||||
|
stats.Chat = m.Chat.Stats()
|
||||||
|
}
|
||||||
|
if m.Context != nil {
|
||||||
|
stats.Context = m.Context.Stats()
|
||||||
|
}
|
||||||
|
|
||||||
|
return stats
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear clears all memory in all namespaces for this memory instance
|
||||||
|
func (m *Memory) Clear() {
|
||||||
|
if m.User != nil {
|
||||||
|
m.User.Clear()
|
||||||
|
}
|
||||||
|
if m.Team != nil {
|
||||||
|
m.Team.Clear()
|
||||||
|
}
|
||||||
|
if m.Chat != nil {
|
||||||
|
m.Chat.Clear()
|
||||||
|
}
|
||||||
|
if m.Context != nil {
|
||||||
|
m.Context.Clear()
|
||||||
|
}
|
||||||
|
}
|
||||||
391
agent/memory/memory_test.go
Normal file
391
agent/memory/memory_test.go
Normal file
|
|
@ -0,0 +1,391 @@
|
||||||
|
package memory_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/stretchr/testify/require"
|
||||||
|
"github.com/yaoapp/yao/agent/memory"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
|
)
|
||||||
|
|
||||||
|
func TestMemoryNew(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Create memory with default stores
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, mem)
|
||||||
|
|
||||||
|
// Verify all namespaces are initialized
|
||||||
|
assert.NotNil(t, mem.User)
|
||||||
|
assert.NotNil(t, mem.Team)
|
||||||
|
assert.NotNil(t, mem.Chat)
|
||||||
|
assert.NotNil(t, mem.Context)
|
||||||
|
|
||||||
|
// Verify IDs
|
||||||
|
assert.Equal(t, "user1", mem.UserID)
|
||||||
|
assert.Equal(t, "team1", mem.TeamID)
|
||||||
|
assert.Equal(t, "chat1", mem.ChatID)
|
||||||
|
assert.Equal(t, "ctx1", mem.ContextID)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryPartialIDs(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Create memory with only user and chat
|
||||||
|
mem, err := memory.New(nil, "user1", "", "chat1", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, mem)
|
||||||
|
|
||||||
|
// Only user and chat namespaces should be initialized
|
||||||
|
assert.NotNil(t, mem.User)
|
||||||
|
assert.Nil(t, mem.Team)
|
||||||
|
assert.NotNil(t, mem.Chat)
|
||||||
|
assert.Nil(t, mem.Context)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespaceBasicOperations(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test User namespace
|
||||||
|
t.Run("User namespace", func(t *testing.T) {
|
||||||
|
ns := mem.GetUser()
|
||||||
|
require.NotNil(t, ns)
|
||||||
|
|
||||||
|
// Set and Get
|
||||||
|
err := ns.Set("name", "John", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
val, ok := ns.Get("name")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "John", val)
|
||||||
|
|
||||||
|
// Has
|
||||||
|
assert.True(t, ns.Has("name"))
|
||||||
|
assert.False(t, ns.Has("nonexistent"))
|
||||||
|
|
||||||
|
// Del
|
||||||
|
err = ns.Del("name")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.False(t, ns.Has("name"))
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Team namespace
|
||||||
|
t.Run("Team namespace", func(t *testing.T) {
|
||||||
|
ns := mem.GetTeam()
|
||||||
|
require.NotNil(t, ns)
|
||||||
|
|
||||||
|
err := ns.Set("setting", "value", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
val, ok := ns.Get("setting")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "value", val)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Chat namespace
|
||||||
|
t.Run("Chat namespace", func(t *testing.T) {
|
||||||
|
ns := mem.GetChat()
|
||||||
|
require.NotNil(t, ns)
|
||||||
|
|
||||||
|
err := ns.Set("topic", "AI", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
val, ok := ns.Get("topic")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "AI", val)
|
||||||
|
})
|
||||||
|
|
||||||
|
// Test Context namespace
|
||||||
|
t.Run("Context namespace", func(t *testing.T) {
|
||||||
|
ns := mem.GetContext()
|
||||||
|
require.NotNil(t, ns)
|
||||||
|
|
||||||
|
err := ns.Set("temp", "data", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
val, ok := ns.Get("temp")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "data", val)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespaceIsolation(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
// Create two memory instances with different user IDs
|
||||||
|
mem1, err := memory.New(nil, "user1", "", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
mem2, err := memory.New(nil, "user2", "", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set value in user1's namespace
|
||||||
|
err = mem1.GetUser().Set("key", "user1_value", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set value in user2's namespace
|
||||||
|
err = mem2.GetUser().Set("key", "user2_value", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Verify isolation
|
||||||
|
val1, ok := mem1.GetUser().Get("key")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "user1_value", val1)
|
||||||
|
|
||||||
|
val2, ok := mem2.GetUser().Get("key")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "user2_value", val2)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespaceIncrDecr(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ns := mem.GetUser()
|
||||||
|
|
||||||
|
// Incr on non-existent key
|
||||||
|
val, err := ns.Incr("counter", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(1), val)
|
||||||
|
|
||||||
|
// Incr again
|
||||||
|
val, err = ns.Incr("counter", 5)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(6), val)
|
||||||
|
|
||||||
|
// Decr
|
||||||
|
val, err = ns.Decr("counter", 2)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, int64(4), val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespaceListOperations(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ns := mem.GetUser()
|
||||||
|
|
||||||
|
// Push values
|
||||||
|
err = ns.Push("list", "a", "b", "c")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// ArrayLen
|
||||||
|
assert.Equal(t, 3, ns.ArrayLen("list"))
|
||||||
|
|
||||||
|
// ArrayAll
|
||||||
|
all, err := ns.ArrayAll("list")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, all, 3)
|
||||||
|
|
||||||
|
// Pop from end
|
||||||
|
val, err := ns.Pop("list", 1)
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Equal(t, "c", val)
|
||||||
|
|
||||||
|
// ArrayLen after pop
|
||||||
|
assert.Equal(t, 2, ns.ArrayLen("list"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespaceSetOperations(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ns := mem.GetUser()
|
||||||
|
|
||||||
|
// AddToSet
|
||||||
|
err = ns.AddToSet("tags", "go", "rust", "go") // "go" should only appear once
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
all, err := ns.ArrayAll("tags")
|
||||||
|
require.NoError(t, err)
|
||||||
|
assert.Len(t, all, 2) // Only "go" and "rust"
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespaceTTL(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "", "", "", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ns := mem.GetContext()
|
||||||
|
|
||||||
|
// Set with short TTL
|
||||||
|
err = ns.Set("temp", "value", 100*time.Millisecond)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should exist immediately
|
||||||
|
val, ok := ns.Get("temp")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "value", val)
|
||||||
|
|
||||||
|
// Wait for expiration
|
||||||
|
time.Sleep(150 * time.Millisecond)
|
||||||
|
|
||||||
|
// Should be expired
|
||||||
|
_, ok = ns.Get("temp")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryClear(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set values in all namespaces
|
||||||
|
mem.GetUser().Set("key", "user_value", 0)
|
||||||
|
mem.GetTeam().Set("key", "team_value", 0)
|
||||||
|
mem.GetChat().Set("key", "chat_value", 0)
|
||||||
|
mem.GetContext().Set("key", "ctx_value", 0)
|
||||||
|
|
||||||
|
// Clear all
|
||||||
|
mem.Clear()
|
||||||
|
|
||||||
|
// All should be empty
|
||||||
|
_, ok := mem.GetUser().Get("key")
|
||||||
|
assert.False(t, ok)
|
||||||
|
_, ok = mem.GetTeam().Get("key")
|
||||||
|
assert.False(t, ok)
|
||||||
|
_, ok = mem.GetChat().Get("key")
|
||||||
|
assert.False(t, ok)
|
||||||
|
_, ok = mem.GetContext().Get("key")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestMemoryStats(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Set some values
|
||||||
|
mem.GetUser().Set("k1", "v1", 0)
|
||||||
|
mem.GetUser().Set("k2", "v2", 0)
|
||||||
|
mem.GetTeam().Set("k1", "v1", 0)
|
||||||
|
|
||||||
|
stats := mem.GetStats()
|
||||||
|
require.NotNil(t, stats)
|
||||||
|
|
||||||
|
assert.Equal(t, 2, stats.User.KeyCount)
|
||||||
|
assert.Equal(t, 1, stats.Team.KeyCount)
|
||||||
|
assert.Equal(t, 0, stats.Chat.KeyCount)
|
||||||
|
assert.Equal(t, 0, stats.Context.KeyCount)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestManager(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mgr := memory.NewManagerWithDefaults()
|
||||||
|
defer mgr.Close()
|
||||||
|
|
||||||
|
// Get memory instance
|
||||||
|
mem1, err := mgr.Memory("user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
require.NotNil(t, mem1)
|
||||||
|
|
||||||
|
// Set a value
|
||||||
|
err = mem1.GetUser().Set("key", "value", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Get same memory instance again
|
||||||
|
mem2, err := mgr.Memory("user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Should be the same instance (cached)
|
||||||
|
val, ok := mem2.GetUser().Get("key")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "value", val)
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestGetSpace(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "team1", "chat1", "ctx1")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// Test GetSpace
|
||||||
|
assert.NotNil(t, mem.GetSpace(memory.SpaceUser))
|
||||||
|
assert.NotNil(t, mem.GetSpace(memory.SpaceTeam))
|
||||||
|
assert.NotNil(t, mem.GetSpace(memory.SpaceChat))
|
||||||
|
assert.NotNil(t, mem.GetSpace(memory.SpaceContext))
|
||||||
|
|
||||||
|
// Invalid space
|
||||||
|
assert.Nil(t, mem.GetSpace(memory.Space("invalid")))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespaceGetMultiSetMulti(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ns := mem.GetUser()
|
||||||
|
|
||||||
|
// SetMulti
|
||||||
|
ns.SetMulti(map[string]interface{}{
|
||||||
|
"a": 1,
|
||||||
|
"b": 2,
|
||||||
|
"c": 3,
|
||||||
|
}, 0)
|
||||||
|
|
||||||
|
// GetMulti
|
||||||
|
result := ns.GetMulti([]string{"a", "b", "c"})
|
||||||
|
assert.Equal(t, 1, result["a"])
|
||||||
|
assert.Equal(t, 2, result["b"])
|
||||||
|
assert.Equal(t, 3, result["c"])
|
||||||
|
|
||||||
|
// DelMulti
|
||||||
|
ns.DelMulti([]string{"a", "b"})
|
||||||
|
assert.False(t, ns.Has("a"))
|
||||||
|
assert.False(t, ns.Has("b"))
|
||||||
|
assert.True(t, ns.Has("c"))
|
||||||
|
}
|
||||||
|
|
||||||
|
func TestNamespaceGetDel(t *testing.T) {
|
||||||
|
test.Prepare(t, config.Conf)
|
||||||
|
defer test.Clean()
|
||||||
|
|
||||||
|
mem, err := memory.New(nil, "user1", "", "", "")
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
ns := mem.GetUser()
|
||||||
|
|
||||||
|
// Set a value
|
||||||
|
err = ns.Set("key", "value", 0)
|
||||||
|
require.NoError(t, err)
|
||||||
|
|
||||||
|
// GetDel
|
||||||
|
val, ok := ns.GetDel("key")
|
||||||
|
assert.True(t, ok)
|
||||||
|
assert.Equal(t, "value", val)
|
||||||
|
|
||||||
|
// Should be deleted
|
||||||
|
_, ok = ns.Get("key")
|
||||||
|
assert.False(t, ok)
|
||||||
|
}
|
||||||
238
agent/memory/namespace.go
Normal file
238
agent/memory/namespace.go
Normal file
|
|
@ -0,0 +1,238 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Ensure Namespace implements NamespaceAccessor
|
||||||
|
var _ NamespaceAccessor = (*Namespace)(nil)
|
||||||
|
|
||||||
|
// GetID returns the namespace identifier
|
||||||
|
func (ns *Namespace) GetID() string {
|
||||||
|
return ns.ID
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSpace returns the space type of this namespace
|
||||||
|
func (ns *Namespace) GetSpace() Space {
|
||||||
|
return ns.Space
|
||||||
|
}
|
||||||
|
|
||||||
|
// prefixKey adds the namespace prefix to a key
|
||||||
|
func (ns *Namespace) prefixKey(key string) string {
|
||||||
|
return ns.Prefix + key
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get retrieves a value by key
|
||||||
|
func (ns *Namespace) Get(key string) (interface{}, bool) {
|
||||||
|
return ns.Store.Get(ns.prefixKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Set stores a value with the default TTL for this namespace
|
||||||
|
func (ns *Namespace) Set(key string, value interface{}, ttl time.Duration) error {
|
||||||
|
if ttl == 0 {
|
||||||
|
ttl = ns.Default
|
||||||
|
}
|
||||||
|
return ns.Store.Set(ns.prefixKey(key), value, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Has checks if a key exists
|
||||||
|
func (ns *Namespace) Has(key string) bool {
|
||||||
|
return ns.Store.Has(ns.prefixKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Del deletes a key (supports wildcards)
|
||||||
|
func (ns *Namespace) Del(key string) error {
|
||||||
|
return ns.Store.Del(ns.prefixKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys returns all keys in this namespace
|
||||||
|
func (ns *Namespace) Keys() []string {
|
||||||
|
allKeys := ns.Store.Keys()
|
||||||
|
prefixLen := len(ns.Prefix)
|
||||||
|
|
||||||
|
// Filter keys that belong to this namespace
|
||||||
|
var result []string
|
||||||
|
for _, key := range allKeys {
|
||||||
|
if len(key) >= prefixLen && key[:prefixLen] == ns.Prefix {
|
||||||
|
result = append(result, key[prefixLen:])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// Len returns the number of keys in this namespace
|
||||||
|
func (ns *Namespace) Len() int {
|
||||||
|
return len(ns.Keys())
|
||||||
|
}
|
||||||
|
|
||||||
|
// Clear deletes all keys in this namespace
|
||||||
|
func (ns *Namespace) Clear() {
|
||||||
|
ns.Store.Del(ns.Prefix + "*")
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSet retrieves a value and sets a new value if not exists
|
||||||
|
func (ns *Namespace) GetSet(key string, ttl time.Duration, getValue func(key string) (interface{}, error)) (interface{}, error) {
|
||||||
|
if ttl == 0 {
|
||||||
|
ttl = ns.Default
|
||||||
|
}
|
||||||
|
return ns.Store.GetSet(ns.prefixKey(key), ttl, getValue)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetDel retrieves a value and deletes it atomically
|
||||||
|
func (ns *Namespace) GetDel(key string) (interface{}, bool) {
|
||||||
|
return ns.Store.GetDel(ns.prefixKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetMulti retrieves multiple values by keys
|
||||||
|
func (ns *Namespace) GetMulti(keys []string) map[string]interface{} {
|
||||||
|
prefixedKeys := make([]string, len(keys))
|
||||||
|
for i, key := range keys {
|
||||||
|
prefixedKeys[i] = ns.prefixKey(key)
|
||||||
|
}
|
||||||
|
result := ns.Store.GetMulti(prefixedKeys)
|
||||||
|
|
||||||
|
// Remove prefix from result keys
|
||||||
|
unprefixed := make(map[string]interface{})
|
||||||
|
prefixLen := len(ns.Prefix)
|
||||||
|
for k, v := range result {
|
||||||
|
if len(k) > prefixLen {
|
||||||
|
unprefixed[k[prefixLen:]] = v
|
||||||
|
} else {
|
||||||
|
unprefixed[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unprefixed
|
||||||
|
}
|
||||||
|
|
||||||
|
// SetMulti stores multiple values
|
||||||
|
func (ns *Namespace) SetMulti(values map[string]interface{}, ttl time.Duration) {
|
||||||
|
if ttl == 0 {
|
||||||
|
ttl = ns.Default
|
||||||
|
}
|
||||||
|
prefixed := make(map[string]interface{})
|
||||||
|
for k, v := range values {
|
||||||
|
prefixed[ns.prefixKey(k)] = v
|
||||||
|
}
|
||||||
|
ns.Store.SetMulti(prefixed, ttl)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DelMulti deletes multiple keys
|
||||||
|
func (ns *Namespace) DelMulti(keys []string) {
|
||||||
|
prefixedKeys := make([]string, len(keys))
|
||||||
|
for i, key := range keys {
|
||||||
|
prefixedKeys[i] = ns.prefixKey(key)
|
||||||
|
}
|
||||||
|
ns.Store.DelMulti(prefixedKeys)
|
||||||
|
}
|
||||||
|
|
||||||
|
// GetSetMulti retrieves multiple values and sets new values if not exists
|
||||||
|
func (ns *Namespace) GetSetMulti(keys []string, ttl time.Duration, getValue func(key string) (interface{}, error)) map[string]interface{} {
|
||||||
|
if ttl == 0 {
|
||||||
|
ttl = ns.Default
|
||||||
|
}
|
||||||
|
prefixedKeys := make([]string, len(keys))
|
||||||
|
for i, key := range keys {
|
||||||
|
prefixedKeys[i] = ns.prefixKey(key)
|
||||||
|
}
|
||||||
|
result := ns.Store.GetSetMulti(prefixedKeys, ttl, getValue)
|
||||||
|
|
||||||
|
// Remove prefix from result keys
|
||||||
|
unprefixed := make(map[string]interface{})
|
||||||
|
prefixLen := len(ns.Prefix)
|
||||||
|
for k, v := range result {
|
||||||
|
if len(k) > prefixLen {
|
||||||
|
unprefixed[k[prefixLen:]] = v
|
||||||
|
} else {
|
||||||
|
unprefixed[k] = v
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return unprefixed
|
||||||
|
}
|
||||||
|
|
||||||
|
// Incr increments a numeric value
|
||||||
|
func (ns *Namespace) Incr(key string, delta int64) (int64, error) {
|
||||||
|
return ns.Store.Incr(ns.prefixKey(key), delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Decr decrements a numeric value
|
||||||
|
func (ns *Namespace) Decr(key string, delta int64) (int64, error) {
|
||||||
|
return ns.Store.Decr(ns.prefixKey(key), delta)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Push appends values to a list
|
||||||
|
func (ns *Namespace) Push(key string, values ...interface{}) error {
|
||||||
|
return ns.Store.Push(ns.prefixKey(key), values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pop removes and returns an element from a list
|
||||||
|
func (ns *Namespace) Pop(key string, position int) (interface{}, error) {
|
||||||
|
return ns.Store.Pop(ns.prefixKey(key), position)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Pull removes the first occurrence of a value from a list
|
||||||
|
func (ns *Namespace) Pull(key string, value interface{}) error {
|
||||||
|
return ns.Store.Pull(ns.prefixKey(key), value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// PullAll removes all occurrences of values from a list
|
||||||
|
func (ns *Namespace) PullAll(key string, values []interface{}) error {
|
||||||
|
return ns.Store.PullAll(ns.prefixKey(key), values)
|
||||||
|
}
|
||||||
|
|
||||||
|
// AddToSet adds values to a set (no duplicates)
|
||||||
|
func (ns *Namespace) AddToSet(key string, values ...interface{}) error {
|
||||||
|
return ns.Store.AddToSet(ns.prefixKey(key), values...)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArrayLen returns the length of a list
|
||||||
|
func (ns *Namespace) ArrayLen(key string) int {
|
||||||
|
return ns.Store.ArrayLen(ns.prefixKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArrayGet retrieves an element from a list by index
|
||||||
|
func (ns *Namespace) ArrayGet(key string, index int) (interface{}, error) {
|
||||||
|
return ns.Store.ArrayGet(ns.prefixKey(key), index)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArraySet sets an element in a list by index
|
||||||
|
func (ns *Namespace) ArraySet(key string, index int, value interface{}) error {
|
||||||
|
return ns.Store.ArraySet(ns.prefixKey(key), index, value)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArraySlice returns a slice of a list
|
||||||
|
func (ns *Namespace) ArraySlice(key string, skip, limit int) ([]interface{}, error) {
|
||||||
|
return ns.Store.ArraySlice(ns.prefixKey(key), skip, limit)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArrayPage returns a page of a list
|
||||||
|
func (ns *Namespace) ArrayPage(key string, page, pageSize int) ([]interface{}, error) {
|
||||||
|
return ns.Store.ArrayPage(ns.prefixKey(key), page, pageSize)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ArrayAll returns all elements of a list
|
||||||
|
func (ns *Namespace) ArrayAll(key string) ([]interface{}, error) {
|
||||||
|
return ns.Store.ArrayAll(ns.prefixKey(key))
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats returns statistics for this namespace
|
||||||
|
func (ns *Namespace) Stats() *NamespaceStats {
|
||||||
|
return &NamespaceStats{
|
||||||
|
Space: ns.Space,
|
||||||
|
ID: ns.ID,
|
||||||
|
KeyCount: ns.Len(),
|
||||||
|
StoreID: ns.StoreID,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Snapshot returns all key-value pairs in this namespace
|
||||||
|
// Used for recovery/resume functionality
|
||||||
|
func (ns *Namespace) Snapshot() map[string]interface{} {
|
||||||
|
keys := ns.Keys()
|
||||||
|
snapshot := make(map[string]interface{}, len(keys))
|
||||||
|
for _, key := range keys {
|
||||||
|
if value, ok := ns.Get(key); ok {
|
||||||
|
snapshot[key] = value
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return snapshot
|
||||||
|
}
|
||||||
99
agent/memory/types.go
Normal file
99
agent/memory/types.go
Normal file
|
|
@ -0,0 +1,99 @@
|
||||||
|
package memory
|
||||||
|
|
||||||
|
import (
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/yaoapp/gou/store"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Space defines the memory space type
|
||||||
|
type Space string
|
||||||
|
|
||||||
|
const (
|
||||||
|
// SpaceUser user-level memory, persists across all chats for a user
|
||||||
|
// Use case: user preferences, long-term knowledge, personal settings
|
||||||
|
SpaceUser Space = "user"
|
||||||
|
|
||||||
|
// SpaceTeam team-level memory, shared across all users in a team
|
||||||
|
// Use case: team knowledge, shared settings, collaborative data
|
||||||
|
SpaceTeam Space = "team"
|
||||||
|
|
||||||
|
// SpaceChat chat-level memory, persists within a single chat session
|
||||||
|
// Use case: conversation context, chat-specific settings, accumulated knowledge
|
||||||
|
SpaceChat Space = "chat"
|
||||||
|
|
||||||
|
// SpaceContext context-level memory, temporary within a single request context
|
||||||
|
// Use case: intermediate results, temporary variables, request-scoped cache
|
||||||
|
SpaceContext Space = "context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Config represents the memory configuration
|
||||||
|
// Each field is a Store ID referencing gou/store, empty string uses built-in default
|
||||||
|
// All spaces use xun-based storage by default for persistence and reliability
|
||||||
|
type Config struct {
|
||||||
|
User string `json:"user,omitempty" yaml:"user,omitempty"` // Store ID for user-level memory (default: xun-based)
|
||||||
|
Team string `json:"team,omitempty" yaml:"team,omitempty"` // Store ID for team-level memory (default: xun-based)
|
||||||
|
Chat string `json:"chat,omitempty" yaml:"chat,omitempty"` // Store ID for chat-level memory (default: xun-based)
|
||||||
|
Context string `json:"context,omitempty" yaml:"context,omitempty"` // Store ID for context-level memory (default: xun-based, shorter TTL)
|
||||||
|
}
|
||||||
|
|
||||||
|
// DefaultStoreID constants for built-in stores
|
||||||
|
const (
|
||||||
|
DefaultUserStore = "__yao.agent.memory.user"
|
||||||
|
DefaultTeamStore = "__yao.agent.memory.team"
|
||||||
|
DefaultChatStore = "__yao.agent.memory.chat"
|
||||||
|
DefaultContextStore = "__yao.agent.memory.context"
|
||||||
|
)
|
||||||
|
|
||||||
|
// Entry represents a memory entry
|
||||||
|
type Entry struct {
|
||||||
|
Key string `json:"key"`
|
||||||
|
Value interface{} `json:"value"`
|
||||||
|
Space Space `json:"space"`
|
||||||
|
Metadata map[string]interface{} `json:"metadata,omitempty"`
|
||||||
|
TTL time.Duration `json:"ttl,omitempty"`
|
||||||
|
CreatedAt time.Time `json:"created_at"`
|
||||||
|
UpdatedAt time.Time `json:"updated_at"`
|
||||||
|
ExpiresAt *time.Time `json:"expires_at,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Namespace represents a memory namespace for a specific space
|
||||||
|
type Namespace struct {
|
||||||
|
Space Space `json:"space"`
|
||||||
|
ID string `json:"id"` // UserID, TeamID, ChatID, or ContextID depending on space
|
||||||
|
Store store.Store `json:"-"` // Underlying store
|
||||||
|
StoreID string `json:"-"` // Store ID
|
||||||
|
Prefix string `json:"-"` // Computed key prefix (e.g., "user:123:", "team:456:")
|
||||||
|
Default time.Duration `json:"-"` // Default TTL for this namespace
|
||||||
|
}
|
||||||
|
|
||||||
|
// Memory represents the complete memory system for an agent
|
||||||
|
// It manages four separate namespaces: User, Team, Chat, and Context
|
||||||
|
type Memory struct {
|
||||||
|
UserID string `json:"user_id"`
|
||||||
|
TeamID string `json:"team_id"`
|
||||||
|
ChatID string `json:"chat_id"`
|
||||||
|
ContextID string `json:"context_id"`
|
||||||
|
|
||||||
|
User *Namespace `json:"-"` // User-level memory namespace
|
||||||
|
Team *Namespace `json:"-"` // Team-level memory namespace
|
||||||
|
Chat *Namespace `json:"-"` // Chat-level memory namespace
|
||||||
|
Context *Namespace `json:"-"` // Context-level memory namespace
|
||||||
|
Config *Config `json:"-"` // Memory configuration
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stats represents memory statistics
|
||||||
|
type Stats struct {
|
||||||
|
User *NamespaceStats `json:"user,omitempty"`
|
||||||
|
Team *NamespaceStats `json:"team,omitempty"`
|
||||||
|
Chat *NamespaceStats `json:"chat,omitempty"`
|
||||||
|
Context *NamespaceStats `json:"context,omitempty"`
|
||||||
|
}
|
||||||
|
|
||||||
|
// NamespaceStats represents statistics for a single memory namespace
|
||||||
|
type NamespaceStats struct {
|
||||||
|
Space Space `json:"space"`
|
||||||
|
ID string `json:"id"`
|
||||||
|
KeyCount int `json:"key_count"`
|
||||||
|
StoreID string `json:"store_id"`
|
||||||
|
}
|
||||||
|
|
@ -312,7 +312,7 @@ func TestSetJSAPIFactory(t *testing.T) {
|
||||||
require.NotNil(t, context.SearchAPIFactory)
|
require.NotNil(t, context.SearchAPIFactory)
|
||||||
|
|
||||||
// Create a mock context
|
// Create a mock context
|
||||||
ctx := &context.Context{}
|
ctx := context.New(nil, nil, "test-chat")
|
||||||
|
|
||||||
// Get search API
|
// Get search API
|
||||||
searchAPI := context.SearchAPIFactory(ctx)
|
searchAPI := context.SearchAPIFactory(ctx)
|
||||||
|
|
@ -337,7 +337,8 @@ func TestSetJSAPIFactory_WithGetter(t *testing.T) {
|
||||||
require.NotNil(t, context.SearchAPIFactory)
|
require.NotNil(t, context.SearchAPIFactory)
|
||||||
|
|
||||||
// Create a context with assistant ID
|
// Create a context with assistant ID
|
||||||
ctx := &context.Context{AssistantID: "test-assistant"}
|
ctx := context.New(nil, nil, "test-chat")
|
||||||
|
ctx.AssistantID = "test-assistant"
|
||||||
|
|
||||||
// Get search API
|
// Get search API
|
||||||
searchAPI := context.SearchAPIFactory(ctx)
|
searchAPI := context.SearchAPIFactory(ctx)
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,6 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/yaoapp/gou/plan"
|
|
||||||
agentContext "github.com/yaoapp/yao/agent/context"
|
agentContext "github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
|
|
@ -14,15 +13,10 @@ import (
|
||||||
|
|
||||||
// newTestContext creates a test context for MCP testing
|
// newTestContext creates a test context for MCP testing
|
||||||
func newTestContext() *agentContext.Context {
|
func newTestContext() *agentContext.Context {
|
||||||
ctx := &agentContext.Context{
|
ctx := agentContext.New(stdContext.Background(), nil, "test-chat")
|
||||||
Context: stdContext.Background(),
|
ctx.AssistantID = "test-assistant"
|
||||||
Space: plan.NewMemorySharedSpace(),
|
ctx.Locale = "en"
|
||||||
ID: "test-querydsl",
|
ctx.Referer = agentContext.RefererAPI
|
||||||
ChatID: "test-chat",
|
|
||||||
AssistantID: "test-assistant",
|
|
||||||
Locale: "en",
|
|
||||||
Referer: agentContext.RefererAPI,
|
|
||||||
}
|
|
||||||
stack, _, _ := agentContext.EnterStack(ctx, "test-assistant", &agentContext.Options{})
|
stack, _, _ := agentContext.EnterStack(ctx, "test-assistant", &agentContext.Options{})
|
||||||
ctx.Stack = stack
|
ctx.Stack = stack
|
||||||
return ctx
|
return ctx
|
||||||
|
|
|
||||||
491
data/bindata.go
491
data/bindata.go
File diff suppressed because it is too large
Load diff
|
|
@ -111,7 +111,7 @@ func DetectType(path string) Type {
|
||||||
return TypeModel
|
return TypeModel
|
||||||
case "conn":
|
case "conn":
|
||||||
return TypeConnector
|
return TypeConnector
|
||||||
case "lru", "redis", "mongo", "badger":
|
case "lru", "redis", "mongo", "xun":
|
||||||
return TypeStore
|
return TypeStore
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -187,7 +187,7 @@ func TypeRootAndExts(typ Type) (string, []string) {
|
||||||
case TypeAIGC:
|
case TypeAIGC:
|
||||||
return "aigcs", []string{".ai.yao", ".ai.jsonc", ".ai.json"}
|
return "aigcs", []string{".ai.yao", ".ai.jsonc", ".ai.json"}
|
||||||
case TypeStore:
|
case TypeStore:
|
||||||
return "stores", []string{".lru.yao", ".redis.yao", ".mongo.yao", ".badger.yao", ".store.yao", ".store.jsonc", ".store.json"}
|
return "stores", []string{".lru.yao", ".redis.yao", ".mongo.yao", ".xun.yao", ".store.yao", ".store.jsonc", ".store.json"}
|
||||||
default:
|
default:
|
||||||
return "", []string{}
|
return "", []string{}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -257,11 +257,6 @@ func TestDetectType(t *testing.T) {
|
||||||
path: filepath.Join("stores", "cache.mongo.yao"),
|
path: filepath.Join("stores", "cache.mongo.yao"),
|
||||||
want: TypeStore,
|
want: TypeStore,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
name: "Store Badger",
|
|
||||||
path: filepath.Join("stores", "cache.badger.yao"),
|
|
||||||
want: TypeStore,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
name: "Store by extension",
|
name: "Store by extension",
|
||||||
path: filepath.Join("stores", "cache.store.yao"),
|
path: filepath.Join("stores", "cache.store.yao"),
|
||||||
|
|
@ -510,7 +505,7 @@ func TestTypeRootAndExts(t *testing.T) {
|
||||||
name: "Store",
|
name: "Store",
|
||||||
typ: TypeStore,
|
typ: TypeStore,
|
||||||
wantRoot: "stores",
|
wantRoot: "stores",
|
||||||
wantExts: []string{".lru.yao", ".redis.yao", ".mongo.yao", ".badger.yao", ".store.yao", ".store.jsonc", ".store.json"},
|
wantExts: []string{".lru.yao", ".redis.yao", ".mongo.yao", ".xun.yao", ".store.yao", ".store.jsonc", ".store.json"},
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
name: "Unknown",
|
name: "Unknown",
|
||||||
|
|
|
||||||
10
go.mod
10
go.mod
|
|
@ -65,18 +65,13 @@ require (
|
||||||
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
github.com/cespare/xxhash/v2 v2.3.0 // indirect
|
||||||
github.com/cloudwego/base64x v0.1.5 // indirect
|
github.com/cloudwego/base64x v0.1.5 // indirect
|
||||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||||
github.com/dgraph-io/badger/v4 v4.7.0 // indirect
|
|
||||||
github.com/dgraph-io/ristretto/v2 v2.2.0 // indirect
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
|
||||||
github.com/dlclark/regexp2 v1.11.5 // indirect
|
github.com/dlclark/regexp2 v1.11.5 // indirect
|
||||||
github.com/dustin/go-humanize v1.0.1 // indirect
|
|
||||||
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6 // indirect
|
||||||
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
github.com/gabriel-vasile/mimetype v1.4.9 // indirect
|
||||||
github.com/gin-contrib/sse v1.1.0 // indirect
|
github.com/gin-contrib/sse v1.1.0 // indirect
|
||||||
github.com/go-errors/errors v1.5.1 // indirect
|
github.com/go-errors/errors v1.5.1 // indirect
|
||||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
|
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e // indirect
|
||||||
github.com/go-logr/logr v1.4.3 // indirect
|
|
||||||
github.com/go-logr/stdr v1.2.2 // indirect
|
|
||||||
github.com/go-playground/locales v0.14.1 // indirect
|
github.com/go-playground/locales v0.14.1 // indirect
|
||||||
github.com/go-playground/universal-translator v0.18.1 // indirect
|
github.com/go-playground/universal-translator v0.18.1 // indirect
|
||||||
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
github.com/go-playground/validator/v10 v10.26.0 // indirect
|
||||||
|
|
@ -87,7 +82,6 @@ require (
|
||||||
github.com/goccy/go-yaml v1.18.0 // indirect
|
github.com/goccy/go-yaml v1.18.0 // indirect
|
||||||
github.com/golang/protobuf v1.5.4 // indirect
|
github.com/golang/protobuf v1.5.4 // indirect
|
||||||
github.com/golang/snappy v1.0.0 // indirect
|
github.com/golang/snappy v1.0.0 // indirect
|
||||||
github.com/google/flatbuffers v25.2.10+incompatible // indirect
|
|
||||||
github.com/google/go-github/v30 v30.1.0 // indirect
|
github.com/google/go-github/v30 v30.1.0 // indirect
|
||||||
github.com/google/go-querystring v1.1.0 // indirect
|
github.com/google/go-querystring v1.1.0 // indirect
|
||||||
github.com/gorilla/websocket v1.5.3 // indirect
|
github.com/gorilla/websocket v1.5.3 // indirect
|
||||||
|
|
@ -125,6 +119,7 @@ require (
|
||||||
github.com/pkg/errors v0.9.1 // indirect
|
github.com/pkg/errors v0.9.1 // indirect
|
||||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||||
github.com/qdrant/go-client v1.14.0 // indirect
|
github.com/qdrant/go-client v1.14.0 // indirect
|
||||||
|
github.com/redis/go-redis/v9 v9.17.2 // indirect
|
||||||
github.com/richardlehane/mscfb v1.0.4 // indirect
|
github.com/richardlehane/mscfb v1.0.4 // indirect
|
||||||
github.com/richardlehane/msoleps v1.0.4 // indirect
|
github.com/richardlehane/msoleps v1.0.4 // indirect
|
||||||
github.com/rivo/uniseg v0.4.7 // indirect
|
github.com/rivo/uniseg v0.4.7 // indirect
|
||||||
|
|
@ -152,10 +147,7 @@ require (
|
||||||
github.com/xuri/nfp v0.0.1 // indirect
|
github.com/xuri/nfp v0.0.1 // indirect
|
||||||
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
github.com/yosida95/uritemplate/v3 v3.0.2 // indirect
|
||||||
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
github.com/youmark/pkcs8 v0.0.0-20240726163527-a2c0da244d78 // indirect
|
||||||
go.opentelemetry.io/auto/sdk v1.1.0 // indirect
|
|
||||||
go.opentelemetry.io/otel v1.37.0 // indirect
|
go.opentelemetry.io/otel v1.37.0 // indirect
|
||||||
go.opentelemetry.io/otel/metric v1.37.0 // indirect
|
|
||||||
go.opentelemetry.io/otel/trace v1.37.0 // indirect
|
|
||||||
golang.org/x/arch v0.17.0 // indirect
|
golang.org/x/arch v0.17.0 // indirect
|
||||||
golang.org/x/image v0.29.0 // indirect
|
golang.org/x/image v0.29.0 // indirect
|
||||||
golang.org/x/mod v0.29.0 // indirect
|
golang.org/x/mod v0.29.0 // indirect
|
||||||
|
|
|
||||||
19
go.sum
19
go.sum
|
|
@ -36,6 +36,10 @@ github.com/blang/semver/v4 v4.0.0 h1:1PFHFE6yCCTv8C1TeyNNarDzntLi7wMI5i/pzqYIsAM
|
||||||
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
github.com/blang/semver/v4 v4.0.0/go.mod h1:IbckMUScFkM3pff0VJDNKRiT6TG/YpiHIM2yvyW5YoQ=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc h1:biVzkmvwrH8WK8raXaxBx6fRVTlJILwEwQGL1I/ByEI=
|
||||||
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
github.com/boombuler/barcode v1.0.1-0.20190219062509-6c824513bacc/go.mod h1:paBWMcWSl3LHKBqUq+rly7CNSldXjb2rDl3JlRe0mD8=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
|
||||||
|
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
|
||||||
|
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
|
||||||
|
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
|
||||||
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
|
github.com/bufbuild/protocompile v0.4.0 h1:LbFKd2XowZvQ/kajzguUp2DC9UEIQhIq77fZZlaQsNA=
|
||||||
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
|
github.com/bufbuild/protocompile v0.4.0/go.mod h1:3v93+mbWn/v3xzN+31nwkJfrEpAUwp+BagBSZWx+TP8=
|
||||||
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
github.com/bytedance/sonic v1.13.2 h1:8/H1FempDZqC4VqjptGo14QQlJx8VdZJegxs6wwfqpQ=
|
||||||
|
|
@ -56,18 +60,10 @@ github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c
|
||||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||||
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
|
github.com/dchest/captcha v1.1.0 h1:2kt47EoYUUkaISobUdTbqwx55xvKOJxyScVfw25xzhQ=
|
||||||
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
|
github.com/dchest/captcha v1.1.0/go.mod h1:7zoElIawLp7GUMLcj54K9kbw+jEyvz2K0FDdRRYhvWo=
|
||||||
github.com/dgraph-io/badger/v4 v4.7.0 h1:Q+J8HApYAY7UMpL8d9owqiB+odzEc0zn/aqOD9jhc6Y=
|
|
||||||
github.com/dgraph-io/badger/v4 v4.7.0/go.mod h1:He7TzG3YBy3j4f5baj5B7Zl2XyfNe5bl4Udl0aPemVA=
|
|
||||||
github.com/dgraph-io/ristretto/v2 v2.2.0 h1:bkY3XzJcXoMuELV8F+vS8kzNgicwQFAaGINAEJdWGOM=
|
|
||||||
github.com/dgraph-io/ristretto/v2 v2.2.0/go.mod h1:RZrm63UmcBAaYWC1DotLYBmTvgkrs0+XhBd7Npn7/zI=
|
|
||||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da h1:aIftn67I1fkbMa512G+w+Pxci9hJPB8oMnkcP3iZF38=
|
|
||||||
github.com/dgryski/go-farm v0.0.0-20240924180020-3414d57e47da/go.mod h1:SqUrOPUnsFjfmXRMNPybcSiG0BgUW2AuFH8PAnS2iTw=
|
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
|
||||||
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
|
||||||
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZQ=
|
||||||
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8=
|
||||||
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
|
|
||||||
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
|
|
||||||
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
|
github.com/elazarl/go-bindata-assetfs v1.0.1 h1:m0kkaHRKEu7tUIUFVwhGGGYClXvyl4RE03qmvRTNfbw=
|
||||||
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
github.com/elazarl/go-bindata-assetfs v1.0.1/go.mod h1:v+YaWX3bdea5J/mo8dSETolEo7R71Vk1u8bnjau5yw4=
|
||||||
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
github.com/emersion/go-imap v1.2.1 h1:+s9ZjMEjOB8NzZMVTM3cCenz2JrQIGGo5j1df19WjTA=
|
||||||
|
|
@ -79,8 +75,6 @@ github.com/emersion/go-sasl v0.0.0-20241020182733-b788ff22d5a6/go.mod h1:iL2twTe
|
||||||
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
github.com/emersion/go-textwrapper v0.0.0-20200911093747-65d896831594/go.mod h1:aqO8z8wPrjkscevZJFVE1wXJrLpC5LtJG7fqLOsPb2U=
|
||||||
github.com/evanw/esbuild v0.25.4 h1:k1bTSim+usBG27w7BfOCorhgx3tO+6bAfMj5pR+6SKg=
|
github.com/evanw/esbuild v0.25.4 h1:k1bTSim+usBG27w7BfOCorhgx3tO+6bAfMj5pR+6SKg=
|
||||||
github.com/evanw/esbuild v0.25.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
github.com/evanw/esbuild v0.25.4/go.mod h1:D2vIQZqV/vIf/VRHtViaUtViZmG7o+kKmlBfVQuRi48=
|
||||||
github.com/expr-lang/expr v1.17.3 h1:myeTTuDFz7k6eFe/JPlep/UsiIjVhG61FMHFu63U7j0=
|
|
||||||
github.com/expr-lang/expr v1.17.3/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
|
||||||
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
|
github.com/expr-lang/expr v1.17.7 h1:Q0xY/e/2aCIp8g9s/LGvMDCC5PxYlvHgDZRQ4y16JX8=
|
||||||
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
github.com/expr-lang/expr v1.17.7/go.mod h1:8/vRC7+7HBzESEqt5kKpYXxrxkr31SaO8r40VO/1IT4=
|
||||||
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
github.com/fatih/color v1.13.0/go.mod h1:kLAiJbzzSOZDVNGyDpeOxJ47H46qBXwg5ILebYFFOfk=
|
||||||
|
|
@ -101,7 +95,6 @@ github.com/go-errors/errors v1.5.1 h1:ZwEMSLRCapFLflTpT7NKaAc7ukJ8ZPEjzlxt8rPN8b
|
||||||
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
github.com/go-errors/errors v1.5.1/go.mod h1:sIVyrIiJhuEF+Pj9Ebtd6P/rEYROXFi3BopGUQ5a5Og=
|
||||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e h1:Lf/gRkoycfOBPa42vU2bbgPurFong6zXeFtPoxholzU=
|
||||||
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
github.com/go-json-experiment/json v0.0.0-20251027170946-4849db3c2f7e/go.mod h1:uNVvRXArCGbZ508SxYYTC5v1JWoz2voff5pm25jU1Ok=
|
||||||
github.com/go-logr/logr v1.2.2/go.mod h1:jdQByPbusPIv2/zmleS9BjJVeZ6kBagPoEUsqbVz/1A=
|
|
||||||
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
github.com/go-logr/logr v1.4.3 h1:CjnDlHq8ikf6E492q6eKboGOC0T8CDaOvkHCIg8idEI=
|
||||||
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
github.com/go-logr/logr v1.4.3/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY=
|
||||||
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag=
|
||||||
|
|
@ -133,8 +126,6 @@ github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek
|
||||||
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps=
|
||||||
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
github.com/golang/snappy v1.0.0 h1:Oy607GVXHs7RtbggtPBnr2RmDArIsAefDwvrdWvRhGs=
|
||||||
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
github.com/golang/snappy v1.0.0/go.mod h1:/XxbfmMg8lxefKM7IXC3fBNl/7bRcc72aCRzEWrmP2Q=
|
||||||
github.com/google/flatbuffers v25.2.10+incompatible h1:F3vclr7C3HpB1k9mxCGRMXq6FdUalZ6H/pNX4FP1v0Q=
|
|
||||||
github.com/google/flatbuffers v25.2.10+incompatible/go.mod h1:1AeVuKshWv4vARoZatz6mlQ0JxURH0Kv5+zNeJKJCa8=
|
|
||||||
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
github.com/google/go-cmp v0.5.2/go.mod h1:v8dTdLbMG2kIc/vJvl+f65V22dbkXbowE6jgT/gNBxE=
|
||||||
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
github.com/google/go-cmp v0.6.0/go.mod h1:17dUlkBOakJ0+DkrSSNjCkIjxS6bF9zb3elmeNGIjoY=
|
||||||
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8=
|
||||||
|
|
@ -264,6 +255,8 @@ github.com/pquerna/otp v1.5.0 h1:NMMR+WrmaqXU4EzdGJEE1aUUI0AMRzsp96fFFWNPwxs=
|
||||||
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
github.com/pquerna/otp v1.5.0/go.mod h1:dkJfzwRKNiegxyNb54X/3fLwhCynbMspSyWKnvi1AEg=
|
||||||
github.com/qdrant/go-client v1.14.0 h1:cyz9OOooAexudw5w69LRe9vKCQFYJvaFvt9icOciI1U=
|
github.com/qdrant/go-client v1.14.0 h1:cyz9OOooAexudw5w69LRe9vKCQFYJvaFvt9icOciI1U=
|
||||||
github.com/qdrant/go-client v1.14.0/go.mod h1:iO8ts78jL4x6LDHFOViyYWELVtIBDTjOykBmiOTHLnQ=
|
github.com/qdrant/go-client v1.14.0/go.mod h1:iO8ts78jL4x6LDHFOViyYWELVtIBDTjOykBmiOTHLnQ=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
|
||||||
|
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
|
github.com/rhysd/go-github-selfupdate v1.2.3 h1:iaa+J202f+Nc+A8zi75uccC8Wg3omaM7HDeimXA22Ag=
|
||||||
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
|
github.com/rhysd/go-github-selfupdate v1.2.3/go.mod h1:mp/N8zj6jFfBQy/XMYoWsmfzxazpPAODuqarmPDe2Rg=
|
||||||
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
github.com/richardlehane/mscfb v1.0.4 h1:WULscsljNPConisD5hR0+OyZjwK46Pfyr6mPu5ZawpM=
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,7 @@ This guide provides comprehensive testing infrastructure for OAuth 2.1 authoriza
|
||||||
### Core Components
|
### Core Components
|
||||||
|
|
||||||
- **OAuth Service Configuration**: Complete OAuth 2.1 configuration with all features enabled
|
- **OAuth Service Configuration**: Complete OAuth 2.1 configuration with all features enabled
|
||||||
- **Store Management**: Support for MongoDB and Badger stores with automatic fallback
|
- **Store Management**: Support for MongoDB and Xun (database-backed) stores with automatic fallback
|
||||||
- **Test Data Sets**: Pre-configured clients and users for comprehensive testing
|
- **Test Data Sets**: Pre-configured clients and users for comprehensive testing
|
||||||
- **Environment Setup**: Standardized initialization and cleanup procedures
|
- **Environment Setup**: Standardized initialization and cleanup procedures
|
||||||
|
|
||||||
|
|
@ -110,7 +110,7 @@ source $YAO_SOURCE_ROOT/env.local.sh
|
||||||
func setupOAuthTestEnvironment(t *testing.T) (*Service, store.Store, store.Store, func()) {
|
func setupOAuthTestEnvironment(t *testing.T) (*Service, store.Store, store.Store, func()) {
|
||||||
// Creates complete OAuth test environment with:
|
// Creates complete OAuth test environment with:
|
||||||
// - Configured OAuth service with all features enabled
|
// - Configured OAuth service with all features enabled
|
||||||
// - Primary store (MongoDB preferred, Badger fallback)
|
// - Primary store (MongoDB preferred, Xun database fallback)
|
||||||
// - Cache store (LRU cache)
|
// - Cache store (LRU cache)
|
||||||
// - Pre-loaded test clients and users
|
// - Pre-loaded test clients and users
|
||||||
// - Cleanup function for proper teardown
|
// - Cleanup function for proper teardown
|
||||||
|
|
@ -325,7 +325,7 @@ export MONGO_TEST_PASS=test
|
||||||
|
|
||||||
### Common Issues
|
### Common Issues
|
||||||
|
|
||||||
1. **Store Connection**: Check MongoDB availability or use Badger fallback
|
1. **Store Connection**: Check MongoDB availability or use Xun (database) fallback
|
||||||
2. **Environment Setup**: Ensure `env.local.sh` is sourced
|
2. **Environment Setup**: Ensure `env.local.sh` is sourced
|
||||||
3. **Test Timeouts**: Increase timeout for slow operations
|
3. **Test Timeouts**: Increase timeout for slow operations
|
||||||
4. **Data Conflicts**: ✅ **RESOLVED** - Now automatically handled with unique test suffixes
|
4. **Data Conflicts**: ✅ **RESOLVED** - Now automatically handled with unique test suffixes
|
||||||
|
|
@ -361,7 +361,7 @@ Tests include comprehensive logging:
|
||||||
### Performance Considerations
|
### Performance Considerations
|
||||||
|
|
||||||
- **MongoDB**: Preferred for full feature testing
|
- **MongoDB**: Preferred for full feature testing
|
||||||
- **Badger**: Fast fallback for basic testing
|
- **Xun**: Database-backed fallback with LRU cache layer
|
||||||
- **Cache**: LRU cache for improved performance
|
- **Cache**: LRU cache for improved performance
|
||||||
- **Cleanup**: Efficient cleanup procedures
|
- **Cleanup**: Efficient cleanup procedures
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,6 @@ import (
|
||||||
"crypto/rand"
|
"crypto/rand"
|
||||||
"fmt"
|
"fmt"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"strings"
|
"strings"
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
@ -15,8 +14,8 @@ import (
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
"github.com/yaoapp/gou/model"
|
"github.com/yaoapp/gou/model"
|
||||||
"github.com/yaoapp/gou/store"
|
"github.com/yaoapp/gou/store"
|
||||||
"github.com/yaoapp/gou/store/badger"
|
|
||||||
"github.com/yaoapp/gou/store/lru"
|
"github.com/yaoapp/gou/store/lru"
|
||||||
|
"github.com/yaoapp/gou/store/xun"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
|
|
@ -332,7 +331,7 @@ func setupOAuthTestEnvironment(t *testing.T) (*Service, store.Store, store.Store
|
||||||
// Get store configurations
|
// Get store configurations
|
||||||
storeConfigs := getStoreConfigs()
|
storeConfigs := getStoreConfigs()
|
||||||
|
|
||||||
// Use the first available store (prefer MongoDB, fallback to Badger)
|
// Use the first available store (prefer MongoDB, fallback to Xun)
|
||||||
var mainStore store.Store
|
var mainStore store.Store
|
||||||
var storeConfig StoreConfig
|
var storeConfig StoreConfig
|
||||||
|
|
||||||
|
|
@ -357,10 +356,10 @@ func setupOAuthTestEnvironment(t *testing.T) (*Service, store.Store, store.Store
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Fallback to Badger if no other store is available
|
// Fallback to Xun if no other store is available
|
||||||
if mainStore == nil {
|
if mainStore == nil {
|
||||||
mainStore = getBadgerStore(t)
|
mainStore = getXunStore(t)
|
||||||
storeConfig = StoreConfig{Name: "Badger", GetFunc: getBadgerStore}
|
storeConfig = StoreConfig{Name: "Xun", GetFunc: getXunStore}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Create cache
|
// Create cache
|
||||||
|
|
@ -656,18 +655,25 @@ func getMongoStore(t *testing.T) store.Store {
|
||||||
return mongoStore
|
return mongoStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBadgerStore(t *testing.T) store.Store {
|
func getXunStore(t *testing.T) store.Store {
|
||||||
tempDir := t.TempDir()
|
// Use test.Prepare to ensure database connection is initialized
|
||||||
dbPath := filepath.Join(tempDir, "test_oauth_badger")
|
test.Prepare(t, config.Conf)
|
||||||
|
|
||||||
badgerStore, err := badger.New(dbPath)
|
// Create xun store using default database connection
|
||||||
|
xunStore, err := xun.New(xun.Option{
|
||||||
|
Table: "__yao_oauth_test",
|
||||||
|
Connector: "default",
|
||||||
|
CacheSize: 1024,
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
badgerStore.Close()
|
xunStore.Clear()
|
||||||
|
xunStore.Close()
|
||||||
|
test.Clean()
|
||||||
})
|
})
|
||||||
|
|
||||||
return badgerStore
|
return xunStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLRUCache(t *testing.T) store.Store {
|
func getLRUCache(t *testing.T) store.Store {
|
||||||
|
|
@ -679,7 +685,7 @@ func getLRUCache(t *testing.T) store.Store {
|
||||||
func getStoreConfigs() []StoreConfig {
|
func getStoreConfigs() []StoreConfig {
|
||||||
return []StoreConfig{
|
return []StoreConfig{
|
||||||
{Name: "MongoDB", GetFunc: getMongoStore},
|
{Name: "MongoDB", GetFunc: getMongoStore},
|
||||||
{Name: "Badger", GetFunc: getBadgerStore},
|
{Name: "Xun", GetFunc: getXunStore},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -795,7 +801,7 @@ func TestNewService(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("create service with missing issuer URL", func(t *testing.T) {
|
t.Run("create service with missing issuer URL", func(t *testing.T) {
|
||||||
store := getBadgerStore(t)
|
store := getXunStore(t)
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Store: store,
|
Store: store,
|
||||||
Signing: types.SigningConfig{
|
Signing: types.SigningConfig{
|
||||||
|
|
@ -836,7 +842,7 @@ func TestServiceGetters(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
func TestConfigDefaults(t *testing.T) {
|
func TestConfigDefaults(t *testing.T) {
|
||||||
store := getBadgerStore(t)
|
store := getXunStore(t)
|
||||||
|
|
||||||
t.Run("set default values", func(t *testing.T) {
|
t.Run("set default values", func(t *testing.T) {
|
||||||
config := &Config{
|
config := &Config{
|
||||||
|
|
@ -915,7 +921,7 @@ func TestFeatureFlags(t *testing.T) {
|
||||||
|
|
||||||
func TestProviderInitialization(t *testing.T) {
|
func TestProviderInitialization(t *testing.T) {
|
||||||
t.Run("default providers created when not provided", func(t *testing.T) {
|
t.Run("default providers created when not provided", func(t *testing.T) {
|
||||||
store := getBadgerStore(t)
|
store := getXunStore(t)
|
||||||
cache := getLRUCache(t)
|
cache := getLRUCache(t)
|
||||||
|
|
||||||
config := &Config{
|
config := &Config{
|
||||||
|
|
@ -942,7 +948,7 @@ func TestProviderInitialization(t *testing.T) {
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("custom providers used when provided", func(t *testing.T) {
|
t.Run("custom providers used when provided", func(t *testing.T) {
|
||||||
store := getBadgerStore(t)
|
store := getXunStore(t)
|
||||||
cache := getLRUCache(t)
|
cache := getLRUCache(t)
|
||||||
|
|
||||||
// Create a temporary service to get default providers for testing
|
// Create a temporary service to get default providers for testing
|
||||||
|
|
@ -1026,7 +1032,7 @@ func TestServiceIntegration(t *testing.T) {
|
||||||
func TestConfigValidation(t *testing.T) {
|
func TestConfigValidation(t *testing.T) {
|
||||||
t.Run("valid configuration", func(t *testing.T) {
|
t.Run("valid configuration", func(t *testing.T) {
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Store: getBadgerStore(t),
|
Store: getXunStore(t),
|
||||||
IssuerURL: "https://test.example.com",
|
IssuerURL: "https://test.example.com",
|
||||||
Signing: types.SigningConfig{
|
Signing: types.SigningConfig{
|
||||||
SigningCertPath: testCertPath,
|
SigningCertPath: testCertPath,
|
||||||
|
|
@ -1069,7 +1075,7 @@ func TestConfigValidation(t *testing.T) {
|
||||||
|
|
||||||
t.Run("missing issuer URL", func(t *testing.T) {
|
t.Run("missing issuer URL", func(t *testing.T) {
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Store: getBadgerStore(t),
|
Store: getXunStore(t),
|
||||||
Signing: types.SigningConfig{
|
Signing: types.SigningConfig{
|
||||||
SigningCertPath: testCertPath,
|
SigningCertPath: testCertPath,
|
||||||
SigningKeyPath: testKeyPath,
|
SigningKeyPath: testKeyPath,
|
||||||
|
|
@ -1088,7 +1094,7 @@ func TestConfigValidation(t *testing.T) {
|
||||||
|
|
||||||
t.Run("partial certificate configuration", func(t *testing.T) {
|
t.Run("partial certificate configuration", func(t *testing.T) {
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Store: getBadgerStore(t),
|
Store: getXunStore(t),
|
||||||
IssuerURL: "https://test.example.com",
|
IssuerURL: "https://test.example.com",
|
||||||
Signing: types.SigningConfig{
|
Signing: types.SigningConfig{
|
||||||
SigningCertPath: testCertPath, // Only cert path, missing key path
|
SigningCertPath: testCertPath, // Only cert path, missing key path
|
||||||
|
|
@ -1108,7 +1114,7 @@ func TestConfigValidation(t *testing.T) {
|
||||||
|
|
||||||
t.Run("invalid token lifetime", func(t *testing.T) {
|
t.Run("invalid token lifetime", func(t *testing.T) {
|
||||||
config := &Config{
|
config := &Config{
|
||||||
Store: getBadgerStore(t),
|
Store: getXunStore(t),
|
||||||
IssuerURL: "https://test.example.com",
|
IssuerURL: "https://test.example.com",
|
||||||
Signing: types.SigningConfig{
|
Signing: types.SigningConfig{
|
||||||
SigningCertPath: testCertPath,
|
SigningCertPath: testCertPath,
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,6 @@ package client
|
||||||
import (
|
import (
|
||||||
"context"
|
"context"
|
||||||
"os"
|
"os"
|
||||||
"path/filepath"
|
|
||||||
"testing"
|
"testing"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
|
|
@ -11,9 +10,11 @@ import (
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
"github.com/yaoapp/gou/store"
|
"github.com/yaoapp/gou/store"
|
||||||
"github.com/yaoapp/gou/store/badger"
|
|
||||||
"github.com/yaoapp/gou/store/lru"
|
"github.com/yaoapp/gou/store/lru"
|
||||||
|
"github.com/yaoapp/gou/store/xun"
|
||||||
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/openapi/oauth/types"
|
"github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
|
"github.com/yaoapp/yao/test"
|
||||||
)
|
)
|
||||||
|
|
||||||
// Store configuration for parameterized tests
|
// Store configuration for parameterized tests
|
||||||
|
|
@ -52,20 +53,26 @@ func getMongoStore(t *testing.T) store.Store {
|
||||||
return mongoStore
|
return mongoStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func getBadgerStore(t *testing.T) store.Store {
|
func getXunStore(t *testing.T) store.Store {
|
||||||
// Create temporary directory for test database
|
// Use test.Prepare to initialize the environment (database, etc.)
|
||||||
tempDir := t.TempDir()
|
test.Prepare(t, config.Conf)
|
||||||
dbPath := filepath.Join(tempDir, "test_oauth_badger")
|
|
||||||
|
|
||||||
badgerStore, err := badger.New(dbPath)
|
// Create xun store using default database connection
|
||||||
|
xunStore, err := xun.New(xun.Option{
|
||||||
|
Table: "__yao_oauth_client_test",
|
||||||
|
Connector: "default",
|
||||||
|
CacheSize: 1024,
|
||||||
|
})
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
|
|
||||||
// Clean up on test completion
|
// Clean up on test completion
|
||||||
t.Cleanup(func() {
|
t.Cleanup(func() {
|
||||||
badgerStore.Close()
|
xunStore.Clear()
|
||||||
|
xunStore.Close()
|
||||||
|
test.Clean()
|
||||||
})
|
})
|
||||||
|
|
||||||
return badgerStore
|
return xunStore
|
||||||
}
|
}
|
||||||
|
|
||||||
func getLRUCache(t *testing.T) store.Store {
|
func getLRUCache(t *testing.T) store.Store {
|
||||||
|
|
@ -78,7 +85,7 @@ func getLRUCache(t *testing.T) store.Store {
|
||||||
func getStoreConfigs() []StoreConfig {
|
func getStoreConfigs() []StoreConfig {
|
||||||
return []StoreConfig{
|
return []StoreConfig{
|
||||||
{Name: "MongoDB", GetFunc: getMongoStore},
|
{Name: "MongoDB", GetFunc: getMongoStore},
|
||||||
{Name: "Badger", GetFunc: getBadgerStore},
|
{Name: "Xun", GetFunc: getXunStore},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,15 +14,18 @@ import (
|
||||||
)
|
)
|
||||||
|
|
||||||
var systemStores = map[string]string{
|
var systemStores = map[string]string{
|
||||||
"__yao.store": "yao/stores/store.badger.yao", // for common data store
|
"__yao.store": "yao/stores/store.xun.yao", // for common data store
|
||||||
"__yao.cache": "yao/stores/cache.lru.yao", // for common cache store
|
"__yao.cache": "yao/stores/cache.lru.yao", // for common cache store
|
||||||
"__yao.oauth.store": "yao/stores/oauth/store.badger.yao", // for OAuth data store
|
"__yao.oauth.store": "yao/stores/oauth/store.xun.yao", // for OAuth data store
|
||||||
"__yao.oauth.cache": "yao/stores/oauth/cache.lru.yao", // for OAuth cache store
|
"__yao.oauth.cache": "yao/stores/oauth/cache.lru.yao", // for OAuth cache store
|
||||||
"__yao.oauth.client": "yao/stores/oauth/client.badger.yao", // for OAuth client store
|
"__yao.oauth.client": "yao/stores/oauth/client.xun.yao", // for OAuth client store
|
||||||
"__yao.agent.memory": "yao/stores/agent/memory.badger.yao", // for agent memory store (for agent memory)
|
"__yao.agent.memory.user": "yao/stores/agent/memory/user.xun.yao", // for agent user-level memory
|
||||||
"__yao.agent.cache": "yao/stores/agent/cache.lru.yao", // for agent cache store (for agent cache)
|
"__yao.agent.memory.team": "yao/stores/agent/memory/team.xun.yao", // for agent team-level memory
|
||||||
"__yao.kb.store": "yao/stores/kb/store.badger.yao", // for knowledge base store
|
"__yao.agent.memory.chat": "yao/stores/agent/memory/chat.xun.yao", // for agent chat-level memory
|
||||||
"__yao.kb.cache": "yao/stores/kb/cache.lru.yao", // for knowledge base cache store
|
"__yao.agent.memory.context": "yao/stores/agent/memory/context.xun.yao", // for agent context-level memory
|
||||||
|
"__yao.agent.cache": "yao/stores/agent/cache.lru.yao", // for agent cache store
|
||||||
|
"__yao.kb.store": "yao/stores/kb/store.xun.yao", // for knowledge base store
|
||||||
|
"__yao.kb.cache": "yao/stores/kb/cache.lru.yao", // for knowledge base cache store
|
||||||
}
|
}
|
||||||
|
|
||||||
// replaceVars replaces template variables in the JSON string
|
// replaceVars replaces template variables in the JSON string
|
||||||
|
|
|
||||||
|
|
@ -43,7 +43,10 @@ func check(t *testing.T) {
|
||||||
assert.True(t, ids["__yao.oauth.store"])
|
assert.True(t, ids["__yao.oauth.store"])
|
||||||
assert.True(t, ids["__yao.oauth.client"])
|
assert.True(t, ids["__yao.oauth.client"])
|
||||||
assert.True(t, ids["__yao.oauth.cache"])
|
assert.True(t, ids["__yao.oauth.cache"])
|
||||||
assert.True(t, ids["__yao.agent.memory"])
|
assert.True(t, ids["__yao.agent.memory.user"])
|
||||||
|
assert.True(t, ids["__yao.agent.memory.team"])
|
||||||
|
assert.True(t, ids["__yao.agent.memory.chat"])
|
||||||
|
assert.True(t, ids["__yao.agent.memory.context"])
|
||||||
assert.True(t, ids["__yao.agent.cache"])
|
assert.True(t, ids["__yao.agent.cache"])
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -220,15 +220,18 @@ var testSystemModels = map[string]string{
|
||||||
}
|
}
|
||||||
|
|
||||||
var testSystemStores = map[string]string{
|
var testSystemStores = map[string]string{
|
||||||
"__yao.store": "yao/stores/store.badger.yao",
|
"__yao.store": "yao/stores/store.xun.yao",
|
||||||
"__yao.cache": "yao/stores/cache.lru.yao",
|
"__yao.cache": "yao/stores/cache.lru.yao",
|
||||||
"__yao.oauth.store": "yao/stores/oauth/store.badger.yao",
|
"__yao.oauth.store": "yao/stores/oauth/store.xun.yao",
|
||||||
"__yao.oauth.client": "yao/stores/oauth/client.badger.yao",
|
"__yao.oauth.client": "yao/stores/oauth/client.xun.yao",
|
||||||
"__yao.oauth.cache": "yao/stores/oauth/cache.lru.yao",
|
"__yao.oauth.cache": "yao/stores/oauth/cache.lru.yao",
|
||||||
"__yao.agent.memory": "yao/stores/agent/memory.badger.yao",
|
"__yao.agent.memory.user": "yao/stores/agent/memory/user.xun.yao",
|
||||||
"__yao.agent.cache": "yao/stores/agent/cache.lru.yao",
|
"__yao.agent.memory.team": "yao/stores/agent/memory/team.xun.yao",
|
||||||
"__yao.kb.store": "yao/stores/kb/store.badger.yao",
|
"__yao.agent.memory.chat": "yao/stores/agent/memory/chat.xun.yao",
|
||||||
"__yao.kb.cache": "yao/stores/kb/cache.lru.yao",
|
"__yao.agent.memory.context": "yao/stores/agent/memory/context.xun.yao",
|
||||||
|
"__yao.agent.cache": "yao/stores/agent/cache.lru.yao",
|
||||||
|
"__yao.kb.store": "yao/stores/kb/store.xun.yao",
|
||||||
|
"__yao.kb.cache": "yao/stores/kb/cache.lru.yao",
|
||||||
}
|
}
|
||||||
|
|
||||||
func loadSystemStores(t *testing.T, cfg config.Config) error {
|
func loadSystemStores(t *testing.T, cfg config.Config) error {
|
||||||
|
|
@ -253,24 +256,6 @@ func loadSystemStores(t *testing.T, cfg config.Config) error {
|
||||||
source = replaceVars(source, vars)
|
source = replaceVars(source, vars)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse store config to check if we need to create directories (for badger stores)
|
|
||||||
var storeConfig map[string]interface{}
|
|
||||||
if err := application.Parse(path, []byte(source), &storeConfig); err == nil {
|
|
||||||
// Check if this is a badger store
|
|
||||||
if storeType, ok := storeConfig["type"].(string); ok && storeType == "badger" {
|
|
||||||
// Extract the path from option.path
|
|
||||||
if option, ok := storeConfig["option"].(map[string]interface{}); ok {
|
|
||||||
if storePath, ok := option["path"].(string); ok {
|
|
||||||
// Create directory for badger store
|
|
||||||
if err := os.MkdirAll(storePath, 0755); err != nil {
|
|
||||||
log.Error("failed to create directory for store %s at %s: %s", id, storePath, err.Error())
|
|
||||||
return fmt.Errorf("failed to create directory for store %s: %w", id, err)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load store with the processed source
|
// Load store with the processed source
|
||||||
_, err = store.LoadSource([]byte(source), id, filepath.Join("__system", path))
|
_, err = store.LoadSource([]byte(source), id, filepath.Join("__system", path))
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -574,10 +559,8 @@ func dbconnect(t *testing.T, cfg config.Config) {
|
||||||
switch cfg.DB.Driver {
|
switch cfg.DB.Driver {
|
||||||
case "sqlite3":
|
case "sqlite3":
|
||||||
capsule.AddConn("primary", "sqlite3", cfg.DB.Primary[0]).SetAsGlobal()
|
capsule.AddConn("primary", "sqlite3", cfg.DB.Primary[0]).SetAsGlobal()
|
||||||
break
|
|
||||||
default:
|
default:
|
||||||
capsule.AddConn("primary", "mysql", cfg.DB.Primary[0]).SetAsGlobal()
|
capsule.AddConn("primary", "mysql", cfg.DB.Primary[0]).SetAsGlobal()
|
||||||
break
|
|
||||||
}
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
107
trace/KNOWN_ISSUES.md
Normal file
107
trace/KNOWN_ISSUES.md
Normal file
|
|
@ -0,0 +1,107 @@
|
||||||
|
# Trace Module - Known Issues
|
||||||
|
|
||||||
|
This document tracks known issues in the Trace module that are scheduled for refactoring.
|
||||||
|
|
||||||
|
## Goroutine Leak
|
||||||
|
|
||||||
|
### Symptom
|
||||||
|
|
||||||
|
Each trace creation starts 2 goroutines that accumulate during rapid iterations:
|
||||||
|
|
||||||
|
1. `trace/pubsub.(*PubSub).forward()` - PubSub event forwarding
|
||||||
|
2. `trace.(*manager).startStateWorker()` - State machine worker
|
||||||
|
|
||||||
|
### Evidence
|
||||||
|
|
||||||
|
```
|
||||||
|
Goroutine growth by function:
|
||||||
|
Function Initial Final Growth
|
||||||
|
--------------------------------------------------------------------------
|
||||||
|
github.com/yaoapp/yao/trace. 0 10 10
|
||||||
|
github.com/yaoapp/yao/trace/pubsub. 0 10 10
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
- Goroutines exit when `Release()` closes their channels
|
||||||
|
- Exit is **asynchronous** (goroutine needs to reach select statement)
|
||||||
|
- Go runtime needs time to schedule and cleanup
|
||||||
|
- In rapid iterations, new goroutines are created before old ones fully exit
|
||||||
|
|
||||||
|
### Current Behavior
|
||||||
|
|
||||||
|
- **NOT a true leak**: Goroutines eventually exit (channels are closed)
|
||||||
|
- **No unbounded growth**: They will be GC'd eventually
|
||||||
|
- **Typical pattern**: Async cleanup in Go
|
||||||
|
|
||||||
|
### Impact on Tests
|
||||||
|
|
||||||
|
Memory leak tests use a 20KB/iteration threshold to accommodate this overhead:
|
||||||
|
|
||||||
|
| Test | Actual Growth | Threshold |
|
||||||
|
| ----------------- | -------------- | --------- |
|
||||||
|
| StandardMode | ~11-15 KB/iter | 20 KB |
|
||||||
|
| BusinessScenarios | ~13-16 KB/iter | 20 KB |
|
||||||
|
| NestedCalls | ~13 KB/iter | 20 KB |
|
||||||
|
|
||||||
|
## Memory Growth
|
||||||
|
|
||||||
|
### Symptom
|
||||||
|
|
||||||
|
Linear memory growth during trace operations:
|
||||||
|
|
||||||
|
```
|
||||||
|
Batch | Iterations | HeapAlloc (MB) | Growth/iter (bytes)
|
||||||
|
------|------------|----------------|--------------------
|
||||||
|
1 | 1000 | 23.28 | 12014.42
|
||||||
|
2 | 2000 | 37.06 | 13229.02
|
||||||
|
3 | 3000 | 50.63 | 13562.39
|
||||||
|
4 | 4000 | 64.54 | 13819.49
|
||||||
|
5 | 5000 | 78.15 | 13910.01
|
||||||
|
```
|
||||||
|
|
||||||
|
### Root Cause
|
||||||
|
|
||||||
|
Trace-related objects are not fully released during `ctx.Release()`:
|
||||||
|
|
||||||
|
- State machine data
|
||||||
|
- PubSub subscriptions
|
||||||
|
- Trace node references
|
||||||
|
|
||||||
|
### Workaround
|
||||||
|
|
||||||
|
The 20KB threshold in memory leak tests accommodates this known overhead while still detecting severe leaks (50KB+ growth would indicate a real problem).
|
||||||
|
|
||||||
|
## Planned Refactoring
|
||||||
|
|
||||||
|
The Trace module is scheduled for refactoring to address:
|
||||||
|
|
||||||
|
1. **Synchronous cleanup**: Ensure goroutines exit before `Release()` returns
|
||||||
|
2. **Memory management**: Properly release all trace-related objects
|
||||||
|
3. **Resource pooling**: Consider reusing trace resources to reduce allocation overhead
|
||||||
|
|
||||||
|
## Testing Notes
|
||||||
|
|
||||||
|
When running memory leak tests:
|
||||||
|
|
||||||
|
- `TestMemoryLeakStandardMode`: 20KB threshold
|
||||||
|
- `TestMemoryLeakBusinessScenarios`: 20KB threshold
|
||||||
|
- `TestMemoryLeakNestedCalls`: 20KB threshold
|
||||||
|
- `TestMemoryLeakNestedConcurrent`: 25KB threshold (concurrent + DB operations)
|
||||||
|
|
||||||
|
These thresholds are intentionally higher than actual growth to:
|
||||||
|
|
||||||
|
1. Accommodate CI environment variations
|
||||||
|
2. Allow for GC timing differences
|
||||||
|
3. Still catch severe leaks (50KB+ would be concerning)
|
||||||
|
|
||||||
|
## Related Files
|
||||||
|
|
||||||
|
- `trace/manager.go` - State machine and goroutine management
|
||||||
|
- `trace/pubsub/pubsub.go` - PubSub forwarding goroutine
|
||||||
|
- `trace/trace.go` - Release() implementation
|
||||||
|
- `agent/assistant/hook/create_mem_test.go` - Memory leak tests
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
_Last updated: December 2025_
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"label": "Agent Memory Store",
|
|
||||||
"description": "Badger-based store for agent memory persistence and long-term storage",
|
|
||||||
"tags": ["agent", "memory", "badger", "ai"],
|
|
||||||
"readonly": false,
|
|
||||||
"builtin": true,
|
|
||||||
"sort": 20,
|
|
||||||
"name": "Agent Memory Store",
|
|
||||||
"type": "badger",
|
|
||||||
"option": {
|
|
||||||
"path": "{{ YAO_DATA_ROOT }}/stores/agent/memory"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
18
yao/stores/agent/memory/chat.xun.yao
Normal file
18
yao/stores/agent/memory/chat.xun.yao
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"label": "Agent Memory - Chat",
|
||||||
|
"description": "Chat-level memory store for conversation context, chat-specific settings, and accumulated knowledge",
|
||||||
|
"tags": ["agent", "memory", "chat", "xun"],
|
||||||
|
"readonly": false,
|
||||||
|
"builtin": true,
|
||||||
|
"sort": 22,
|
||||||
|
"name": "Agent Memory Chat Store",
|
||||||
|
"type": "xun",
|
||||||
|
"option": {
|
||||||
|
"type": "xun",
|
||||||
|
"table": "__yao_agent_memory_chat",
|
||||||
|
"connector": "default",
|
||||||
|
"cache_size": 10240,
|
||||||
|
"persist_interval": 30,
|
||||||
|
"cleanup_interval": 60
|
||||||
|
}
|
||||||
|
}
|
||||||
18
yao/stores/agent/memory/context.xun.yao
Normal file
18
yao/stores/agent/memory/context.xun.yao
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"label": "Agent Memory - Context",
|
||||||
|
"description": "Context-level memory store for intermediate results, temporary variables, and request-scoped cache",
|
||||||
|
"tags": ["agent", "memory", "context", "xun"],
|
||||||
|
"readonly": false,
|
||||||
|
"builtin": true,
|
||||||
|
"sort": 23,
|
||||||
|
"name": "Agent Memory Context Store",
|
||||||
|
"type": "xun",
|
||||||
|
"option": {
|
||||||
|
"type": "xun",
|
||||||
|
"table": "__yao_agent_memory_context",
|
||||||
|
"connector": "default",
|
||||||
|
"cache_size": 10240,
|
||||||
|
"persist_interval": 10,
|
||||||
|
"cleanup_interval": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
18
yao/stores/agent/memory/team.xun.yao
Normal file
18
yao/stores/agent/memory/team.xun.yao
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
{
|
||||||
|
"label": "Agent Memory - Team",
|
||||||
|
"description": "Team-level memory store for team knowledge, shared settings, and collaborative data",
|
||||||
|
"tags": ["agent", "memory", "team", "xun"],
|
||||||
|
"readonly": false,
|
||||||
|
"builtin": true,
|
||||||
|
"sort": 21,
|
||||||
|
"name": "Agent Memory Team Store",
|
||||||
|
"type": "xun",
|
||||||
|
"option": {
|
||||||
|
"type": "xun",
|
||||||
|
"table": "__yao_agent_memory_team",
|
||||||
|
"connector": "default",
|
||||||
|
"cache_size": 10240,
|
||||||
|
"persist_interval": 60,
|
||||||
|
"cleanup_interval": 1440
|
||||||
|
}
|
||||||
|
}
|
||||||
19
yao/stores/agent/memory/user.xun.yao
Normal file
19
yao/stores/agent/memory/user.xun.yao
Normal file
|
|
@ -0,0 +1,19 @@
|
||||||
|
{
|
||||||
|
"label": "Agent Memory - User",
|
||||||
|
"description": "User-level memory store for user preferences, long-term knowledge, and personal settings",
|
||||||
|
"tags": ["agent", "memory", "user", "xun"],
|
||||||
|
"readonly": false,
|
||||||
|
"builtin": true,
|
||||||
|
"sort": 20,
|
||||||
|
"name": "Agent Memory User Store",
|
||||||
|
"type": "xun",
|
||||||
|
"option": {
|
||||||
|
"type": "xun",
|
||||||
|
"table": "__yao_agent_memory_user",
|
||||||
|
"connector": "default",
|
||||||
|
"cache_size": 10240,
|
||||||
|
"persist_interval": 60,
|
||||||
|
"cleanup_interval": 1440
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"label": "Knowledge Base Data Store",
|
|
||||||
"description": "Badger-based persistent store for Knowledge Base data",
|
|
||||||
"tags": ["kb", "persistent", "badger", "data"],
|
|
||||||
"readonly": false,
|
|
||||||
"builtin": true,
|
|
||||||
"sort": 10,
|
|
||||||
"name": "Knowledge Base Data Store",
|
|
||||||
"type": "badger",
|
|
||||||
"option": {
|
|
||||||
"path": "{{ YAO_DATA_ROOT }}/stores/kb/store"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
15
yao/stores/kb/store.xun.yao
Normal file
15
yao/stores/kb/store.xun.yao
Normal file
|
|
@ -0,0 +1,15 @@
|
||||||
|
{
|
||||||
|
"label": "Knowledge Base Data Store",
|
||||||
|
"description": "Database-backed persistent store for Knowledge Base data",
|
||||||
|
"tags": ["kb", "persistent", "xun", "data"],
|
||||||
|
"readonly": false,
|
||||||
|
"builtin": true,
|
||||||
|
"sort": 10,
|
||||||
|
"name": "Knowledge Base Data Store",
|
||||||
|
"type": "xun",
|
||||||
|
"option": {
|
||||||
|
"type": "xun",
|
||||||
|
"table": "__yao_kb_store",
|
||||||
|
"connector": "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"label": "OAuth Client Store",
|
|
||||||
"description": "Badger-based store for OAuth client information and credentials",
|
|
||||||
"tags": ["oauth", "client", "badger", "security"],
|
|
||||||
"readonly": false,
|
|
||||||
"builtin": true,
|
|
||||||
"sort": 10,
|
|
||||||
"name": "OAuth Client Store",
|
|
||||||
"type": "badger",
|
|
||||||
"option": {
|
|
||||||
"path": "{{ YAO_DATA_ROOT }}/stores/oauth/client"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
yao/stores/oauth/client.xun.yao
Normal file
16
yao/stores/oauth/client.xun.yao
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"label": "OAuth Client Store",
|
||||||
|
"description": "Database-backed store for OAuth client information and credentials",
|
||||||
|
"tags": ["oauth", "client", "xun", "security"],
|
||||||
|
"readonly": false,
|
||||||
|
"builtin": true,
|
||||||
|
"sort": 10,
|
||||||
|
"name": "OAuth Client Store",
|
||||||
|
"type": "xun",
|
||||||
|
"option": {
|
||||||
|
"type": "xun",
|
||||||
|
"table": "__yao_oauth_client",
|
||||||
|
"connector": "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"label": "OAuth Data Store",
|
|
||||||
"description": "Badger-based persistent store for OAuth authorization codes, refresh tokens and session data",
|
|
||||||
"tags": ["oauth", "persistent", "badger", "data"],
|
|
||||||
"readonly": false,
|
|
||||||
"builtin": true,
|
|
||||||
"sort": 10,
|
|
||||||
"name": "OAuth Data Store",
|
|
||||||
"type": "badger",
|
|
||||||
"option": {
|
|
||||||
"path": "{{ YAO_DATA_ROOT }}/stores/oauth/store"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
yao/stores/oauth/store.xun.yao
Normal file
16
yao/stores/oauth/store.xun.yao
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"label": "OAuth Data Store",
|
||||||
|
"description": "Database-backed persistent store for OAuth authorization codes, refresh tokens and session data",
|
||||||
|
"tags": ["oauth", "persistent", "xun", "data"],
|
||||||
|
"readonly": false,
|
||||||
|
"builtin": true,
|
||||||
|
"sort": 10,
|
||||||
|
"name": "OAuth Data Store",
|
||||||
|
"type": "xun",
|
||||||
|
"option": {
|
||||||
|
"type": "xun",
|
||||||
|
"table": "__yao_oauth_store",
|
||||||
|
"connector": "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
{
|
|
||||||
"label": "System Data Store",
|
|
||||||
"description": "Badger-based key-value store for common data storage in Yao system",
|
|
||||||
"tags": ["system", "storage", "badger", "kv"],
|
|
||||||
"readonly": false,
|
|
||||||
"builtin": true,
|
|
||||||
"sort": 1,
|
|
||||||
"name": "System Data Store",
|
|
||||||
"type": "badger",
|
|
||||||
"option": {
|
|
||||||
"path": "{{ YAO_DATA_ROOT }}/stores/store"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
16
yao/stores/store.xun.yao
Normal file
16
yao/stores/store.xun.yao
Normal file
|
|
@ -0,0 +1,16 @@
|
||||||
|
{
|
||||||
|
"label": "System Data Store",
|
||||||
|
"description": "Database-backed key-value store for common data storage in Yao system",
|
||||||
|
"tags": ["system", "storage", "xun", "kv"],
|
||||||
|
"readonly": false,
|
||||||
|
"builtin": true,
|
||||||
|
"sort": 1,
|
||||||
|
"name": "System Data Store",
|
||||||
|
"type": "xun",
|
||||||
|
"option": {
|
||||||
|
"type": "xun",
|
||||||
|
"table": "__yao_kv_store",
|
||||||
|
"connector": "default"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
Loading…
Add table
Reference in a new issue