Refactor Context Management to Use Memory Instead of Space

- Replaced all instances of `ctx.Space` with `ctx.Memory.Context` in the context management code, ensuring a more structured approach to handling temporary request-scoped data.
- Updated related test cases to reflect the changes in context memory usage, enhancing the reliability and clarity of tests.
- Removed the deprecated `Space` references and adjusted comments and documentation to align with the new memory management strategy.
This commit is contained in:
Max 2025-12-22 11:19:00 +08:00
parent 51be1844d5
commit 632c0f5674
35 changed files with 2676 additions and 1604 deletions

View file

@ -252,14 +252,14 @@ func (ast *Assistant) BufferUserInput(ctx *agentcontext.Context, inputMessages [
}
}
// UpdateSpaceSnapshot updates the space snapshot in the buffer
// Should be called when space data changes
// UpdateSpaceSnapshot updates the context memory snapshot in the buffer
// Only captures Context-level memory (request-scoped temporary data) for recovery
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
}
snapshot := ctx.Space.Snapshot()
snapshot := ctx.Memory.Context.Snapshot()
ctx.Buffer.SetSpaceSnapshot(snapshot)
}

View file

@ -10,7 +10,6 @@ import (
"github.com/google/uuid"
"github.com/stretchr/testify/assert"
"github.com/stretchr/testify/require"
"github.com/yaoapp/gou/plan"
"github.com/yaoapp/yao/agent/assistant"
agentcontext "github.com/yaoapp/yao/agent/context"
storetypes "github.com/yaoapp/yao/agent/store/types"
@ -431,15 +430,16 @@ func TestBufferStepTracking(t *testing.T) {
t.Run("BeginAndCompleteStep", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_step_001")
ctx.Space = plan.NewMemorySharedSpace()
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Set some space data
ctx.Space.Set("test_key", "test_value")
// Set some context memory data
if ctx.Memory != nil && ctx.Memory.Context != nil {
ctx.Memory.Context.Set("test_key", "test_value", 0)
}
// Begin a step
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.Run("SpaceSnapshotCapture", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_space_001")
ctx.Space = plan.NewMemorySharedSpace()
t.Run("ContextMemorySnapshotCapture", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_memory_001")
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Set space data before step
ctx.Space.Set("key1", "value1")
ctx.Space.Set("key2", 123)
// Set context memory data before step
require.NotNil(t, ctx.Memory)
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)
// Verify space snapshot was captured
// Verify context memory snapshot was captured
steps := ctx.Buffer.GetAllSteps()
require.Len(t, steps, 1)
assert.NotNil(t, steps[0].SpaceSnapshot)
assert.Equal(t, "value1", steps[0].SpaceSnapshot["key1"])
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) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_multi_step")
ctx.Space = plan.NewMemorySharedSpace()
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
@ -536,7 +536,6 @@ func TestFlushBuffer(t *testing.T) {
t.Run("FlushOnSuccess", func(t *testing.T) {
chatID := fmt.Sprintf("test_flush_success_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
ctx.Space = plan.NewMemorySharedSpace()
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
@ -584,7 +583,6 @@ func TestFlushBuffer(t *testing.T) {
t.Run("FlushOnFailure", func(t *testing.T) {
chatID := fmt.Sprintf("test_flush_fail_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
ctx.Space = plan.NewMemorySharedSpace()
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
@ -633,7 +631,6 @@ func TestFlushBuffer(t *testing.T) {
t.Run("FlushOnInterrupt", func(t *testing.T) {
chatID := fmt.Sprintf("test_flush_interrupt_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
ctx.Space = plan.NewMemorySharedSpace()
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
@ -673,7 +670,6 @@ func TestFlushBuffer(t *testing.T) {
t.Run("FlushWithModeAndConnector", func(t *testing.T) {
chatID := fmt.Sprintf("test_flush_mode_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
ctx.Space = plan.NewMemorySharedSpace()
// Enter stack with connector and mode options
opts := &agentcontext.Options{

View file

@ -262,7 +262,7 @@ func TestDBAuthWheresFilter(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)
assert.Nil(t, wheres, "Nil Authorized should return nil")
@ -419,16 +419,15 @@ func TestKBSearchIntegration(t *testing.T) {
// ========== Helper Functions ==========
func createAuthContext(userID, teamID string, teamOnly, ownerOnly bool) *agentContext.Context {
return &agentContext.Context{
Authorized: &oauthtypes.AuthorizedInfo{
authorized := &oauthtypes.AuthorizedInfo{
UserID: userID,
TeamID: teamID,
Constraints: oauthtypes.DataConstraints{
TeamOnly: teamOnly,
OwnerOnly: ownerOnly,
},
},
}
return agentContext.New(context.Background(), authorized, "test-chat")
}
func createAuthCollection(ctx context.Context, t *testing.T, id, userID, teamID string, public bool, share string) {

View file

@ -457,8 +457,8 @@ func TestVision_CachedContent(t *testing.T) {
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
func TestVision_FileMetadataInSpace(t *testing.T) {
// TestVision_FileMetadataInMemory tests that file metadata is correctly passed to vision agent via ctx.Memory.Context
func TestVision_FileMetadataInMemory(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)

View file

@ -8,7 +8,6 @@ import (
"testing"
"github.com/yaoapp/gou/connector/openai"
"github.com/yaoapp/gou/plan"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/config"
"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
func newTestContext(capabilities *openai.Capabilities) *agentContext.Context {
return &agentContext.Context{
Context: stdContext.Background(),
Space: plan.NewMemorySharedSpace(),
ChatID: "test-chat",
AssistantID: "test-assistant",
Locale: "en-us",
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{
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) {

View file

@ -49,15 +49,15 @@ func CallAgent(ctx *agentContext.Context, agentID string, message agentContext.M
}
// 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)
// Uses Space instead of Metadata to avoid creating context copies and ensure proper cleanup
// The file metadata is passed via ctx.Memory.Context for access by hooks (especially Next hook)
// 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}:current_file - Currently processing file (single object)
func CallAgentWithFileInfo(ctx *agentContext.Context, agentID string, message agentContext.Message, info *Info) (string, error) {
// Store file information in Space if available
if info != nil && ctx.Space != nil {
// Store file information in Memory.Context if available
if info != nil && ctx.Memory != nil && ctx.Memory.Context != nil {
fileInfo := map[string]interface{}{
"url": info.URL,
"filename": info.Filename,
@ -74,14 +74,14 @@ func CallAgentWithFileInfo(ctx *agentContext.Context, agentID string, message ag
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"
currentFileKey := agentID + ":current_file"
// Thread-safe: append current file to files list
fileInfoMutex.Lock()
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{}
if existingList, ok := existing.([]interface{}); ok {
for _, item := range existingList {
@ -95,23 +95,23 @@ func CallAgentWithFileInfo(ctx *agentContext.Context, agentID string, message ag
}
// Append current file to list
filesList = append(filesList, fileInfo)
ctx.Space.Set(filesListKey, filesList)
ctx.Memory.Context.Set(filesListKey, filesList, 0)
fileInfoMutex.Unlock()
// Store current file in Space
if err := ctx.Space.Set(currentFileKey, fileInfo); err != nil {
log.Trace("[Content] Failed to set current file info in Space: %v", err)
// Store current file in Memory.Context
if err := ctx.Memory.Context.Set(currentFileKey, fileInfo, 0); err != nil {
log.Trace("[Content] Failed to set current file info in Memory.Context: %v", err)
}
// Ensure cleanup after agent call completes
defer func() {
// Clean up current file
if err := ctx.Space.Delete(currentFileKey); err != nil {
log.Trace("[Content] Failed to delete current file info from Space: %v", err)
if err := ctx.Memory.Context.Del(currentFileKey); err != nil {
log.Trace("[Content] Failed to delete current file info from Memory.Context: %v", err)
}
// Clean up files list (reset for next call)
if err := ctx.Space.Delete(filesListKey); err != nil {
log.Trace("[Content] Failed to delete files list from Space: %v", err)
if err := ctx.Memory.Context.Del(filesListKey); err != nil {
log.Trace("[Content] Failed to delete files list from Memory.Context: %v", err)
}
}()
}

View file

@ -35,7 +35,7 @@ interface Context {
authorized: Record<string, any>; // Authorization data (empty object if not set)
// 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
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.
> **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)`
@ -1248,138 +1248,372 @@ Retrieves a trace space by 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 |
| ----------------- | ------------------------------------- |
| `Get(key)` | Get a value from the space |
| `Set(key, value)` | Set a value in the space |
| `Delete(key)` | Delete a key from the space |
| `GetDel(key)` | Get a value and immediately delete it |
| Namespace | Scope | Persistence | Use Case |
| -------------------- | ------------------- | ----------- | ------------------------------------------- |
| `ctx.memory.user` | Per user | Persistent | User preferences, settings, long-term state |
| `ctx.memory.team` | Per team | Persistent | Team-wide settings, shared configurations |
| `ctx.memory.chat` | Per chat session | Persistent | Chat-specific context, conversation state |
| `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:**
```javascript
const user_data = ctx.space.Get("user_data");
if (user_data) {
console.log("Found user:", user_data.name);
// Metadata
id: string; // Namespace ID
space: string; // Space type: "user", "team", "chat", or "context"
}
```
#### `ctx.space.Set(key, value): void`
### Basic KV Operations
Sets a value in the space.
#### `Get(key): any`
**Parameters:**
- `key`: String - The key to set
- `value`: Any - The value to store
**Example:**
Gets a value from the namespace.
```javascript
ctx.space.Set("user_data", { name: "John", id: 123 });
ctx.space.Set("processing_status", "started");
// User preferences
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.
**Parameters:**
- `key`: String - The key to delete
**Example:**
Sets a value with optional TTL (time-to-live in seconds).
```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.
**Parameters:**
- `key`: String - The key to retrieve and delete
**Returns:**
- `any`: The value, or `null` if not found
**Example:**
Deletes a key. Supports wildcard patterns with `*`.
```javascript
// Store file metadata in parent agent
ctx.space.Set("file_metadata", { name: "report.pdf", size: 1024 });
// Delete single key
ctx.memory.user.Del("old_setting");
// In child agent, get and consume the data
const metadata = ctx.space.GetDel("file_metadata");
// metadata is now deleted from space
// Delete with wildcard pattern
ctx.memory.chat.Del("cache:*"); // Deletes all keys starting with "cache:"
```
#### `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
```javascript
// Use case 1: Pass data between hooks
// Use case 1: User preferences (persistent across sessions)
function Create(ctx, messages) {
// Store data for later use
ctx.space.Set("original_query", messages[0].content);
// Load user preferences
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 };
}
function Next(ctx, payload) {
// Retrieve data from Create hook
const query = ctx.space.Get("original_query");
console.log("Original query was:", query);
// Retrieve temporary data
const startTime = ctx.memory.context.Get("request_start");
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) {
// Prepare context for child agent
ctx.space.Set("parent_context", {
user_id: ctx.authorized.user_id,
session_start: Date.now(),
});
// Check team quota
const used = ctx.memory.team.Incr("monthly_requests");
const limit = ctx.memory.team.Get("monthly_limit") || 10000;
// Call child agent...
if (used > limit) {
throw new Error("Team quota exceeded");
}
return { messages };
}
// Use case 3: One-time data consumption
function Next(ctx, payload) {
// Get and delete in one operation
const temp_data = ctx.space.GetDel("temp_processing_data");
if (temp_data) {
// Process and discard
// Use case 5: Rate limiting with counters
function Create(ctx, messages) {
const key = `rate:${new Date().toISOString().slice(0, 13)}`; // Hourly bucket
const count = ctx.memory.user.Incr(key);
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:**
- Space is shared across all hooks within the same request
- Space persists across nested agent calls (A2A)
- Values can be any JSON-serializable data
- Use `GetDel` for data that should only be consumed once
- `user`, `team`, `chat` namespaces are persistent (backed by database)
- `context` namespace is temporary and cleared when the request context is released
- All namespaces support TTL for automatic expiration
- Wildcard deletion (`Del("prefix:*")`) works on all namespaces
- Counter operations (`Incr`, `Decr`) are atomic
## MCP API
@ -1697,7 +1931,7 @@ interface UsesConfig {
```javascript
function Create(ctx, messages) {
// Store data for Next hook
ctx.space.Set("user_query", messages[0]?.content);
ctx.memory.context.Set("user_query", messages[0]?.content);
// Modify messages
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.
- **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
- 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
@ -1891,9 +2125,9 @@ function Create(ctx, messages) {
// Extract user query from the last message
const user_query = messages[messages.length - 1]?.content || "";
// Store data in space for use in Next hook
ctx.space.Set("original_query", user_query);
ctx.space.Set("request_time", Date.now());
// Store data in context memory for use in Next hook
ctx.memory.context.Set("original_query", user_query);
ctx.memory.context.Set("request_time", Date.now());
// Add trace node to show processing in UI
const create_node = ctx.trace.Add(
@ -1943,9 +2177,9 @@ function Create(ctx, messages) {
function Next(ctx, payload) {
const { messages, completion, tools, error } = payload;
// Retrieve data from Create hook via space
const original_query = ctx.space.Get("original_query");
const request_time = ctx.space.Get("request_time");
// Retrieve data from Create hook via context memory
const original_query = ctx.memory.context.Get("original_query");
const request_time = ctx.memory.context.Get("request_time");
const duration = Date.now() - request_time;
// 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)
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
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
9. **Block Grouping**: Only use Block IDs when you need to group multiple messages together (e.g., LLM output + follow-up card)

View file

@ -6,7 +6,7 @@ import (
"sync"
"time"
"github.com/yaoapp/gou/plan"
"github.com/yaoapp/yao/agent/memory"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/openapi/oauth/types"
@ -27,11 +27,21 @@ func New(parent context.Context, authorized *types.AuthorizedInfo, chatID string
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{
Context: parent,
ID: contextID, // Generate unique ID for the context
Authorized: authorized, // Set authorized info
Space: plan.NewMemorySharedSpace(),
Memory: mem,
ChatID: chatID,
IDGenerator: message.NewIDGenerator(), // Initialize ID generator for this context
messageMetadata: newMessageMetadataStore(), // Initialize message metadata store
@ -78,14 +88,15 @@ func (ctx *Context) Release() {
ctx.trace = nil
}
// Clear space
if ctx.Space != nil {
// Clear context-level memory only (request-scoped temporary data)
// User, Team, Chat level memory is persistent and should NOT be cleared
if ctx.Memory != nil && ctx.Memory.Context != nil {
if ctx.Logger != nil {
ctx.Logger.Cleanup("Space")
ctx.Logger.Cleanup("Memory.Context")
}
ctx.Space.Clear()
ctx.Space = nil
ctx.Memory.Context.Clear()
}
ctx.Memory = nil
// Clear stacks
if ctx.Stacks != nil {
@ -379,9 +390,9 @@ func (ctx *Context) BeginStep(stepType string, input map[string]interface{}) *Bu
return nil
}
// Update space snapshot before starting step
if ctx.Space != nil {
ctx.Buffer.SetSpaceSnapshot(ctx.Space.Snapshot())
// Update context memory snapshot before starting step (for recovery)
if ctx.Memory != nil && ctx.Memory.Context != nil {
ctx.Buffer.SetSpaceSnapshot(ctx.Memory.Context.Snapshot())
}
return ctx.Buffer.BeginStep(stepType, input, ctx.Stack)

View file

@ -211,7 +211,7 @@ func TestGetCompletionRequest(t *testing.T) {
assert.Equal(t, tt.expectedReferer, ctx.Referer)
assert.Equal(t, tt.expectedAccept, ctx.Accept)
assert.Equal(t, tt.expectedAssistantID, ctx.AssistantID)
assert.NotNil(t, ctx.Space)
assert.NotNil(t, ctx.Memory)
assert.NotNil(t, ctx.Cache)
})
}
@ -227,7 +227,7 @@ func TestContextNew_WithAuthorized(t *testing.T) {
assert.NotNil(t, ctx)
assert.Equal(t, "test-chat-id", ctx.ChatID)
assert.NotNil(t, ctx.Space)
assert.NotNil(t, ctx.Memory)
assert.NotNil(t, ctx.IDGenerator)
}

View file

@ -1,7 +1,10 @@
package context
import (
"time"
"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/message"
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
if ctx.Space != nil {
spaceObj := ctx.createSpaceObject(v8ctx)
obj.Set("space", spaceObj)
spaceObj.Release()
// Memory object - create a JavaScript object with User/Team/Chat/Context namespaces
if ctx.Memory != nil {
memoryObj := ctx.createMemoryObject(v8ctx)
obj.Set("memory", memoryObj)
memoryObj.Release()
}
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
// Space is a shared data space for passing data between requests and calls
func (ctx *Context) createSpaceObject(v8ctx *v8go.Context) *v8go.Value {
// createMemoryObject creates a Memory object for JavaScript access
// Memory provides four namespaces: User, Team, Chat, Context
// Each namespace supports: Get, Set, Del, Has, Keys, Len, Clear, Incr, Decr
func (ctx *Context) createMemoryObject(v8ctx *v8go.Context) *v8go.Value {
iso := v8ctx.Isolate()
spaceObj, _ := v8ctx.RunScript("({})", "space-init")
obj, _ := spaceObj.AsObject()
objTpl := v8go.NewObjectTemplate(iso)
obj, _ := objTpl.NewInstance(v8ctx)
// Get method: space.Get(key)
getFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
if ctx.Space == nil {
return v8go.Null(iso)
// 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 {
if len(info.Args()) < 1 {
return bridge.JsException(info.Context(), "Get requires a key argument")
}
key := info.Args()[0].String()
value, err := ctx.Space.Get(key)
if err != nil {
value, ok := ns.Get(key)
if !ok {
return v8go.Null(iso)
}
@ -751,12 +785,8 @@ func (ctx *Context) createSpaceObject(v8ctx *v8go.Context) *v8go.Value {
getFuncVal := getFunc.GetFunction(v8ctx)
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 {
if ctx.Space == nil {
return bridge.JsException(info.Context(), "Space is not available")
}
if len(info.Args()) < 2 {
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())
}
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())
}
@ -776,50 +813,128 @@ func (ctx *Context) createSpaceObject(v8ctx *v8go.Context) *v8go.Value {
setFuncVal := setFunc.GetFunction(v8ctx)
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 {
if ctx.Space == nil {
return bridge.JsException(info.Context(), "Space is not available")
}
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()
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 v8go.Undefined(iso)
})
delFuncVal := delFunc.GetFunction(v8ctx)
obj.Set("Delete", delFuncVal.Value)
obj.Set("Del", delFuncVal.Value)
// GetDel method: space.GetDel(key) - Get value and delete immediately
// Convenient for one-time use data (e.g., file metadata passed between agents)
getDelFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
if ctx.Space == nil {
return v8go.Null(iso)
// Has method: ns.Has(key)
hasFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
if len(info.Args()) < 1 {
return bridge.JsException(info.Context(), "Has requires a key argument")
}
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 {
return bridge.JsException(info.Context(), "GetDel requires a key argument")
}
key := info.Args()[0].String()
// Get value first
value, err := ctx.Space.Get(key)
if err != nil {
value, ok := ns.GetDel(key)
if !ok {
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)
if err != nil {
return v8go.Null(iso)
@ -830,7 +945,7 @@ func (ctx *Context) createSpaceObject(v8ctx *v8go.Context) *v8go.Value {
getDelFuncVal := getDelFunc.GetFunction(v8ctx)
obj.Set("GetDel", getDelFuncVal.Value)
return spaceObj
return obj.Value
}
// sendGroupMethod implements ctx.SendGroup(group)

View file

@ -11,21 +11,23 @@ import (
"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
func TestMCPListResources(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
// Initialize context with trace
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
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -62,15 +64,7 @@ func TestMCPReadResource(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(),
Referer: context.RefererAPI,
}
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
ctx.Stack = stack
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -105,15 +99,7 @@ func TestMCPListTools(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(),
Referer: context.RefererAPI,
}
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
ctx.Stack = stack
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -152,15 +138,7 @@ func TestMCPCallTool(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(),
Referer: context.RefererAPI,
}
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
ctx.Stack = stack
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -195,15 +173,7 @@ func TestMCPCallTools(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(),
Referer: context.RefererAPI,
}
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
ctx.Stack = stack
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -243,15 +213,7 @@ func TestMCPCallToolsParallel(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(),
Referer: context.RefererAPI,
}
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
ctx.Stack = stack
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -291,15 +253,7 @@ func TestMCPListPrompts(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(),
Referer: context.RefererAPI,
}
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
ctx.Stack = stack
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -336,15 +290,7 @@ func TestMCPGetPrompt(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(),
Referer: context.RefererAPI,
}
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
ctx.Stack = stack
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -379,15 +325,7 @@ func TestMCPListSamples(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(),
Referer: context.RefererAPI,
}
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
ctx.Stack = stack
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -422,15 +360,7 @@ func TestMCPGetSample(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(),
Referer: context.RefererAPI,
}
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
ctx.Stack = stack
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -467,15 +397,7 @@ func TestMCPJsApiWithTrace(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(),
Referer: context.RefererAPI,
}
stack, _, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})
ctx.Stack = stack
ctx := newMCPTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {

View 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"])
}

View file

@ -7,27 +7,26 @@ import (
"github.com/stretchr/testify/assert"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/config"
"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
func TestContextRelease(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
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
cxt := newReleaseTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -71,17 +70,7 @@ func TestTraceRelease(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
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
cxt := newReleaseTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -134,17 +123,7 @@ func TestContextReleaseWithTrace(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
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
cxt := newReleaseTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -183,17 +162,7 @@ func TestTryFinallyPattern(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
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
cxt := newReleaseTestContext()
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -237,12 +206,8 @@ func TestNoOpTraceRelease(t *testing.T) {
defer test.Clean()
// Context without trace initialization
cxt := &context.Context{
ChatID: "test-chat-id",
AssistantID: "test-assistant-id",
Context: stdContext.Background(),
IDGenerator: message.NewIDGenerator(),
}
cxt := context.New(stdContext.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
res, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -286,17 +251,7 @@ func TestTryFinallyPatternWithError(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
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
cxt := newReleaseTestContext()
_, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {

View file

@ -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")
}

View file

@ -12,11 +12,20 @@ import (
"github.com/stretchr/testify/assert"
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/config"
"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
func TestStressContextCreationAndRelease(t *testing.T) {
if testing.Short() {
@ -30,17 +39,7 @@ func TestStressContextCreationAndRelease(t *testing.T) {
startMemory := getMemStats()
for i := 0; i < iterations; i++ {
cxt := &context.Context{
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
cxt := newStressTestContext(fmt.Sprintf("chat-%d", i))
_, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -107,17 +106,7 @@ func TestStressTraceOperations(t *testing.T) {
for i := 0; i < iterations; i++ {
// Create new context for each iteration to avoid context cancellation issues
cxt := &context.Context{
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
cxt := newStressTestContext(fmt.Sprintf("stress-test-chat-%d", i))
_, err := v8.Call(v8.CallOptions{}, fmt.Sprintf(`
function test(ctx) {
const trace = ctx.trace
@ -185,16 +174,7 @@ func TestStressMCPOperations(t *testing.T) {
iterations := 500
cxt := &context.Context{
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
cxt := newStressTestContext("mcp-stress-test")
startMemory := getMemStats()
@ -269,16 +249,7 @@ func TestStressConcurrentContexts(t *testing.T) {
defer wg.Done()
for i := 0; i < iterationsPerGoroutine; i++ {
cxt := &context.Context{
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
cxt := newStressTestContext(fmt.Sprintf("chat-%d-%d", goroutineID, i))
_, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -345,12 +316,8 @@ func TestStressNoOpTracePerformance(t *testing.T) {
iterations := 1000
// Context without trace initialization (no-op trace)
cxt := &context.Context{
ChatID: "noop-stress-test",
AssistantID: "test-assistant",
Context: stdContext.Background(),
IDGenerator: message.NewIDGenerator(),
}
cxt := context.New(stdContext.Background(), nil, "noop-stress-test")
cxt.AssistantID = "test-assistant"
startMemory := getMemStats()
startTime := time.Now()
@ -420,16 +387,7 @@ func TestStressReleasePatterns(t *testing.T) {
startMemory := getMemStats()
for i := 0; i < iterations; i++ {
cxt := &context.Context{
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
cxt := newStressTestContext(fmt.Sprintf("manual-%d", i))
_, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -459,16 +417,7 @@ func TestStressReleasePatterns(t *testing.T) {
startMemory := getMemStats()
for i := 0; i < iterations; i++ {
cxt := &context.Context{
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
cxt := newStressTestContext(fmt.Sprintf("gc-%d", i))
_, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -500,16 +449,7 @@ func TestStressReleasePatterns(t *testing.T) {
startMemory := getMemStats()
for i := 0; i < iterations; i++ {
cxt := &context.Context{
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
cxt := newStressTestContext(fmt.Sprintf("separate-%d", i))
_, err := v8.Call(v8.CallOptions{}, `
function test(ctx) {
@ -546,16 +486,7 @@ func TestStressLongRunningTrace(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
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
cxt := newStressTestContext("long-running-test")
startMemory := getMemStats()
operations := 100

View file

@ -10,7 +10,6 @@ import (
v8 "github.com/yaoapp/gou/runtime/v8"
"github.com/yaoapp/gou/runtime/v8/bridge"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/openapi/oauth/types"
"github.com/yaoapp/yao/test"
@ -23,12 +22,8 @@ func TestJsValue(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
ChatID: "ChatID-123456",
AssistantID: "AssistantID-1234",
Context: stdContext.Background(),
IDGenerator: message.NewIDGenerator(),
}
cxt := context.New(stdContext.Background(), nil, "ChatID-123456")
cxt.AssistantID = "AssistantID-1234"
v8.RegisterFunction("testContextJsvalue", testContextJsvalueEmbed)
res, err := v8.Call(v8.CallOptions{}, `
@ -91,12 +86,8 @@ func TestJsValueConcurrent(t *testing.T) {
chatID := fmt.Sprintf("ChatID-%d-%d", routineID, j)
assistantID := fmt.Sprintf("AssistantID-%d-%d", routineID, j)
cxt := &context.Context{
ChatID: chatID,
AssistantID: assistantID,
Context: stdContext.Background(),
IDGenerator: message.NewIDGenerator(),
}
cxt := context.New(stdContext.Background(), nil, chatID)
cxt.AssistantID = assistantID
res, err := v8.Call(v8.CallOptions{}, `
function test(cxt) {
@ -150,12 +141,8 @@ func TestJsValueRegistrationAndCleanup(t *testing.T) {
// Create multiple contexts and verify registration
contextCount := 5
for i := 0; i < contextCount; i++ {
cxt := &context.Context{
ChatID: fmt.Sprintf("ChatID-%d", i),
AssistantID: fmt.Sprintf("AssistantID-%d", i),
Context: stdContext.Background(),
IDGenerator: message.NewIDGenerator(),
}
cxt := context.New(stdContext.Background(), nil, fmt.Sprintf("ChatID-%d", i))
cxt.AssistantID = fmt.Sprintf("AssistantID-%d", i)
_, err := v8.Call(v8.CallOptions{}, `
function test(cxt) {
@ -219,26 +206,7 @@ func TestJsValueAllFields(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
ChatID: "test-chat-id",
AssistantID: "test-assistant-id",
Locale: "zh-cn",
Theme: "dark",
Context: stdContext.Background(),
Client: context.Client{
Type: "web",
UserAgent: "Mozilla/5.0",
IP: "127.0.0.1",
},
Referer: "api",
Accept: "cui-web",
Route: "/dashboard/home",
Metadata: map[string]interface{}{
"key1": "value1",
"key2": 123,
"key3": true,
},
Authorized: &types.AuthorizedInfo{
authInfo := &types.AuthorizedInfo{
Subject: "test-user",
ClientID: "test-client",
UserID: "user-123",
@ -253,7 +221,24 @@ func TestJsValueAllFields(t *testing.T) {
"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)
@ -404,14 +389,10 @@ func TestJsValueTrace(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
ChatID: "test-chat-id",
AssistantID: "test-assistant-id",
Stack: &context.Stack{
cxt := context.New(stdContext.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Stack = &context.Stack{
TraceID: "test-trace-id",
},
Context: stdContext.Background(),
IDGenerator: message.NewIDGenerator(),
}
res, err := v8.Call(v8.CallOptions{}, `
@ -470,21 +451,17 @@ func TestJsValueAuthorizedAndMetadata(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
ChatID: "test-chat-id",
AssistantID: "test-assistant-id",
Context: stdContext.Background(),
IDGenerator: message.NewIDGenerator(),
Authorized: &types.AuthorizedInfo{
authInfo := &types.AuthorizedInfo{
UserID: "user-123",
TenantID: "tenant-456",
ClientID: "client-789",
},
Metadata: map[string]interface{}{
}
cxt := context.New(stdContext.Background(), authInfo, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Metadata = map[string]interface{}{
"request_id": "req-001",
"source": "api",
"version": "1.0.0",
},
}
v8.RegisterFunction("testAuthorizedMetadata", testAuthorizedMetadataEmbed)
@ -573,14 +550,9 @@ func TestJsValueAuthorizedNil(t *testing.T) {
test.Prepare(t, config.Conf)
defer test.Clean()
cxt := &context.Context{
ChatID: "test-chat-id",
AssistantID: "test-assistant-id",
Context: stdContext.Background(),
IDGenerator: message.NewIDGenerator(),
Authorized: nil, // Explicitly nil
Metadata: nil, // Explicitly nil (should be empty object)
}
cxt := context.New(stdContext.Background(), nil, "test-chat-id")
cxt.AssistantID = "test-assistant-id"
cxt.Metadata = nil // Explicitly nil (should be empty object)
res, err := v8.Call(v8.CallOptions{}, `
function test(cxt) {

View file

@ -5,7 +5,6 @@ import (
"testing"
"github.com/yaoapp/gou/mcp/types"
"github.com/yaoapp/gou/plan"
"github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
@ -13,15 +12,10 @@ import (
// newTestMCPContext creates a test context
func newTestMCPContext() *context.Context {
ctx := &context.Context{
Context: stdContext.Background(),
Space: plan.NewMemorySharedSpace(),
ID: "test-context",
ChatID: "test-chat",
AssistantID: "test-assistant",
Locale: "en",
Referer: context.RefererAPI,
}
ctx := context.New(stdContext.Background(), nil, "test-chat")
ctx.AssistantID = "test-assistant"
ctx.Locale = "en"
ctx.Referer = context.RefererAPI
// Initialize stack and trace
stack, traceID, _ := context.EnterStack(ctx, "test-assistant", &context.Options{})

View file

@ -6,8 +6,8 @@ import (
"time"
"github.com/yaoapp/gou/connector/openai"
"github.com/yaoapp/gou/plan"
"github.com/yaoapp/gou/store"
"github.com/yaoapp/yao/agent/memory"
"github.com/yaoapp/yao/agent/output"
"github.com/yaoapp/yao/agent/output/message"
"github.com/yaoapp/yao/openapi/oauth/types"
@ -226,7 +226,7 @@ type Context struct {
// External
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"
Stack *Stack `json:"-"` // Stack, current active stack of the request
Stacks map[string]*Stack `json:"-"` // Stacks, all stacks in this request (for trace logging)

View 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
View 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
View 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
View 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
View 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
View 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"`
}

View file

@ -312,7 +312,7 @@ func TestSetJSAPIFactory(t *testing.T) {
require.NotNil(t, context.SearchAPIFactory)
// Create a mock context
ctx := &context.Context{}
ctx := context.New(nil, nil, "test-chat")
// Get search API
searchAPI := context.SearchAPIFactory(ctx)
@ -337,7 +337,8 @@ func TestSetJSAPIFactory_WithGetter(t *testing.T) {
require.NotNil(t, context.SearchAPIFactory)
// 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
searchAPI := context.SearchAPIFactory(ctx)

View file

@ -6,7 +6,6 @@ import (
"testing"
"github.com/stretchr/testify/assert"
"github.com/yaoapp/gou/plan"
agentContext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/config"
"github.com/yaoapp/yao/test"
@ -14,15 +13,10 @@ import (
// newTestContext creates a test context for MCP testing
func newTestContext() *agentContext.Context {
ctx := &agentContext.Context{
Context: stdContext.Background(),
Space: plan.NewMemorySharedSpace(),
ID: "test-querydsl",
ChatID: "test-chat",
AssistantID: "test-assistant",
Locale: "en",
Referer: agentContext.RefererAPI,
}
ctx := agentContext.New(stdContext.Background(), nil, "test-chat")
ctx.AssistantID = "test-assistant"
ctx.Locale = "en"
ctx.Referer = agentContext.RefererAPI
stack, _, _ := agentContext.EnterStack(ctx, "test-assistant", &agentContext.Options{})
ctx.Stack = stack
return ctx

File diff suppressed because it is too large Load diff

View file

@ -19,8 +19,11 @@ var systemStores = map[string]string{
"__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.client": "yao/stores/oauth/client.xun.yao", // for OAuth client store
"__yao.agent.memory": "yao/stores/agent/memory.xun.yao", // for agent memory store (for agent memory)
"__yao.agent.cache": "yao/stores/agent/cache.lru.yao", // for agent cache store (for agent cache)
"__yao.agent.memory.user": "yao/stores/agent/memory/user.xun.yao", // for agent user-level memory
"__yao.agent.memory.team": "yao/stores/agent/memory/team.xun.yao", // for agent team-level memory
"__yao.agent.memory.chat": "yao/stores/agent/memory/chat.xun.yao", // for agent chat-level memory
"__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
}

View file

@ -43,7 +43,10 @@ func check(t *testing.T) {
assert.True(t, ids["__yao.oauth.store"])
assert.True(t, ids["__yao.oauth.client"])
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"])
}

View file

@ -225,7 +225,10 @@ var testSystemStores = map[string]string{
"__yao.oauth.store": "yao/stores/oauth/store.xun.yao",
"__yao.oauth.client": "yao/stores/oauth/client.xun.yao",
"__yao.oauth.cache": "yao/stores/oauth/cache.lru.yao",
"__yao.agent.memory": "yao/stores/agent/memory.xun.yao",
"__yao.agent.memory.user": "yao/stores/agent/memory/user.xun.yao",
"__yao.agent.memory.team": "yao/stores/agent/memory/team.xun.yao",
"__yao.agent.memory.chat": "yao/stores/agent/memory/chat.xun.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",

View file

@ -1,15 +0,0 @@
{
"label": "Agent Memory Store",
"description": "Database-backed store for agent memory persistence and long-term storage",
"tags": ["agent", "memory", "xun", "ai"],
"readonly": false,
"builtin": true,
"sort": 20,
"name": "Agent Memory Store",
"type": "xun",
"option": {
"type": "xun",
"table": "__yao_agent_memory",
"connector": "default"
}
}

View 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
}
}

View 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
}
}

View 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
}
}

View 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
}
}