Implement chat buffer management and step tracking in Assistant

- Introduced methods for initializing and managing a chat buffer, allowing for efficient storage of user inputs and assistant messages during chat sessions.
- Added functionality to track execution steps, including beginning and completing steps, with support for capturing space snapshots and handling errors.
- Enhanced the FlushBuffer method to save buffered messages and steps to the database, ensuring data integrity and recovery capabilities.
- Updated the Stream method to integrate buffer management, ensuring proper handling of chat sessions and message storage.
- Added comprehensive tests to validate buffer initialization, user input handling, and step tracking functionalities.
This commit is contained in:
Max 2025-12-09 12:10:11 +08:00
parent 76bbfa0927
commit 04a111bbfa
11 changed files with 2565 additions and 1 deletions

View file

@ -57,6 +57,38 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
_, _, done := context.EnterStack(ctx, ast.ID, opts)
defer done()
// ================================================
// Initialize Chat Buffer (for root stack only)
// Buffer is flushed in defer block at the end
// ================================================
ast.InitBuffer(ctx)
// Track final status for buffer flush
var finalStatus = context.StepStatusCompleted
var finalError error
// Defer buffer flush - always executes on exit (success, error, interrupt, panic)
defer func() {
// Handle panic recovery for status tracking
if r := recover(); r != nil {
finalStatus = context.ResumeStatusFailed
if e, ok := r.(error); ok {
finalError = e
} else {
finalError = fmt.Errorf("panic: %v", r)
}
log.Error("[AGENT] Panic recovered in Stream: %v", r)
// Re-panic after flush to preserve original behavior
defer panic(r)
}
// Flush buffer to database
ast.FlushBuffer(ctx, finalStatus, finalError)
}()
// Buffer user input messages
ast.BufferUserInput(ctx, inputMessages)
// Determine stream handler
streamHandler := ast.getStreamHandler(ctx, opts)
@ -64,6 +96,8 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// so that output adapters can use them when converting stream_start event
err = ast.initializeCapabilities(ctx, opts)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
@ -76,6 +110,9 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Use async version to not block the main flow
ast.InitializeConversationAsync(ctx, opts)
// Ensure chat session exists
ast.EnsureChat(ctx)
// Initialize agent trace node
agentNode := ast.initAgentTraceNode(ctx, inputMessages)
@ -95,15 +132,27 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Request Create hook ( Optional )
var createResponse *context.HookCreateResponse
if ast.HookScript != nil {
// Begin step tracking for hook_create
ast.BeginStep(ctx, context.StepTypeHookCreate, map[string]interface{}{
"messages": fullMessages,
})
var err error
createResponse, opts, err = ast.HookScript.Create(ctx, fullMessages, opts)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
// Send error stream_end for root stack
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Complete step
ast.CompleteStep(ctx, map[string]interface{}{
"response": createResponse,
})
// Log the create response
ast.traceCreateHook(agentNode, createResponse)
}
@ -119,6 +168,8 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Build the LLM request first
completionMessages, completionOptions, err = ast.BuildRequest(ctx, inputMessages, createResponse)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
// Send error stream_end for root stack
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
@ -128,19 +179,34 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// Build content - convert extended types (file, data) to standard LLM types (text, image_url, input_audio)
completionMessages, err = ast.BuildContent(ctx, completionMessages, completionOptions, opts)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Begin step tracking for LLM call
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
"messages": completionMessages,
})
// Execute the LLM streaming call
completionResponse, err = ast.executeLLMStream(ctx, completionMessages, completionOptions, agentNode, streamHandler, opts)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
// Send error stream_end for root stack
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Complete LLM step
ast.CompleteStep(ctx, map[string]interface{}{
"content": completionResponse.Content,
"tool_calls": completionResponse.ToolCalls,
})
}
// ================================================
@ -155,6 +221,12 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
for attempt := 0; attempt < maxToolRetries; attempt++ {
// Begin step tracking for tool calls
ast.BeginStep(ctx, context.StepTypeTool, map[string]interface{}{
"tool_calls": currentResponse.ToolCalls,
"attempt": attempt,
})
// Execute all tool calls
toolResults, hasErrors := ast.executeToolCalls(ctx, currentResponse.ToolCalls, attempt)
@ -175,8 +247,11 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
}
}
// If all successful, break out
// If all successful, complete step and break out
if !hasErrors {
ast.CompleteStep(ctx, map[string]interface{}{
"results": toolCallResponses,
})
log.Trace("[AGENT] All tool calls succeeded (attempt %d)", attempt)
break
}
@ -193,6 +268,8 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// If no retryable errors, don't retry (MCP internal issues)
if !hasRetryableErrors {
err := fmt.Errorf("tool calls failed with non-retryable errors (MCP internal issues)")
finalStatus = context.ResumeStatusFailed
finalError = err
log.Error("[AGENT] %v", err)
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
@ -202,19 +279,35 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// If it's the last attempt, return error
if attempt == maxToolRetries-1 {
err := fmt.Errorf("tool calls failed after %d attempts", maxToolRetries)
finalStatus = context.ResumeStatusFailed
finalError = err
log.Error("[AGENT] %v", err)
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Complete current step (with partial results)
ast.CompleteStep(ctx, map[string]interface{}{
"results": toolCallResponses,
"has_errors": true,
})
// Build retry messages with tool call results (including errors)
retryMessages := ast.buildToolRetryMessages(currentMessages, currentResponse, toolResults)
// Begin LLM retry step
ast.BeginStep(ctx, context.StepTypeLLM, map[string]interface{}{
"messages": retryMessages,
"retry_attempt": attempt + 1,
})
// Retry LLM call (streaming to keep user informed)
log.Trace("[AGENT] Retrying LLM for tool call correction (attempt %d/%d)", attempt+1, maxToolRetries-1)
currentResponse, err = ast.executeLLMForToolRetry(ctx, retryMessages, completionOptions, agentNode, streamHandler, opts)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
log.Error("[AGENT] LLM retry failed: %v", err)
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
@ -224,12 +317,20 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
// If LLM didn't return tool calls, it might have given up
if currentResponse.ToolCalls == nil {
err := fmt.Errorf("LLM did not return tool calls in retry attempt %d", attempt+1)
finalStatus = context.ResumeStatusFailed
finalError = err
log.Error("[AGENT] %v", err)
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Complete LLM retry step
ast.CompleteStep(ctx, map[string]interface{}{
"content": currentResponse.Content,
"tool_calls": currentResponse.ToolCalls,
})
// Update messages for next iteration
currentMessages = retryMessages
}
@ -245,6 +346,13 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
var nextResponse *context.NextHookResponse = nil
if ast.HookScript != nil {
// Begin step tracking for hook_next
ast.BeginStep(ctx, context.StepTypeHookNext, map[string]interface{}{
"messages": fullMessages,
"completion": completionResponse,
"tools": toolCallResponses,
})
var err error
nextResponse, opts, err = ast.HookScript.Next(ctx, &context.NextHookPayload{
Messages: fullMessages,
@ -252,11 +360,18 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
Tools: toolCallResponses,
}, opts)
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err
}
// Complete hook_next step
ast.CompleteStep(ctx, map[string]interface{}{
"response": nextResponse,
})
// Process Next hook response
finalResponse, err = ast.processNextResponse(&NextProcessContext{
Context: ctx,
@ -268,6 +383,8 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
CreateResponse: createResponse,
})
if err != nil {
finalStatus = context.ResumeStatusFailed
finalError = err
ast.traceAgentFail(agentNode, err)
ast.sendStreamEndOnError(ctx, streamHandler, streamStartTime, err)
return nil, err

View file

@ -4,9 +4,13 @@ import (
"fmt"
"strings"
"sync"
"time"
"github.com/google/uuid"
"github.com/yaoapp/kun/log"
agentcontext "github.com/yaoapp/yao/agent/context"
"github.com/yaoapp/yao/agent/i18n"
storetypes "github.com/yaoapp/yao/agent/store/types"
"github.com/yaoapp/yao/kb"
kbapi "github.com/yaoapp/yao/kb/api"
"github.com/yaoapp/yao/trace/types"
@ -210,6 +214,261 @@ func mergeChatMetadata(defaultMetadata map[string]interface{}, ctx *agentcontext
return metadata
}
// =============================================================================
// Chat Buffer Integration
// =============================================================================
// InitBuffer initializes the chat buffer for the context
// Should be called at the start of Stream() for root stack only
func (ast *Assistant) InitBuffer(ctx *agentcontext.Context) {
// Only initialize for root stack
if ctx.Stack == nil || !ctx.Stack.IsRoot() {
return
}
// Skip if buffer already exists
if ctx.Buffer != nil {
return
}
// Skip if History is disabled in options
if ctx.Stack.Options != nil && ctx.Stack.Options.Skip != nil && ctx.Stack.Options.Skip.History {
log.Trace("[CHAT] Buffer skipped: Skip.History is true")
return
}
// Generate request ID if not set
requestID := ctx.RequestID()
if requestID == "" {
requestID = uuid.New().String()
}
ctx.Buffer = agentcontext.NewChatBuffer(ctx.ChatID, requestID, ast.ID)
log.Trace("[CHAT] Buffer initialized: chatID=%s, requestID=%s, assistantID=%s", ctx.ChatID, requestID, ast.ID)
}
// BufferUserInput adds user input messages to the buffer
// Should be called after InitBuffer
func (ast *Assistant) BufferUserInput(ctx *agentcontext.Context, inputMessages []agentcontext.Message) {
if ctx.Buffer == nil {
return
}
// Convert input messages to buffer format
for _, msg := range inputMessages {
// Extract content from message
var content interface{}
var name string
content = msg.Content
if msg.Name != nil {
name = *msg.Name
}
ctx.Buffer.AddUserInput(content, name)
}
}
// UpdateSpaceSnapshot updates the space snapshot in the buffer
// Should be called when space data changes
func (ast *Assistant) UpdateSpaceSnapshot(ctx *agentcontext.Context) {
if ctx.Buffer == nil || ctx.Space == nil {
return
}
snapshot := ctx.Space.Snapshot()
ctx.Buffer.SetSpaceSnapshot(snapshot)
}
// BeginStep starts tracking an execution step
// Returns the step for further updates
func (ast *Assistant) BeginStep(ctx *agentcontext.Context, stepType string, input map[string]interface{}) *agentcontext.BufferedStep {
if ctx.Buffer == nil {
return nil
}
// Update space snapshot before beginning step
ast.UpdateSpaceSnapshot(ctx)
return ctx.Buffer.BeginStep(stepType, input, ctx.Stack)
}
// CompleteStep marks the current step as completed
func (ast *Assistant) CompleteStep(ctx *agentcontext.Context, output map[string]interface{}) {
if ctx.Buffer == nil {
return
}
ctx.Buffer.CompleteStep(output)
}
// FlushBuffer saves all buffered data to the database
// Should be called in defer block at the end of Stream()
func (ast *Assistant) FlushBuffer(ctx *agentcontext.Context, finalStatus string, err error) {
if ctx.Buffer == nil {
return
}
// Only flush for root stack
if ctx.Stack == nil || !ctx.Stack.IsRoot() {
return
}
// Get chat store
chatStore := GetChatStore()
if chatStore == nil {
log.Error("[CHAT] Chat store not available, cannot flush buffer")
return
}
// Mark current step as failed/interrupted if needed
if finalStatus != agentcontext.StepStatusCompleted && err != nil {
ctx.Buffer.FailCurrentStep(finalStatus, err)
}
// 1. Save all messages (user input + assistant responses)
messages := ast.convertBufferedMessages(ctx.Buffer.GetMessages())
if len(messages) > 0 {
if saveErr := chatStore.SaveMessages(ctx.ChatID, messages); saveErr != nil {
log.Error("[CHAT] Failed to save messages: %v", saveErr)
} else {
log.Trace("[CHAT] Saved %d messages for chat=%s", len(messages), ctx.ChatID)
}
}
// 2. Update chat last_message_at
if len(messages) > 0 {
now := time.Now()
if updateErr := chatStore.UpdateChat(ctx.ChatID, map[string]interface{}{
"last_message_at": now,
}); updateErr != nil {
log.Trace("[CHAT] Failed to update last_message_at: %v", updateErr)
}
}
// 3. Only save resume steps on error/interrupt (not on success)
if finalStatus != agentcontext.StepStatusCompleted {
steps := ast.convertBufferedSteps(ctx.Buffer.GetStepsForResume(finalStatus))
if len(steps) > 0 {
if saveErr := chatStore.SaveResume(steps); saveErr != nil {
log.Error("[CHAT] Failed to save resume steps: %v", saveErr)
} else {
log.Trace("[CHAT] Saved %d resume steps for chat=%s (status=%s)", len(steps), ctx.ChatID, finalStatus)
}
}
}
}
// convertBufferedMessages converts BufferedMessage slice to store Message slice
func (ast *Assistant) convertBufferedMessages(buffered []*agentcontext.BufferedMessage) []*storetypes.Message {
if len(buffered) == 0 {
return nil
}
messages := make([]*storetypes.Message, len(buffered))
for i, msg := range buffered {
messages[i] = &storetypes.Message{
MessageID: msg.MessageID,
ChatID: msg.ChatID,
RequestID: msg.RequestID,
Role: msg.Role,
Type: msg.Type,
Props: msg.Props,
BlockID: msg.BlockID,
ThreadID: msg.ThreadID,
AssistantID: msg.AssistantID,
Sequence: msg.Sequence,
Metadata: msg.Metadata,
CreatedAt: msg.CreatedAt,
UpdatedAt: msg.CreatedAt,
}
}
return messages
}
// convertBufferedSteps converts BufferedStep slice to store Resume slice
func (ast *Assistant) convertBufferedSteps(buffered []*agentcontext.BufferedStep) []*storetypes.Resume {
if len(buffered) == 0 {
return nil
}
steps := make([]*storetypes.Resume, len(buffered))
for i, step := range buffered {
steps[i] = &storetypes.Resume{
ResumeID: step.ResumeID,
ChatID: step.ChatID,
RequestID: step.RequestID,
AssistantID: step.AssistantID,
StackID: step.StackID,
StackParentID: step.StackParentID,
StackDepth: step.StackDepth,
Type: step.Type,
Status: step.Status,
Input: step.Input,
Output: step.Output,
SpaceSnapshot: step.SpaceSnapshot,
Error: step.Error,
Sequence: step.Sequence,
Metadata: step.Metadata,
CreatedAt: step.CreatedAt,
UpdatedAt: step.CreatedAt,
}
}
return steps
}
// EnsureChat ensures a chat session exists, creates if not
func (ast *Assistant) EnsureChat(ctx *agentcontext.Context) error {
if ctx.ChatID == "" {
return nil // No chat ID, skip
}
chatStore := GetChatStore()
if chatStore == nil {
return nil // No store, skip
}
// Check if chat exists
_, err := chatStore.GetChat(ctx.ChatID)
if err == nil {
return nil // Chat exists
}
// Create new chat with permission fields
chat := &storetypes.Chat{
ChatID: ctx.ChatID,
AssistantID: ast.ID,
Mode: "chat",
Status: "active",
Share: "private",
Sort: 0,
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
}
// Set permission fields from authorized info
if ctx.Authorized != nil {
chat.CreatedBy = ctx.Authorized.UserID
chat.UpdatedBy = ctx.Authorized.UserID
chat.TeamID = ctx.Authorized.TeamID
chat.TenantID = ctx.Authorized.TenantID
}
return chatStore.CreateChat(chat)
}
// GetChatStore returns the chat store instance
// Returns nil if storage is not configured
func GetChatStore() storetypes.ChatStore {
if storage == nil {
return nil
}
return storage
}
// =============================================================================
// Deprecated methods (kept for compatibility)
// =============================================================================
func (ast *Assistant) saveChat(ctx *agentcontext.Context, input []agentcontext.Message, opts *agentcontext.Options) error {
_ = ctx
_ = input

View file

@ -7,10 +7,13 @@ import (
"testing"
"time"
"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"
"github.com/yaoapp/yao/agent/testutils"
"github.com/yaoapp/yao/kb"
oauthtypes "github.com/yaoapp/yao/openapi/oauth/types"
@ -300,3 +303,566 @@ func TestInitializeConversation(t *testing.T) {
t.Logf("✓ Correctly skipped with history flag")
})
}
// =============================================================================
// Buffer Integration Tests
// =============================================================================
func TestBufferInitialization(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
require.NotNil(t, ast)
t.Run("InitBufferForRootStack", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_buffer_001")
// Enter stack to simulate root stack
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
// Initialize buffer
ast.InitBuffer(ctx)
// Verify buffer was created
assert.NotNil(t, ctx.Buffer, "Buffer should be initialized for root stack")
assert.Equal(t, "test_chat_buffer_001", ctx.Buffer.ChatID())
assert.Equal(t, ast.ID, ctx.Buffer.AssistantID())
t.Logf("✓ Buffer initialized: chatID=%s, assistantID=%s", ctx.Buffer.ChatID(), ctx.Buffer.AssistantID())
})
t.Run("SkipBufferForNestedStack", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_buffer_nested")
// Enter root stack
_, _, doneRoot := agentcontext.EnterStack(ctx, "root_assistant", nil)
defer doneRoot()
// Enter nested stack
_, _, doneNested := agentcontext.EnterStack(ctx, "nested_assistant", nil)
defer doneNested()
// Try to initialize buffer (should be skipped for nested stack)
ast.InitBuffer(ctx)
// Buffer should be nil because we're not at root
assert.Nil(t, ctx.Buffer, "Buffer should not be initialized for nested stack")
t.Logf("✓ Buffer correctly skipped for nested stack")
})
t.Run("IdempotentBufferInit", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_buffer_idem")
// Enter stack
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
// Initialize buffer twice
ast.InitBuffer(ctx)
firstBuffer := ctx.Buffer
ast.InitBuffer(ctx)
secondBuffer := ctx.Buffer
// Should be the same buffer instance
assert.Same(t, firstBuffer, secondBuffer, "Buffer should be idempotent")
t.Logf("✓ Buffer initialization is idempotent")
})
}
func TestBufferUserInput(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
t.Run("BufferSimpleTextInput", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_input_001")
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Create input messages
inputMessages := []agentcontext.Message{
{
Role: agentcontext.RoleUser,
Content: "Hello, how are you?",
},
}
// Buffer user input
ast.BufferUserInput(ctx, inputMessages)
// Verify buffer contains the message
messages := ctx.Buffer.GetMessages()
assert.Len(t, messages, 1, "Should have 1 buffered message")
assert.Equal(t, "user", messages[0].Role)
assert.Equal(t, "user_input", messages[0].Type)
assert.Equal(t, "Hello, how are you?", messages[0].Props["content"])
t.Logf("✓ User input buffered: %v", messages[0].Props)
})
t.Run("BufferMultipleMessages", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_input_multi")
// Enter stack and init buffer
_, _, done := agentcontext.EnterStack(ctx, ast.ID, nil)
defer done()
ast.InitBuffer(ctx)
// Create multiple input messages
inputMessages := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "First message"},
{Role: agentcontext.RoleUser, Content: "Second message"},
}
// Buffer user input
ast.BufferUserInput(ctx, inputMessages)
// Verify buffer contains all messages
messages := ctx.Buffer.GetMessages()
assert.Len(t, messages, 2, "Should have 2 buffered messages")
assert.Equal(t, 1, messages[0].Sequence)
assert.Equal(t, 2, messages[1].Sequence)
t.Logf("✓ Multiple messages buffered with correct sequence")
})
t.Run("BufferWithNilBuffer", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "test_chat_input_nil")
// Don't initialize buffer
inputMessages := []agentcontext.Message{
{Role: agentcontext.RoleUser, Content: "Test"},
}
// Should not panic
ast.BufferUserInput(ctx, inputMessages)
t.Logf("✓ BufferUserInput handles nil buffer gracefully")
})
}
func TestBufferStepTracking(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
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")
// Begin a step
step := ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{
"messages": []string{"Hello"},
})
assert.NotNil(t, step, "Step should be created")
assert.Equal(t, agentcontext.StepTypeLLM, step.Type)
assert.Equal(t, agentcontext.StepStatusRunning, step.Status)
assert.NotEmpty(t, step.StackID)
// Complete the step
ast.CompleteStep(ctx, map[string]interface{}{
"content": "Response",
})
// Verify step is completed
steps := ctx.Buffer.GetAllSteps()
assert.Len(t, steps, 1)
assert.Equal(t, agentcontext.StepStatusCompleted, steps[0].Status)
assert.Equal(t, "Response", steps[0].Output["content"])
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()
// 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)
// Begin step (should capture space snapshot)
ast.BeginStep(ctx, agentcontext.StepTypeHookCreate, nil)
// Verify space 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.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)
defer done()
ast.InitBuffer(ctx)
// Step 1: hook_create
ast.BeginStep(ctx, agentcontext.StepTypeHookCreate, map[string]interface{}{"phase": "create"})
ast.CompleteStep(ctx, map[string]interface{}{"result": "created"})
// Step 2: llm
ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{"phase": "llm"})
ast.CompleteStep(ctx, map[string]interface{}{"result": "completed"})
// Step 3: hook_next
ast.BeginStep(ctx, agentcontext.StepTypeHookNext, map[string]interface{}{"phase": "next"})
ast.CompleteStep(ctx, map[string]interface{}{"result": "done"})
// Verify all steps
steps := ctx.Buffer.GetAllSteps()
assert.Len(t, steps, 3)
assert.Equal(t, agentcontext.StepTypeHookCreate, steps[0].Type)
assert.Equal(t, agentcontext.StepTypeLLM, steps[1].Type)
assert.Equal(t, agentcontext.StepTypeHookNext, steps[2].Type)
t.Logf("✓ Multiple steps tracked correctly")
})
}
func TestFlushBuffer(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
// Skip if chat store not available
chatStore := assistant.GetChatStore()
if chatStore == nil {
t.Skip("Chat store not configured, skipping flush tests")
}
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)
defer done()
ast.InitBuffer(ctx)
// Ensure chat exists
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Mode: "chat",
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
// Add some messages to buffer
ctx.Buffer.AddUserInput("Test question", "")
ctx.Buffer.AddAssistantMessage("text", map[string]interface{}{"content": "Test answer"}, "", "", ast.ID, nil)
// Add a step
ast.BeginStep(ctx, agentcontext.StepTypeLLM, nil)
ast.CompleteStep(ctx, nil)
// Flush buffer (success case)
ast.FlushBuffer(ctx, agentcontext.StepStatusCompleted, nil)
// Verify messages were saved
messages, err := chatStore.GetMessages(chatID, storetypes.MessageFilter{})
assert.NoError(t, err)
assert.Len(t, messages, 2, "Should have 2 messages saved")
// Verify no resume records (success case)
resumes, err := chatStore.GetResume(chatID)
assert.NoError(t, err)
assert.Len(t, resumes, 0, "Should have no resume records on success")
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ Buffer flushed on success: %d messages saved, no resume records", len(messages))
})
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)
defer done()
ast.InitBuffer(ctx)
// Ensure chat exists
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Mode: "chat",
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
// Add messages
ctx.Buffer.AddUserInput("Test question", "")
// Add a step that will "fail"
ast.BeginStep(ctx, agentcontext.StepTypeLLM, map[string]interface{}{"test": "data"})
// Don't complete - simulate failure
// Flush buffer (failure case)
testErr := fmt.Errorf("simulated error")
ast.FlushBuffer(ctx, agentcontext.ResumeStatusFailed, testErr)
// Verify messages were saved
messages, err := chatStore.GetMessages(chatID, storetypes.MessageFilter{})
assert.NoError(t, err)
assert.Len(t, messages, 1, "Should have 1 message saved")
// Verify resume records were saved
resumes, err := chatStore.GetResume(chatID)
assert.NoError(t, err)
assert.Len(t, resumes, 1, "Should have 1 resume record on failure")
assert.Equal(t, agentcontext.ResumeStatusFailed, resumes[0].Status)
// Cleanup
chatStore.DeleteResume(chatID)
chatStore.DeleteChat(chatID)
t.Logf("✓ Buffer flushed on failure: messages and resume records saved")
})
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)
defer done()
ast.InitBuffer(ctx)
// Ensure chat exists
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Mode: "chat",
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
// Add messages and steps
ctx.Buffer.AddUserInput("Test question", "")
ast.BeginStep(ctx, agentcontext.StepTypeLLM, nil)
// Flush buffer (interrupt case)
ast.FlushBuffer(ctx, agentcontext.ResumeStatusInterrupted, nil)
// Verify resume records were saved with interrupted status
resumes, err := chatStore.GetResume(chatID)
assert.NoError(t, err)
assert.Len(t, resumes, 1, "Should have 1 resume record on interrupt")
assert.Equal(t, agentcontext.ResumeStatusInterrupted, resumes[0].Status)
// Cleanup
chatStore.DeleteResume(chatID)
chatStore.DeleteChat(chatID)
t.Logf("✓ Buffer flushed on interrupt: resume records saved with interrupted status")
})
}
func TestEnsureChat(t *testing.T) {
testutils.Prepare(t)
defer testutils.Clean(t)
ast, err := assistant.Get("mohe")
require.NoError(t, err)
// Skip if chat store not available
chatStore := assistant.GetChatStore()
if chatStore == nil {
t.Skip("Chat store not configured, skipping EnsureChat tests")
}
t.Run("CreateNewChat", func(t *testing.T) {
chatID := fmt.Sprintf("test_ensure_new_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
// Ensure chat creates it
err := ast.EnsureChat(ctx)
assert.NoError(t, err)
// Verify chat was created
chat, err := chatStore.GetChat(chatID)
assert.NoError(t, err)
assert.NotNil(t, chat)
assert.Equal(t, chatID, chat.ChatID)
assert.Equal(t, ast.ID, chat.AssistantID)
assert.Equal(t, "active", chat.Status)
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ New chat created: %s", chatID)
})
t.Run("SkipExistingChat", func(t *testing.T) {
chatID := fmt.Sprintf("test_ensure_exist_%s", uuid.New().String()[:8])
ctx := agentcontext.New(context.Background(), nil, chatID)
// Create chat first
err := chatStore.CreateChat(&storetypes.Chat{
ChatID: chatID,
AssistantID: ast.ID,
Title: "Existing Chat",
Mode: "chat",
Status: "active",
Share: "private",
CreatedAt: time.Now(),
UpdatedAt: time.Now(),
})
require.NoError(t, err)
// EnsureChat should not error
err = ast.EnsureChat(ctx)
assert.NoError(t, err)
// Verify chat still has original title
chat, err := chatStore.GetChat(chatID)
assert.NoError(t, err)
assert.Equal(t, "Existing Chat", chat.Title)
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ Existing chat preserved")
})
t.Run("SkipEmptyChatID", func(t *testing.T) {
ctx := agentcontext.New(context.Background(), nil, "")
// Should not error with empty chat ID
err := ast.EnsureChat(ctx)
assert.NoError(t, err)
t.Logf("✓ Empty chat ID handled gracefully")
})
t.Run("CreateChatWithPermissions", func(t *testing.T) {
chatID := fmt.Sprintf("test_ensure_perm_%s", uuid.New().String()[:8])
// Create context with authorized info
ctx := agentcontext.New(context.Background(), &oauthtypes.AuthorizedInfo{
UserID: "test_user_001",
TeamID: "test_team_001",
TenantID: "test_tenant_001",
}, chatID)
// EnsureChat should create with permission fields
err := ast.EnsureChat(ctx)
assert.NoError(t, err)
// Verify permission fields were saved
chat, err := chatStore.GetChat(chatID)
assert.NoError(t, err)
assert.NotNil(t, chat)
assert.Equal(t, "test_user_001", chat.CreatedBy, "CreatedBy should be set")
assert.Equal(t, "test_user_001", chat.UpdatedBy, "UpdatedBy should be set")
assert.Equal(t, "test_team_001", chat.TeamID, "TeamID should be set")
assert.Equal(t, "test_tenant_001", chat.TenantID, "TenantID should be set")
// Cleanup
chatStore.DeleteChat(chatID)
t.Logf("✓ Chat created with permission fields: user=%s, team=%s, tenant=%s",
chat.CreatedBy, chat.TeamID, chat.TenantID)
})
}
func TestConvertBufferedTypes(t *testing.T) {
t.Run("ConvertBufferedMessages", func(t *testing.T) {
// Create buffered messages
buffered := []*agentcontext.BufferedMessage{
{
MessageID: "msg_001",
ChatID: "chat_001",
RequestID: "req_001",
Role: "user",
Type: "user_input",
Props: map[string]interface{}{"content": "Hello"},
Sequence: 1,
CreatedAt: time.Now(),
},
{
MessageID: "msg_002",
ChatID: "chat_001",
RequestID: "req_001",
Role: "assistant",
Type: "text",
Props: map[string]interface{}{"content": "Hi there!"},
BlockID: "block_001",
AssistantID: "test_assistant",
Sequence: 2,
CreatedAt: time.Now(),
},
}
// Verify structure matches store types
assert.Len(t, buffered, 2)
assert.Equal(t, "user", buffered[0].Role)
assert.Equal(t, "assistant", buffered[1].Role)
assert.Equal(t, "block_001", buffered[1].BlockID)
t.Logf("✓ Buffered messages have correct structure")
})
t.Run("ConvertBufferedSteps", func(t *testing.T) {
// Create buffered steps
buffered := []*agentcontext.BufferedStep{
{
ResumeID: "resume_001",
ChatID: "chat_001",
RequestID: "req_001",
AssistantID: "test_assistant",
StackID: "stack_001",
StackDepth: 0,
Type: agentcontext.StepTypeLLM,
Status: agentcontext.ResumeStatusFailed,
Input: map[string]interface{}{"messages": []string{"Hello"}},
SpaceSnapshot: map[string]interface{}{"key": "value"},
Error: "Test error",
Sequence: 1,
CreatedAt: time.Now(),
},
}
// Verify structure
assert.Len(t, buffered, 1)
assert.Equal(t, agentcontext.StepTypeLLM, buffered[0].Type)
assert.Equal(t, agentcontext.ResumeStatusFailed, buffered[0].Status)
assert.Equal(t, "Test error", buffered[0].Error)
assert.Equal(t, "value", buffered[0].SpaceSnapshot["key"])
t.Logf("✓ Buffered steps have correct structure")
})
}

View file

@ -323,6 +323,57 @@ func (s *streamState) handleMessageEnd(data []byte) int {
threadID = s.ctx.Stack.ID
}
// Get BlockID from metadata if available
var blockID string
if s.ctx != nil {
if metadata := s.ctx.GetMessageMetadata(s.currentGroupID); metadata != nil {
blockID = metadata.BlockID
}
}
// Buffer the complete LLM message for storage
// Delta chunks are not stored, but we need to save the final complete content
// Skip if History is disabled in options
shouldSkipHistory := s.ctx.Stack != nil && s.ctx.Stack.Options != nil &&
s.ctx.Stack.Options.Skip != nil && s.ctx.Stack.Options.Skip.History
if s.ctx.Buffer != nil && len(s.buffer) > 0 && !shouldSkipHistory {
assistantID := ""
if s.ctx.Stack != nil {
assistantID = s.ctx.Stack.AssistantID
}
// Build props based on message type
var props map[string]interface{}
if msgType == message.TypeToolCall {
// For tool calls, try to parse the accumulated buffer as JSON
var toolCallData interface{}
if err := jsoniter.Unmarshal(s.buffer, &toolCallData); err == nil {
props = map[string]interface{}{
"calls": toolCallData,
}
} else {
props = map[string]interface{}{
"content": string(s.buffer),
}
}
} else {
// For text/thinking, content is the accumulated text
props = map[string]interface{}{
"content": string(s.buffer),
}
}
s.ctx.Buffer.AddAssistantMessage(
msgType,
props,
blockID,
threadID,
assistantID,
nil,
)
}
// Build EventMessageEndData with complete content
endData := message.EventMessageEndData{
MessageID: s.currentGroupID, // Use the message ID

359
agent/context/buffer.go Normal file
View file

@ -0,0 +1,359 @@
package context
import (
"sync"
"time"
"github.com/google/uuid"
)
// =============================================================================
// Chat Buffer - Buffers messages and steps during execution for batch saving
// =============================================================================
// ChatBuffer buffers messages and resume steps during agent execution
// All data is held in memory and batch-written at the end of Stream()
type ChatBuffer struct {
// Identity
chatID string
requestID string
assistantID string
// Message buffer
messages []*BufferedMessage
msgSequence int
// Step buffer (for Resume)
steps []*BufferedStep
currentStep *BufferedStep
stepSequence int
// Space snapshot (captured when step starts, for recovery)
spaceSnapshot map[string]interface{}
mu sync.Mutex
}
// BufferedMessage represents a message waiting to be saved
type BufferedMessage struct {
MessageID string `json:"message_id"`
ChatID string `json:"chat_id"`
RequestID string `json:"request_id,omitempty"`
Role string `json:"role"` // "user" or "assistant"
Type string `json:"type"` // "text", "image", "loading", "tool_call", "retrieval", etc.
Props map[string]interface{} `json:"props"`
BlockID string `json:"block_id,omitempty"`
ThreadID string `json:"thread_id,omitempty"`
AssistantID string `json:"assistant_id,omitempty"`
Sequence int `json:"sequence"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// BufferedStep represents an execution step waiting to be saved (for Resume)
// Only saved when request is interrupted or failed
type BufferedStep struct {
ResumeID string `json:"resume_id"`
ChatID string `json:"chat_id"`
RequestID string `json:"request_id"`
AssistantID string `json:"assistant_id"`
StackID string `json:"stack_id"`
StackParentID string `json:"stack_parent_id,omitempty"`
StackDepth int `json:"stack_depth"`
Type string `json:"type"` // "input", "hook_create", "llm", "tool", "hook_next", "delegate"
Status string `json:"status"` // "running", "completed", "failed", "interrupted"
Input map[string]interface{} `json:"input,omitempty"`
Output map[string]interface{} `json:"output,omitempty"`
SpaceSnapshot map[string]interface{} `json:"space_snapshot,omitempty"`
Error string `json:"error,omitempty"`
Sequence int `json:"sequence"`
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
}
// Step status constants (internal use only, not stored in database)
const (
StepStatusRunning = "running"
StepStatusCompleted = "completed"
)
// Step type constants
const (
StepTypeInput = "input"
StepTypeHookCreate = "hook_create"
StepTypeLLM = "llm"
StepTypeTool = "tool"
StepTypeHookNext = "hook_next"
StepTypeDelegate = "delegate"
)
// Resume status constants (for database storage)
const (
ResumeStatusFailed = "failed"
ResumeStatusInterrupted = "interrupted"
)
// NewChatBuffer creates a new chat buffer
func NewChatBuffer(chatID, requestID, assistantID string) *ChatBuffer {
return &ChatBuffer{
chatID: chatID,
requestID: requestID,
assistantID: assistantID,
messages: make([]*BufferedMessage, 0),
steps: make([]*BufferedStep, 0),
}
}
// =============================================================================
// Message Buffer Methods
// =============================================================================
// AddMessage adds a message to the buffer
func (b *ChatBuffer) AddMessage(msg *BufferedMessage) {
if msg == nil {
return
}
b.mu.Lock()
defer b.mu.Unlock()
// Auto-generate IDs if not provided
if msg.MessageID == "" {
msg.MessageID = uuid.New().String()
}
if msg.ChatID == "" {
msg.ChatID = b.chatID
}
if msg.RequestID == "" {
msg.RequestID = b.requestID
}
if msg.CreatedAt.IsZero() {
msg.CreatedAt = time.Now()
}
// Auto-increment sequence
b.msgSequence++
msg.Sequence = b.msgSequence
b.messages = append(b.messages, msg)
}
// AddUserInput adds user input message to the buffer
func (b *ChatBuffer) AddUserInput(content interface{}, name string) {
props := map[string]interface{}{
"content": content,
"role": "user",
}
if name != "" {
props["name"] = name
}
b.AddMessage(&BufferedMessage{
Role: "user",
Type: "user_input",
Props: props,
})
}
// AddAssistantMessage adds an assistant message to the buffer
// This is called by ctx.Send() to buffer messages for batch saving
func (b *ChatBuffer) AddAssistantMessage(msgType string, props map[string]interface{}, blockID, threadID, assistantID string, metadata map[string]interface{}) {
// Skip event type messages (transient, not stored)
if msgType == "event" {
return
}
b.AddMessage(&BufferedMessage{
Role: "assistant",
Type: msgType,
Props: props,
BlockID: blockID,
ThreadID: threadID,
AssistantID: assistantID,
Metadata: metadata,
})
}
// GetMessages returns all buffered messages
func (b *ChatBuffer) GetMessages() []*BufferedMessage {
b.mu.Lock()
defer b.mu.Unlock()
result := make([]*BufferedMessage, len(b.messages))
copy(result, b.messages)
return result
}
// GetMessageCount returns the number of buffered messages
func (b *ChatBuffer) GetMessageCount() int {
b.mu.Lock()
defer b.mu.Unlock()
return len(b.messages)
}
// =============================================================================
// Step Buffer Methods (for Resume)
// =============================================================================
// BeginStep starts tracking a new execution step
// Returns the step for further updates
func (b *ChatBuffer) BeginStep(stepType string, input map[string]interface{}, stack *Stack) *BufferedStep {
b.mu.Lock()
defer b.mu.Unlock()
b.stepSequence++
step := &BufferedStep{
ResumeID: uuid.New().String(),
ChatID: b.chatID,
RequestID: b.requestID,
AssistantID: b.assistantID,
Type: stepType,
Status: StepStatusRunning,
Input: input,
Sequence: b.stepSequence,
CreatedAt: time.Now(),
}
// Set stack information if available
if stack != nil {
step.StackID = stack.ID
step.StackParentID = stack.ParentID
step.StackDepth = stack.Depth
}
// Capture current space snapshot
if b.spaceSnapshot != nil {
step.SpaceSnapshot = copyMap(b.spaceSnapshot)
}
b.steps = append(b.steps, step)
b.currentStep = step
return step
}
// CompleteStep marks the current step as completed
func (b *ChatBuffer) CompleteStep(output map[string]interface{}) {
b.mu.Lock()
defer b.mu.Unlock()
if b.currentStep != nil {
b.currentStep.Output = output
b.currentStep.Status = StepStatusCompleted
b.currentStep = nil
}
}
// FailCurrentStep marks the current step as failed or interrupted
func (b *ChatBuffer) FailCurrentStep(status string, err error) {
b.mu.Lock()
defer b.mu.Unlock()
if b.currentStep != nil && b.currentStep.Status == StepStatusRunning {
b.currentStep.Status = status
if err != nil {
b.currentStep.Error = err.Error()
}
}
}
// GetCurrentStep returns the current running step
func (b *ChatBuffer) GetCurrentStep() *BufferedStep {
b.mu.Lock()
defer b.mu.Unlock()
return b.currentStep
}
// GetStepsForResume returns steps that need to be saved for resume
// Only returns steps with failed or interrupted status
func (b *ChatBuffer) GetStepsForResume(finalStatus string) []*BufferedStep {
b.mu.Lock()
defer b.mu.Unlock()
// If completed successfully, no steps need to be saved
if finalStatus == StepStatusCompleted {
return nil
}
// Mark current running step with final status
if b.currentStep != nil && b.currentStep.Status == StepStatusRunning {
b.currentStep.Status = finalStatus
}
// Return all steps (they will all have the context for recovery)
result := make([]*BufferedStep, len(b.steps))
copy(result, b.steps)
return result
}
// GetAllSteps returns all buffered steps (for debugging/testing)
func (b *ChatBuffer) GetAllSteps() []*BufferedStep {
b.mu.Lock()
defer b.mu.Unlock()
result := make([]*BufferedStep, len(b.steps))
copy(result, b.steps)
return result
}
// =============================================================================
// Space Snapshot Methods
// =============================================================================
// SetSpaceSnapshot sets the space snapshot for recovery
// Should be called when space data changes
func (b *ChatBuffer) SetSpaceSnapshot(snapshot map[string]interface{}) {
b.mu.Lock()
defer b.mu.Unlock()
b.spaceSnapshot = copyMap(snapshot)
}
// GetSpaceSnapshot returns the current space snapshot
func (b *ChatBuffer) GetSpaceSnapshot() map[string]interface{} {
b.mu.Lock()
defer b.mu.Unlock()
return copyMap(b.spaceSnapshot)
}
// =============================================================================
// Identity Methods
// =============================================================================
// ChatID returns the chat ID
func (b *ChatBuffer) ChatID() string {
return b.chatID
}
// RequestID returns the request ID
func (b *ChatBuffer) RequestID() string {
return b.requestID
}
// AssistantID returns the assistant ID
func (b *ChatBuffer) AssistantID() string {
return b.assistantID
}
// SetAssistantID updates the assistant ID (for A2A calls)
func (b *ChatBuffer) SetAssistantID(assistantID string) {
b.mu.Lock()
defer b.mu.Unlock()
b.assistantID = assistantID
}
// =============================================================================
// Helper Functions
// =============================================================================
// copyMap creates a shallow copy of a map
func copyMap(src map[string]interface{}) map[string]interface{} {
if src == nil {
return nil
}
dst := make(map[string]interface{}, len(src))
for k, v := range src {
dst[k] = v
}
return dst
}

1074
agent/context/buffer_test.go Normal file

File diff suppressed because it is too large Load diff

View file

@ -387,3 +387,93 @@ func (ctx *Context) getMessageMetadata(messageID string) *MessageMetadata {
}
return ctx.messageMetadata.getMessage(messageID)
}
// GetMessageMetadata returns metadata for a message (public version)
func (ctx *Context) GetMessageMetadata(messageID string) *MessageMetadata {
return ctx.getMessageMetadata(messageID)
}
// =============================================================================
// Chat Buffer Methods
// =============================================================================
// InitBuffer initializes the chat buffer for this context
// Should be called at the start of Stream() to begin buffering messages and steps
func (ctx *Context) InitBuffer(assistantID string) *ChatBuffer {
ctx.Buffer = NewChatBuffer(ctx.ChatID, ctx.RequestID(), assistantID)
return ctx.Buffer
}
// HasBuffer returns true if the buffer is initialized
func (ctx *Context) HasBuffer() bool {
return ctx.Buffer != nil
}
// BufferUserInput adds user input to the buffer
// Should be called at the start of Stream() to buffer the user's input message
func (ctx *Context) BufferUserInput(messages []Message) {
if ctx.Buffer == nil {
return
}
for _, msg := range messages {
if msg.Role == RoleUser {
// Get name if available
var name string
if msg.Name != nil {
name = *msg.Name
}
ctx.Buffer.AddUserInput(msg.Content, name)
}
}
}
// BufferAssistantMessage adds an assistant message to the buffer
// Called by ctx.Send() to buffer messages for batch saving
func (ctx *Context) BufferAssistantMessage(msgType string, props map[string]interface{}, blockID, threadID string, metadata map[string]interface{}) {
if ctx.Buffer == nil {
return
}
ctx.Buffer.AddAssistantMessage(msgType, props, blockID, threadID, ctx.AssistantID, metadata)
}
// BeginStep starts tracking a new execution step
// Returns the step for further updates
func (ctx *Context) BeginStep(stepType string, input map[string]interface{}) *BufferedStep {
if ctx.Buffer == nil {
return nil
}
// Update space snapshot before starting step
if ctx.Space != nil {
ctx.Buffer.SetSpaceSnapshot(ctx.Space.Snapshot())
}
return ctx.Buffer.BeginStep(stepType, input, ctx.Stack)
}
// CompleteStep marks the current step as completed
func (ctx *Context) CompleteStep(output map[string]interface{}) {
if ctx.Buffer == nil {
return
}
ctx.Buffer.CompleteStep(output)
}
// FailCurrentStep marks the current step as failed or interrupted
func (ctx *Context) FailCurrentStep(status string, err error) {
if ctx.Buffer == nil {
return
}
ctx.Buffer.FailCurrentStep(status, err)
}
// shouldSkipHistory checks if history saving should be skipped
// Returns true if Skip.History is set in the current stack options
func (ctx *Context) shouldSkipHistory() bool {
if ctx.Stack == nil || ctx.Stack.Options == nil || ctx.Stack.Options.Skip == nil {
return false
}
return ctx.Stack.Options.Skip.History
}

View file

@ -146,6 +146,25 @@ func (ctx *Context) Send(msg *message.Message) error {
return err
}
// === Buffer message for batch saving (non-delta, non-event messages only) ===
// Delta messages are streaming chunks; only final content should be saved
// Event messages are transient lifecycle signals, not stored
// Skip if History is disabled in options
if !msg.Delta && !isEventMessage && ctx.Buffer != nil && !ctx.shouldSkipHistory() {
assistantID := ""
if ctx.Stack != nil {
assistantID = ctx.Stack.AssistantID
}
ctx.Buffer.AddAssistantMessage(
msg.Type,
msg.Props,
msg.BlockID,
msg.ThreadID,
assistantID,
nil, // metadata can be added if needed
)
}
// === Auto-send message_end for non-delta messages (complete messages) ===
if !msg.Delta && !isEventMessage && msg.MessageID != "" && ctx.messageMetadata != nil {
metadata := ctx.messageMetadata.getMessage(msg.MessageID)

View file

@ -231,6 +231,9 @@ type Context struct {
Writer Writer `json:"-"` // Writer, it will be used to write response data to the client
IDGenerator *message.IDGenerator `json:"-"` // ID generator for this context (chunk, message, block, thread IDs)
// Chat buffer for batch saving messages and resume steps
Buffer *ChatBuffer `json:"-"` // Chat buffer for batch saving at end of Stream()
// Internal
trace traceTypes.Manager `json:"-"` // Trace manager, lazy initialized on first access
messageMetadata *messageMetadataStore `json:"-"` // Thread-safe message metadata store for delta operations

View file

@ -38,6 +38,12 @@ type Chat struct {
Metadata map[string]interface{} `json:"metadata,omitempty"`
CreatedAt time.Time `json:"created_at"`
UpdatedAt time.Time `json:"updated_at"`
// Permission fields (managed by Yao framework when permission: true)
CreatedBy string `json:"__yao_created_by,omitempty"` // User ID who created the record
UpdatedBy string `json:"__yao_updated_by,omitempty"` // User ID who last updated
TeamID string `json:"__yao_team_id,omitempty"` // Team ID for team-level access
TenantID string `json:"__yao_tenant_id,omitempty"` // Tenant ID for multi-tenancy
}
// ChatFilter for listing chats

View file

@ -80,6 +80,20 @@ func (store *Xun) CreateChat(chat *types.Chat) error {
data["metadata"] = metadataJSON
}
// Handle permission fields (Yao framework permission: true)
if chat.CreatedBy != "" {
data["__yao_created_by"] = chat.CreatedBy
}
if chat.UpdatedBy != "" {
data["__yao_updated_by"] = chat.UpdatedBy
}
if chat.TeamID != "" {
data["__yao_team_id"] = chat.TeamID
}
if chat.TenantID != "" {
data["__yao_tenant_id"] = chat.TenantID
}
// Insert
return store.newQueryChat().Insert(data)
}
@ -349,6 +363,12 @@ func (store *Xun) rowToChat(data map[string]interface{}) (*types.Chat, error) {
}
}
// Handle permission fields
chat.CreatedBy = getString(data, "__yao_created_by")
chat.UpdatedBy = getString(data, "__yao_updated_by")
chat.TeamID = getString(data, "__yao_team_id")
chat.TenantID = getString(data, "__yao_tenant_id")
return chat, nil
}