Enhance content processing with forceUses configuration
- Updated the Vision function and associated handlers to accept a forceUses parameter, allowing explicit control over the use of external tools regardless of model capabilities. - Modified the Assistant's BuildContent method to set the AssistantID in the context for better file tracking. - Enhanced the applyCreateResponseOptions method to merge and prioritize Uses and ForceUses configurations from the createResponse. - Added comprehensive tests to validate the adjustment of Uses and ForceUses configurations through hooks, ensuring correct application in content processing.
This commit is contained in:
parent
d9b8496a7d
commit
666c1e8d74
22 changed files with 1291 additions and 84 deletions
|
|
@ -500,9 +500,39 @@ func (ast *Assistant) applyCreateResponseOptions(options *context.CompletionOpti
|
|||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Uses configuration (merge with existing)
|
||||
// createResponse.Uses has highest priority and overrides existing Uses
|
||||
if createResponse.Uses != nil {
|
||||
if options.Uses == nil {
|
||||
options.Uses = createResponse.Uses
|
||||
} else {
|
||||
// Merge: createResponse.Uses overrides existing (only non-empty fields)
|
||||
if createResponse.Uses.Vision != "" {
|
||||
options.Uses.Vision = createResponse.Uses.Vision
|
||||
}
|
||||
if createResponse.Uses.Audio != "" {
|
||||
options.Uses.Audio = createResponse.Uses.Audio
|
||||
}
|
||||
if createResponse.Uses.Search != "" {
|
||||
options.Uses.Search = createResponse.Uses.Search
|
||||
}
|
||||
if createResponse.Uses.Fetch != "" {
|
||||
options.Uses.Fetch = createResponse.Uses.Fetch
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ForceUses configuration
|
||||
// If hook specifies ForceUses, it takes priority
|
||||
if createResponse.ForceUses != nil {
|
||||
options.ForceUses = *createResponse.ForceUses
|
||||
}
|
||||
}
|
||||
|
||||
// getUses get the Uses configuration with priority: assistant.Uses > global settings
|
||||
// Note: createResponse.Uses (applied in applyCreateResponseOptions) has even higher priority
|
||||
// Final priority order: createResponse.Uses > assistant.Uses > global settings
|
||||
func (ast *Assistant) getUses() *context.Uses {
|
||||
// Priority 1: Assistant-specific Uses configuration
|
||||
if ast.Uses != nil {
|
||||
|
|
|
|||
|
|
@ -12,6 +12,12 @@ import (
|
|||
//
|
||||
// This should be called after BuildRequest and before executing LLM call
|
||||
func (ast *Assistant) BuildContent(ctx *context.Context, messages []context.Message, options *context.CompletionOptions, opts *context.Options) ([]context.Message, error) {
|
||||
// Set AssistantID in context for file info tracking in Space
|
||||
// This ensures hooks can access file information using the correct namespace
|
||||
if ctx.AssistantID == "" {
|
||||
ctx.AssistantID = ast.ID
|
||||
}
|
||||
|
||||
// Get connector and capabilities
|
||||
_, capabilities, err := ast.GetConnector(ctx, opts)
|
||||
if err != nil {
|
||||
|
|
@ -21,8 +27,11 @@ func (ast *Assistant) BuildContent(ctx *context.Context, messages []context.Mess
|
|||
// Get Uses configuration from options (already merged in BuildRequest)
|
||||
uses := options.Uses
|
||||
|
||||
// Get ForceUses configuration from options
|
||||
forceUses := options.ForceUses
|
||||
|
||||
// Process content through Vision function
|
||||
processedMessages, err := content.Vision(ctx, capabilities, messages, uses)
|
||||
processedMessages, err := content.Vision(ctx, capabilities, messages, uses, forceUses)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to process content: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -352,4 +352,130 @@ func TestCreate(t *testing.T) {
|
|||
|
||||
t.Log("✓ Context fields successfully adjusted by hook")
|
||||
})
|
||||
|
||||
// Test scenario 10: Adjust uses configuration - tests that uses can be modified by the hook
|
||||
t.Run("AdjustUses", func(t *testing.T) {
|
||||
// Create a fresh context for this test
|
||||
usesCtx := newTestContext("chat-test-uses", "tests.create")
|
||||
|
||||
// Call the hook which should adjust uses configuration
|
||||
res, _, err := agent.HookScript.Create(usesCtx, []context.Message{{Role: "user", Content: "adjust_uses"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with adjust_uses: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify the response contains uses configuration
|
||||
if res.Uses == nil {
|
||||
t.Fatalf("Expected uses configuration, got nil")
|
||||
}
|
||||
|
||||
// Verify each uses field
|
||||
if res.Uses.Vision != "mcp:vision-server" {
|
||||
t.Errorf("Expected vision 'mcp:vision-server', got: %s", res.Uses.Vision)
|
||||
}
|
||||
if res.Uses.Audio != "mcp:audio-server" {
|
||||
t.Errorf("Expected audio 'mcp:audio-server', got: %s", res.Uses.Audio)
|
||||
}
|
||||
if res.Uses.Search != "agent" {
|
||||
t.Errorf("Expected search 'agent', got: %s", res.Uses.Search)
|
||||
}
|
||||
if res.Uses.Fetch != "mcp:fetch-server" {
|
||||
t.Errorf("Expected fetch 'mcp:fetch-server', got: %s", res.Uses.Fetch)
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
if res.Metadata == nil {
|
||||
t.Fatalf("Expected metadata, got nil")
|
||||
}
|
||||
if usesAdjusted, ok := res.Metadata["uses_adjusted"].(bool); !ok || !usesAdjusted {
|
||||
t.Errorf("Expected metadata['uses_adjusted'] = true, got: %v", res.Metadata["uses_adjusted"])
|
||||
}
|
||||
|
||||
// Now test that BuildRequest properly applies the uses configuration
|
||||
inputMessages := []context.Message{{Role: "user", Content: "test uses"}}
|
||||
_, options, err := agent.BuildRequest(usesCtx, inputMessages, res)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify that options.Uses has the values from createResponse
|
||||
if options.Uses == nil {
|
||||
t.Fatalf("Expected options.Uses to be set, got nil")
|
||||
}
|
||||
if options.Uses.Vision != "mcp:vision-server" {
|
||||
t.Errorf("Expected options.Uses.Vision 'mcp:vision-server', got: %s", options.Uses.Vision)
|
||||
}
|
||||
if options.Uses.Audio != "mcp:audio-server" {
|
||||
t.Errorf("Expected options.Uses.Audio 'mcp:audio-server', got: %s", options.Uses.Audio)
|
||||
}
|
||||
if options.Uses.Search != "agent" {
|
||||
t.Errorf("Expected options.Uses.Search 'agent', got: %s", options.Uses.Search)
|
||||
}
|
||||
if options.Uses.Fetch != "mcp:fetch-server" {
|
||||
t.Errorf("Expected options.Uses.Fetch 'mcp:fetch-server', got: %s", options.Uses.Fetch)
|
||||
}
|
||||
|
||||
t.Log("✓ Uses configuration successfully adjusted by hook and applied to options")
|
||||
})
|
||||
|
||||
// Test scenario 11: Adjust uses configuration with force_uses flag
|
||||
t.Run("AdjustUsesForce", func(t *testing.T) {
|
||||
// Create a fresh context for this test
|
||||
usesCtx := newTestContext("chat-test-uses-force", "tests.create")
|
||||
|
||||
// Call the hook which should adjust uses configuration and set force_uses
|
||||
res, _, err := agent.HookScript.Create(usesCtx, []context.Message{{Role: "user", Content: "adjust_uses_force"}})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to create with adjust_uses_force: %s", err.Error())
|
||||
}
|
||||
if res == nil {
|
||||
t.Fatalf("Expected non-nil response, got nil")
|
||||
}
|
||||
|
||||
// Verify the response contains uses configuration
|
||||
if res.Uses == nil {
|
||||
t.Fatalf("Expected uses configuration, got nil")
|
||||
}
|
||||
|
||||
// Verify uses fields
|
||||
if res.Uses.Vision != "tests.vision-helper" {
|
||||
t.Errorf("Expected vision 'tests.vision-helper', got: %s", res.Uses.Vision)
|
||||
}
|
||||
if res.Uses.Audio != "mcp:audio-server" {
|
||||
t.Errorf("Expected audio 'mcp:audio-server', got: %s", res.Uses.Audio)
|
||||
}
|
||||
|
||||
// Verify force_uses flag
|
||||
if res.ForceUses == nil {
|
||||
t.Fatalf("Expected force_uses to be set, got nil")
|
||||
}
|
||||
if !*res.ForceUses {
|
||||
t.Errorf("Expected force_uses to be true, got: %v", *res.ForceUses)
|
||||
}
|
||||
|
||||
// Verify metadata
|
||||
if res.Metadata == nil {
|
||||
t.Fatalf("Expected metadata, got nil")
|
||||
}
|
||||
if usesForced, ok := res.Metadata["uses_forced"].(bool); !ok || !usesForced {
|
||||
t.Errorf("Expected metadata['uses_forced'] = true, got: %v", res.Metadata["uses_forced"])
|
||||
}
|
||||
|
||||
// Now test that BuildRequest properly applies the force_uses flag
|
||||
inputMessages := []context.Message{{Role: "user", Content: "test force uses"}}
|
||||
_, options, err := agent.BuildRequest(usesCtx, inputMessages, res)
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to build request: %s", err.Error())
|
||||
}
|
||||
|
||||
// Verify that options.ForceUses is true
|
||||
if !options.ForceUses {
|
||||
t.Errorf("Expected options.ForceUses to be true, got: %v", options.ForceUses)
|
||||
}
|
||||
|
||||
t.Log("✓ Uses configuration with force_uses flag successfully adjusted by hook and applied to options")
|
||||
})
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func (h *AudioHandler) CanHandle(contentType string, fileType FileType) bool {
|
|||
// Logic similar to image:
|
||||
// 1. If model supports audio input -> convert to base64 format
|
||||
// 2. If model doesn't support audio -> use agent/MCP specified in uses.Audio
|
||||
func (h *AudioHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses) (*Result, error) {
|
||||
func (h *AudioHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses, forceUses bool) (*Result, error) {
|
||||
// TODO: Implement audio handling
|
||||
// 1. Check model audio capabilities
|
||||
// 2. If supported:
|
||||
|
|
|
|||
|
|
@ -51,7 +51,12 @@ import (
|
|||
// - MUST convert to type="text"
|
||||
//
|
||||
// Return: Messages with only standard LLM-compatible content types (text, image_url, input_audio)
|
||||
func Vision(ctx *agentContext.Context, capabilities *openai.Capabilities, messages []agentContext.Message, uses *agentContext.Uses) ([]agentContext.Message, error) {
|
||||
func Vision(ctx *agentContext.Context, capabilities *openai.Capabilities, messages []agentContext.Message, uses *agentContext.Uses, forceUses ...bool) ([]agentContext.Message, error) {
|
||||
// Determine if we should force using Uses tools even when model has native capabilities
|
||||
shouldForceUses := false
|
||||
if len(forceUses) > 0 {
|
||||
shouldForceUses = forceUses[0]
|
||||
}
|
||||
// Initialize handlers and fetcher
|
||||
registry := NewRegistry()
|
||||
fetcher := NewFetcher()
|
||||
|
|
@ -64,7 +69,7 @@ func Vision(ctx *agentContext.Context, capabilities *openai.Capabilities, messag
|
|||
processedMessages := make([]agentContext.Message, 0, len(messages))
|
||||
|
||||
for _, msg := range messages {
|
||||
processedMsg, err := processMessage(ctx, &msg, capabilities, uses, registry, fetcher, processedFiles)
|
||||
processedMsg, err := processMessage(ctx, &msg, capabilities, uses, shouldForceUses, registry, fetcher, processedFiles)
|
||||
if err != nil {
|
||||
// Log error but continue processing other messages
|
||||
// TODO: Add proper logging
|
||||
|
|
@ -84,6 +89,7 @@ func processMessage(
|
|||
msg *agentContext.Message,
|
||||
capabilities *openai.Capabilities,
|
||||
uses *agentContext.Uses,
|
||||
forceUses bool,
|
||||
registry *Registry,
|
||||
fetcher Fetcher,
|
||||
processedFiles map[string]string,
|
||||
|
|
@ -99,10 +105,13 @@ func processMessage(
|
|||
return *msg, nil
|
||||
}
|
||||
|
||||
// Note: File information will be collected and stored in Space by CallAgentWithFileInfo
|
||||
// when calling vision agents, using the agent ID as namespace prefix
|
||||
|
||||
// Process each content part
|
||||
processedParts := make([]agentContext.ContentPart, 0, len(parts))
|
||||
for _, part := range parts {
|
||||
processedPart, err := processContentPart(ctx, &part, capabilities, uses, registry, fetcher, processedFiles)
|
||||
processedPart, err := processContentPart(ctx, &part, capabilities, uses, forceUses, registry, fetcher, processedFiles)
|
||||
if err != nil {
|
||||
// Log error and handle gracefully
|
||||
fmt.Printf("Warning: failed to process content part: %v\n", err)
|
||||
|
|
@ -154,6 +163,7 @@ func processContentPart(
|
|||
part *agentContext.ContentPart,
|
||||
capabilities *openai.Capabilities,
|
||||
uses *agentContext.Uses,
|
||||
forceUses bool,
|
||||
registry *Registry,
|
||||
fetcher Fetcher,
|
||||
processedFiles map[string]string,
|
||||
|
|
@ -168,7 +178,7 @@ func processContentPart(
|
|||
|
||||
case agentContext.ContentImageURL:
|
||||
// Image URL - check if it needs processing
|
||||
return processImageURLContent(ctx, part, capabilities, uses, registry, fetcher, processedFiles)
|
||||
return processImageURLContent(ctx, part, capabilities, uses, forceUses, registry, fetcher, processedFiles)
|
||||
|
||||
case agentContext.ContentInputAudio:
|
||||
// Audio - check if it needs processing
|
||||
|
|
@ -178,7 +188,7 @@ func processContentPart(
|
|||
// 2. Handle extended types - MUST convert to standard types
|
||||
switch part.Type {
|
||||
case agentContext.ContentFile:
|
||||
return processFileContent(ctx, part, capabilities, uses, registry, fetcher, processedFiles)
|
||||
return processFileContent(ctx, part, capabilities, uses, forceUses, registry, fetcher, processedFiles)
|
||||
|
||||
case agentContext.ContentData:
|
||||
return processDataContent(ctx, part)
|
||||
|
|
@ -195,6 +205,7 @@ func processFileContent(
|
|||
part *agentContext.ContentPart,
|
||||
capabilities *openai.Capabilities,
|
||||
uses *agentContext.Uses,
|
||||
forceUses bool,
|
||||
registry *Registry,
|
||||
fetcher Fetcher,
|
||||
processedFiles map[string]string,
|
||||
|
|
@ -230,13 +241,18 @@ func processFileContent(
|
|||
return nil, fmt.Errorf("failed to fetch content: %w", err)
|
||||
}
|
||||
|
||||
// Set filename from part if not already set
|
||||
if info.Filename == "" && part.File.Filename != "" {
|
||||
info.Filename = part.File.Filename
|
||||
}
|
||||
|
||||
// Detect file type if not already set
|
||||
if info.FileType == FileTypeUnknown {
|
||||
info.FileType = DetectFileType(info.ContentType, part.File.Filename)
|
||||
}
|
||||
|
||||
// Process with appropriate handler
|
||||
result, err := registry.Handle(ctx, info, capabilities, uses)
|
||||
result, err := registry.Handle(ctx, info, capabilities, uses, forceUses)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to handle content: %w", err)
|
||||
}
|
||||
|
|
@ -259,6 +275,7 @@ func processImageURLContent(
|
|||
part *agentContext.ContentPart,
|
||||
capabilities *openai.Capabilities,
|
||||
uses *agentContext.Uses,
|
||||
forceUses bool,
|
||||
registry *Registry,
|
||||
fetcher Fetcher,
|
||||
processedFiles map[string]string,
|
||||
|
|
@ -305,7 +322,7 @@ func processImageURLContent(
|
|||
info.FileType = FileTypeImage
|
||||
|
||||
// Process with image handler
|
||||
result, err := registry.Handle(ctx, info, capabilities, uses)
|
||||
result, err := registry.Handle(ctx, info, capabilities, uses, forceUses)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to handle image: %w", err)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -119,7 +119,7 @@ func main() {
|
|||
}
|
||||
|
||||
// 3. Call Vision function
|
||||
result, err := content.Vision(ctx, capabilities, messages, nil)
|
||||
result, err := content.Vision(ctx, capabilities, messages, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Vision function failed: %v", err)
|
||||
}
|
||||
|
|
@ -218,7 +218,7 @@ func TestVision_ImageWithVisionSupport(t *testing.T) {
|
|||
}
|
||||
|
||||
// 3. Call Vision function (no uses needed for direct vision support)
|
||||
result, err := content.Vision(ctx, capabilities, messages, nil)
|
||||
result, err := content.Vision(ctx, capabilities, messages, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Vision function failed: %v", err)
|
||||
}
|
||||
|
|
@ -318,7 +318,7 @@ func TestVision_ImageWithAgent(t *testing.T) {
|
|||
}
|
||||
|
||||
// 3. Call Vision - should use agent since model doesn't support vision
|
||||
result, err := content.Vision(ctx, capabilities, messages, uses)
|
||||
result, err := content.Vision(ctx, capabilities, messages, uses, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Vision function failed: %v", err)
|
||||
}
|
||||
|
|
@ -408,7 +408,7 @@ func TestVision_CachedContent(t *testing.T) {
|
|||
}
|
||||
|
||||
// 3. Call Vision
|
||||
result, err := content.Vision(ctx, capabilities, messages, nil)
|
||||
result, err := content.Vision(ctx, capabilities, messages, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Vision function failed: %v", err)
|
||||
}
|
||||
|
|
@ -456,3 +456,220 @@ 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) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Setup test uploader
|
||||
uploaderName := "test-vision-metadata"
|
||||
manager := setupTestUploader(t, uploaderName)
|
||||
defer cleanupTestUploader(uploaderName)
|
||||
|
||||
// 1. Generate and upload a test image
|
||||
imageData := generateTestImage(t)
|
||||
|
||||
reader := strings.NewReader(string(imageData))
|
||||
fileHeader := &attachment.FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "test-metadata.png",
|
||||
Size: int64(len(imageData)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader.Header.Set("Content-Type", "image/png")
|
||||
|
||||
uploadedFile, err := manager.Upload(context.Background(), fileHeader, reader, attachment.UploadOption{
|
||||
Groups: []string{"vision", "test"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload image: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Uploaded file: %s (ID: %s)", uploadedFile.Filename, uploadedFile.ID)
|
||||
|
||||
// 2. Prepare Vision context - model doesn't support vision, use agent
|
||||
ctx := agentContext.New(context.Background(), nil, "test")
|
||||
|
||||
// Capabilities without vision support
|
||||
capabilities := &openai.Capabilities{
|
||||
Vision: nil, // No vision support - force agent usage
|
||||
}
|
||||
|
||||
// Uses configuration with vision-helper agent
|
||||
uses := &agentContext.Uses{
|
||||
Vision: "tests.vision-helper", // Use vision-helper agent that logs file metadata
|
||||
}
|
||||
|
||||
messages := []agentContext.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []agentContext.ContentPart{
|
||||
{
|
||||
Type: agentContext.ContentImageURL,
|
||||
ImageURL: &agentContext.ImageURL{
|
||||
URL: "__" + uploaderName + "://" + uploadedFile.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 3. Call Vision - should pass file metadata to agent via Space
|
||||
result, err := content.Vision(ctx, capabilities, messages, uses, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Vision function failed: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 message, got %d", len(result))
|
||||
}
|
||||
|
||||
// 4. Verify result contains file metadata validation info
|
||||
contentParts, ok := result[0].Content.([]agentContext.ContentPart)
|
||||
if !ok {
|
||||
t.Fatalf("Expected content to be []ContentPart, got %T", result[0].Content)
|
||||
}
|
||||
|
||||
if len(contentParts) != 1 {
|
||||
t.Fatalf("Expected 1 content part, got %d", len(contentParts))
|
||||
}
|
||||
|
||||
if contentParts[0].Type != agentContext.ContentText {
|
||||
t.Errorf("Expected ContentText (from agent), got: %s", contentParts[0].Type)
|
||||
}
|
||||
|
||||
// The vision-helper assistant should have logged file metadata from Space
|
||||
// We can verify this through the response text (which should contain the description)
|
||||
if contentParts[0].Text == "" {
|
||||
t.Error("Expected non-empty text from vision agent")
|
||||
}
|
||||
|
||||
t.Logf("✓ Vision agent processed image with file metadata")
|
||||
t.Logf("Agent response: %s", contentParts[0].Text)
|
||||
|
||||
// Note: The actual validation of Space data happens in the vision-helper's Next hook
|
||||
// which logs the file metadata. In a real test, we would need to capture those logs
|
||||
// or have the agent return structured data that we can verify.
|
||||
}
|
||||
|
||||
// TestVision_MultipleFilesMetadata tests file metadata handling with multiple attachments
|
||||
func TestVision_MultipleFilesMetadata(t *testing.T) {
|
||||
testutils.Prepare(t)
|
||||
defer testutils.Clean(t)
|
||||
|
||||
// Setup test uploader
|
||||
uploaderName := "test-vision-multi"
|
||||
manager := setupTestUploader(t, uploaderName)
|
||||
defer cleanupTestUploader(uploaderName)
|
||||
|
||||
// 1. Upload two test images
|
||||
imageData1 := generateTestImage(t)
|
||||
imageData2 := generateTestImage(t)
|
||||
|
||||
// Upload first image
|
||||
reader1 := strings.NewReader(string(imageData1))
|
||||
fileHeader1 := &attachment.FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "test-image-1.png",
|
||||
Size: int64(len(imageData1)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader1.Header.Set("Content-Type", "image/png")
|
||||
|
||||
uploadedFile1, err := manager.Upload(context.Background(), fileHeader1, reader1, attachment.UploadOption{
|
||||
Groups: []string{"vision", "test"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload first image: %v", err)
|
||||
}
|
||||
|
||||
// Upload second image
|
||||
reader2 := strings.NewReader(string(imageData2))
|
||||
fileHeader2 := &attachment.FileHeader{
|
||||
FileHeader: &multipart.FileHeader{
|
||||
Filename: "test-image-2.png",
|
||||
Size: int64(len(imageData2)),
|
||||
Header: make(map[string][]string),
|
||||
},
|
||||
}
|
||||
fileHeader2.Header.Set("Content-Type", "image/png")
|
||||
|
||||
uploadedFile2, err := manager.Upload(context.Background(), fileHeader2, reader2, attachment.UploadOption{
|
||||
Groups: []string{"vision", "test"},
|
||||
})
|
||||
if err != nil {
|
||||
t.Fatalf("Failed to upload second image: %v", err)
|
||||
}
|
||||
|
||||
t.Logf("Uploaded files: %s (ID: %s), %s (ID: %s)",
|
||||
uploadedFile1.Filename, uploadedFile1.ID,
|
||||
uploadedFile2.Filename, uploadedFile2.ID)
|
||||
|
||||
// 2. Prepare Vision context with both images
|
||||
ctx := agentContext.New(context.Background(), nil, "test")
|
||||
|
||||
capabilities := &openai.Capabilities{
|
||||
Vision: nil, // No vision support
|
||||
}
|
||||
|
||||
uses := &agentContext.Uses{
|
||||
Vision: "tests.vision-helper",
|
||||
}
|
||||
|
||||
messages := []agentContext.Message{
|
||||
{
|
||||
Role: "user",
|
||||
Content: []agentContext.ContentPart{
|
||||
{
|
||||
Type: agentContext.ContentImageURL,
|
||||
ImageURL: &agentContext.ImageURL{
|
||||
URL: "__" + uploaderName + "://" + uploadedFile1.ID,
|
||||
},
|
||||
},
|
||||
{
|
||||
Type: agentContext.ContentImageURL,
|
||||
ImageURL: &agentContext.ImageURL{
|
||||
URL: "__" + uploaderName + "://" + uploadedFile2.ID,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// 3. Call Vision - should handle multiple files' metadata
|
||||
result, err := content.Vision(ctx, capabilities, messages, uses, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Vision function failed: %v", err)
|
||||
}
|
||||
|
||||
if len(result) != 1 {
|
||||
t.Fatalf("Expected 1 message, got %d", len(result))
|
||||
}
|
||||
|
||||
// 4. Verify both images were processed
|
||||
contentParts, ok := result[0].Content.([]agentContext.ContentPart)
|
||||
if !ok {
|
||||
t.Fatalf("Expected content to be []ContentPart, got %T", result[0].Content)
|
||||
}
|
||||
|
||||
// Each image should be processed by the vision agent and return text
|
||||
if len(contentParts) != 2 {
|
||||
t.Fatalf("Expected 2 content parts (one per image), got %d", len(contentParts))
|
||||
}
|
||||
|
||||
for i, part := range contentParts {
|
||||
if part.Type != agentContext.ContentText {
|
||||
t.Errorf("Part %d: expected ContentText, got: %s", i, part.Type)
|
||||
}
|
||||
if part.Text == "" {
|
||||
t.Errorf("Part %d: expected non-empty text", i)
|
||||
}
|
||||
}
|
||||
|
||||
t.Logf("✓ Multiple files processed with metadata tracking")
|
||||
t.Logf("File 1 response: %s", contentParts[0].Text)
|
||||
t.Logf("File 2 response: %s", contentParts[1].Text)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -21,7 +21,7 @@ func (h *ExcelHandler) CanHandle(contentType string, fileType FileType) bool {
|
|||
}
|
||||
|
||||
// Handle processes Excel spreadsheet content
|
||||
func (h *ExcelHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses) (*Result, error) {
|
||||
func (h *ExcelHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses, forceUses bool) (*Result, error) {
|
||||
// TODO: Implement Excel handling
|
||||
// 1. Extract data from .xlsx or .xls file
|
||||
// 2. Convert to text format (e.g., CSV-like or structured text)
|
||||
|
|
|
|||
|
|
@ -69,9 +69,14 @@ func (f *DefaultFetcher) fetchUploader(ctx *agentContext.Context, wrapper string
|
|||
|
||||
// 5. Return Info with data
|
||||
return &Info{
|
||||
Data: data,
|
||||
ContentType: file.ContentType,
|
||||
FileType: DetectFileType(file.ContentType, file.Filename),
|
||||
Data: data,
|
||||
ContentType: file.ContentType,
|
||||
Filename: file.Filename,
|
||||
FileType: DetectFileType(file.ContentType, file.Filename),
|
||||
URL: wrapper,
|
||||
Source: SourceUploader,
|
||||
UploaderName: uploaderName,
|
||||
FileID: fileID,
|
||||
}, nil
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -18,9 +18,10 @@ func (h *ImageHandler) CanHandle(contentType string, fileType FileType) bool {
|
|||
|
||||
// Handle processes image content
|
||||
// Logic:
|
||||
// 1. If model supports vision -> convert to base64 or image_url format
|
||||
// 2. If model doesn't support vision -> use agent/MCP specified in uses.Vision
|
||||
func (h *ImageHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses) (*Result, error) {
|
||||
// 1. If forceUses is true and uses.Vision is specified -> use vision tool regardless of model capability
|
||||
// 2. If model supports vision and forceUses is false -> convert to base64 or image_url format
|
||||
// 3. If model doesn't support vision -> use agent/MCP specified in uses.Vision
|
||||
func (h *ImageHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses, forceUses bool) (*Result, error) {
|
||||
if len(info.Data) == 0 {
|
||||
return nil, fmt.Errorf("no image data to process")
|
||||
}
|
||||
|
|
@ -32,6 +33,17 @@ func (h *ImageHandler) Handle(ctx *agentContext.Context, info *Info, capabilitie
|
|||
// Check if model supports vision
|
||||
supportsVision, visionFormat := agentContext.GetVisionSupport(capabilities)
|
||||
|
||||
// If forceUses is true and uses.Vision is specified, use vision tool regardless of model capability
|
||||
if forceUses && uses != nil && uses.Vision != "" {
|
||||
text, err := h.handleWithVisionAgent(ctx, info, uses.Vision)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to handle image with vision agent/MCP (forced): %w", err)
|
||||
}
|
||||
return &Result{
|
||||
Text: text,
|
||||
}, nil
|
||||
}
|
||||
|
||||
if supportsVision {
|
||||
// Model supports vision - return as image_url ContentPart
|
||||
contentPart, err := h.handleWithVisionModel(ctx, info, visionFormat)
|
||||
|
|
@ -133,7 +145,7 @@ func (h *ImageHandler) callVisionAgent(ctx *agentContext.Context, agentID string
|
|||
Content: []agentContext.ContentPart{
|
||||
{
|
||||
Type: agentContext.ContentText,
|
||||
Text: "Please describe this image in detail.",
|
||||
Text: "Please analyze this image.",
|
||||
},
|
||||
{
|
||||
Type: agentContext.ContentImageURL,
|
||||
|
|
@ -145,7 +157,10 @@ func (h *ImageHandler) callVisionAgent(ctx *agentContext.Context, agentID string
|
|||
},
|
||||
}
|
||||
|
||||
return CallAgent(ctx, agentID, message)
|
||||
// Call agent with file metadata in context
|
||||
// File info (filename, file_id, etc.) will be available in ctx.Metadata["file_info"]
|
||||
// This allows hooks (especially Next hook) to access and format file information
|
||||
return CallAgentWithFileInfo(ctx, agentID, message, info)
|
||||
}
|
||||
|
||||
// callMCPVisionTool calls an MCP vision tool to describe the image
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func TestImageHandler_Handle_WithVisionSupport(t *testing.T) {
|
|||
Data: pngData,
|
||||
}
|
||||
|
||||
result, err := handler.Handle(ctx, info, capabilities, nil)
|
||||
result, err := handler.Handle(ctx, info, capabilities, nil, false)
|
||||
if err != nil {
|
||||
t.Fatalf("Handle() error = %v", err)
|
||||
}
|
||||
|
|
@ -160,7 +160,7 @@ func TestImageHandler_Handle_WithoutVisionSupport(t *testing.T) {
|
|||
}
|
||||
|
||||
// Should return error because no vision support and no tool
|
||||
_, err := handler.Handle(ctx, info, capabilities, nil)
|
||||
_, err := handler.Handle(ctx, info, capabilities, nil, false)
|
||||
if err == nil {
|
||||
t.Error("Expected error when no vision support and no tool specified")
|
||||
}
|
||||
|
|
@ -185,7 +185,7 @@ func TestImageHandler_Handle_EmptyData(t *testing.T) {
|
|||
Data: []byte{}, // Empty data
|
||||
}
|
||||
|
||||
_, err := handler.Handle(ctx, info, capabilities, nil)
|
||||
_, err := handler.Handle(ctx, info, capabilities, nil, false)
|
||||
if err == nil {
|
||||
t.Error("Expected error for empty image data")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,8 @@ type Handler interface {
|
|||
// ctx: agent context (passed from Vision function)
|
||||
// capabilities: model capabilities (for vision/audio support detection)
|
||||
// uses: configuration for external tools (agents/MCP servers)
|
||||
Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses) (*Result, error)
|
||||
// forceUses: if true, force using Uses tools even when model has native capabilities
|
||||
Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses, forceUses bool) (*Result, error)
|
||||
}
|
||||
|
||||
// Fetcher defines the interface for fetching content from different sources
|
||||
|
|
|
|||
|
|
@ -23,7 +23,7 @@ func (h *PDFHandler) CanHandle(contentType string, fileType FileType) bool {
|
|||
// 1. Check if uses.Vision is specified and supports PDF
|
||||
// 2. If yes, use vision tool to handle PDF (images + text)
|
||||
// 3. If no, extract text directly from PDF
|
||||
func (h *PDFHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses) (*Result, error) {
|
||||
func (h *PDFHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses, forceUses bool) (*Result, error) {
|
||||
// TODO: Implement PDF handling
|
||||
// 1. Check if vision tool supports PDF
|
||||
// 2. If yes:
|
||||
|
|
|
|||
|
|
@ -37,11 +37,11 @@ func (r *Registry) GetHandler(contentType string, fileType FileType) Handler {
|
|||
}
|
||||
|
||||
// Handle processes content using the appropriate handler
|
||||
func (r *Registry) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses) (*Result, error) {
|
||||
func (r *Registry) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses, forceUses bool) (*Result, error) {
|
||||
handler := r.GetHandler(info.ContentType, info.FileType)
|
||||
if handler == nil {
|
||||
return nil, fmt.Errorf("no handler found for content type: %s, file type: %s", info.ContentType, info.FileType)
|
||||
}
|
||||
|
||||
return handler.Handle(ctx, info, capabilities, uses)
|
||||
return handler.Handle(ctx, info, capabilities, uses, forceUses)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -49,7 +49,7 @@ func (h *TextHandler) CanHandle(contentType string, fileType FileType) bool {
|
|||
}
|
||||
|
||||
// Handle processes text content
|
||||
func (h *TextHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses) (*Result, error) {
|
||||
func (h *TextHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses, forceUses bool) (*Result, error) {
|
||||
if len(info.Data) == 0 {
|
||||
return nil, fmt.Errorf("no data to process")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -104,7 +104,7 @@ func TestTextHandler_Handle(t *testing.T) {
|
|||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
result, err := handler.Handle(testCtx, tt.info, nil, nil)
|
||||
result, err := handler.Handle(testCtx, tt.info, nil, nil, false)
|
||||
if (err != nil) != tt.wantErr {
|
||||
t.Errorf("Handle() error = %v, wantErr %v", err, tt.wantErr)
|
||||
return
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"encoding/base64"
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/mcp"
|
||||
|
|
@ -19,6 +20,9 @@ type AgentCaller interface {
|
|||
// AgentGetterFunc is a function type that gets an agent by ID
|
||||
var AgentGetterFunc func(agentID string) (AgentCaller, error)
|
||||
|
||||
// fileInfoMutex protects concurrent access to files_info list in Space
|
||||
var fileInfoMutex sync.Mutex
|
||||
|
||||
// CallAgent calls an agent to process content (vision, audio, etc.)
|
||||
// This is a generic function that can be used by any handler
|
||||
func CallAgent(ctx *agentContext.Context, agentID string, message agentContext.Message) (string, error) {
|
||||
|
|
@ -51,75 +55,183 @@ func CallAgent(ctx *agentContext.Context, agentID string, message agentContext.M
|
|||
return extractTextFromAgentResponse(response)
|
||||
}
|
||||
|
||||
// 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
|
||||
//
|
||||
// Space 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 {
|
||||
fileInfo := map[string]interface{}{
|
||||
"url": info.URL,
|
||||
"filename": info.Filename,
|
||||
"content_type": info.ContentType,
|
||||
"file_type": string(info.FileType),
|
||||
"source": string(info.Source),
|
||||
}
|
||||
|
||||
// Add uploader-specific information if available
|
||||
if info.UploaderName != "" {
|
||||
fileInfo["uploader_name"] = info.UploaderName
|
||||
}
|
||||
if info.FileID != "" {
|
||||
fileInfo["file_id"] = info.FileID
|
||||
}
|
||||
|
||||
// Use agent ID as namespace prefix for Space 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 {
|
||||
// Convert existing data to []map[string]interface{}
|
||||
if existingList, ok := existing.([]interface{}); ok {
|
||||
for _, item := range existingList {
|
||||
if itemMap, ok := item.(map[string]interface{}); ok {
|
||||
filesList = append(filesList, itemMap)
|
||||
}
|
||||
}
|
||||
} else if existingList, ok := existing.([]map[string]interface{}); ok {
|
||||
filesList = existingList
|
||||
}
|
||||
}
|
||||
// Append current file to list
|
||||
filesList = append(filesList, fileInfo)
|
||||
ctx.Space.Set(filesListKey, filesList)
|
||||
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)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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)
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
// Call the agent with the original context
|
||||
return CallAgent(ctx, agentID, message)
|
||||
}
|
||||
|
||||
// extractTextFromAgentResponse extracts text from agent response
|
||||
// Handles two response formats:
|
||||
// 1. Custom Hook response: if it's a string, return directly; otherwise JSON stringify
|
||||
// 2. Standard response: extract from completion.content
|
||||
// Handles two main response formats from agent.Stream():
|
||||
//
|
||||
// 1. Standard Response (No Next Hook or Next Hook returns nil):
|
||||
// Structure: { completion: { content: "text" | [...ContentPart] } }
|
||||
// Action: Extract text from completion.content field
|
||||
//
|
||||
// 2. Next Hook Response with Custom Data:
|
||||
// Structure: { next: <any data from Next hook> }
|
||||
// Action:
|
||||
// - If next is string → return directly
|
||||
// - If next is map/object → JSON stringify and return
|
||||
// - This preserves the complete custom data structure from the hook
|
||||
//
|
||||
// Priority:
|
||||
// 1. Check for "next" field (custom hook data) → return complete data
|
||||
// 2. Check for "completion" field (standard LLM response) → extract text only
|
||||
// 3. Fallback to direct string or JSON stringify
|
||||
func extractTextFromAgentResponse(response interface{}) (string, error) {
|
||||
if response == nil {
|
||||
return "", fmt.Errorf("agent returned nil response")
|
||||
}
|
||||
|
||||
// Try to parse as standard response format (has "completion" field with LLM result)
|
||||
if responseMap, ok := response.(map[string]interface{}); ok {
|
||||
// Check for completion field (standard LLM response)
|
||||
if completion, hasCompletion := responseMap["completion"]; hasCompletion {
|
||||
if completionMap, ok := completion.(map[string]interface{}); ok {
|
||||
// Extract content from completion
|
||||
if content, hasContent := completionMap["content"]; hasContent {
|
||||
// Content can be string or structured
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
return v, nil
|
||||
case []interface{}:
|
||||
// Handle multimodal content array
|
||||
var text string
|
||||
for _, part := range v {
|
||||
if partMap, ok := part.(map[string]interface{}); ok {
|
||||
if partType, _ := partMap["type"].(string); partType == "text" {
|
||||
if textContent, ok := partMap["text"].(string); ok {
|
||||
text += textContent
|
||||
}
|
||||
// First, try to convert to map if it's a struct
|
||||
// agent.Stream() may return *agentContext.Response which needs to be converted
|
||||
var responseMap map[string]interface{}
|
||||
|
||||
// Check if it's already a map
|
||||
if rm, ok := response.(map[string]interface{}); ok {
|
||||
responseMap = rm
|
||||
} else {
|
||||
// Try to marshal and unmarshal to convert struct to map
|
||||
jsonBytes, err := jsoniter.Marshal(response)
|
||||
if err != nil {
|
||||
// If it's a plain string, return directly
|
||||
if responseStr, ok := response.(string); ok {
|
||||
return responseStr, nil
|
||||
}
|
||||
return "", fmt.Errorf("failed to serialize agent response: %w", err)
|
||||
}
|
||||
|
||||
// Unmarshal to map
|
||||
if err := jsoniter.Unmarshal(jsonBytes, &responseMap); err != nil {
|
||||
// If unmarshal fails, return the JSON string
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
}
|
||||
|
||||
// Priority 1: Check for "next" field (custom hook data)
|
||||
// If Next hook returns custom data, it's stored in the "next" field
|
||||
// Return the complete custom data structure (preserve hook's intent)
|
||||
if next, hasNext := responseMap["next"]; hasNext && next != nil {
|
||||
// If next is a string, return directly
|
||||
if nextStr, ok := next.(string); ok {
|
||||
return nextStr, nil
|
||||
}
|
||||
// Otherwise, JSON stringify to preserve complete structure
|
||||
jsonBytes, err := jsoniter.Marshal(next)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize next hook data: %w", err)
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// Priority 2: Check for "completion" field (standard LLM response)
|
||||
// Extract text content from the LLM completion
|
||||
if completion, hasCompletion := responseMap["completion"]; hasCompletion && completion != nil {
|
||||
if completionMap, ok := completion.(map[string]interface{}); ok {
|
||||
// Extract content from completion
|
||||
if content, hasContent := completionMap["content"]; hasContent {
|
||||
// Content can be string or []ContentPart (multimodal)
|
||||
switch v := content.(type) {
|
||||
case string:
|
||||
// Simple text content
|
||||
return v, nil
|
||||
case []interface{}:
|
||||
// Multimodal content array - extract all text parts
|
||||
var text string
|
||||
for _, part := range v {
|
||||
if partMap, ok := part.(map[string]interface{}); ok {
|
||||
if partType, _ := partMap["type"].(string); partType == "text" {
|
||||
if textContent, ok := partMap["text"].(string); ok {
|
||||
text += textContent
|
||||
}
|
||||
}
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
}
|
||||
if text != "" {
|
||||
return text, nil
|
||||
}
|
||||
// No text found in content parts
|
||||
return "", fmt.Errorf("no text content found in completion content parts")
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Check for data field (custom hook response with data wrapper)
|
||||
if data, hasData := responseMap["data"]; hasData {
|
||||
// If data is a string, return directly
|
||||
if dataStr, ok := data.(string); ok {
|
||||
return dataStr, nil
|
||||
}
|
||||
// Otherwise, JSON stringify
|
||||
jsonBytes, err := jsoniter.Marshal(data)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize hook data response: %w", err)
|
||||
}
|
||||
return string(jsonBytes), nil
|
||||
}
|
||||
|
||||
// If the map itself looks like content, try to extract
|
||||
// This handles cases where the response is the content directly
|
||||
if content, hasContent := responseMap["content"]; hasContent {
|
||||
if contentStr, ok := content.(string); ok {
|
||||
return contentStr, nil
|
||||
}
|
||||
// Fallback: Try to find a "content" field directly (shouldn't happen normally)
|
||||
if content, hasContent := responseMap["content"]; hasContent {
|
||||
if contentStr, ok := content.(string); ok {
|
||||
return contentStr, nil
|
||||
}
|
||||
}
|
||||
|
||||
// Custom Hook response: if it's a plain string, return directly
|
||||
if responseStr, ok := response.(string); ok {
|
||||
return responseStr, nil
|
||||
}
|
||||
|
||||
// Otherwise, JSON stringify the response
|
||||
// Last resort: JSON stringify the entire response
|
||||
jsonBytes, err := jsoniter.Marshal(response)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("failed to serialize agent response: %w", err)
|
||||
|
|
|
|||
|
|
@ -55,6 +55,7 @@ type Info struct {
|
|||
FileType FileType // Type of the file
|
||||
ContentType string // MIME content type
|
||||
URL string // Original URL or file ID
|
||||
Filename string // Original filename (if available)
|
||||
Data []byte // File data (if already fetched)
|
||||
|
||||
// For uploader wrapper
|
||||
|
|
|
|||
|
|
@ -20,7 +20,7 @@ func (h *WordHandler) CanHandle(contentType string, fileType FileType) bool {
|
|||
}
|
||||
|
||||
// Handle processes Word document content
|
||||
func (h *WordHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses) (*Result, error) {
|
||||
func (h *WordHandler) Handle(ctx *agentContext.Context, info *Info, capabilities *openai.Capabilities, uses *agentContext.Uses, forceUses bool) (*Result, error) {
|
||||
// TODO: Implement Word document handling
|
||||
// 1. Extract text from .docx or .doc file
|
||||
// 2. Preserve formatting information if needed
|
||||
|
|
|
|||
|
|
@ -60,6 +60,8 @@ func (ctx *Context) NewObject(v8ctx *v8go.Context) (*v8go.Value, error) {
|
|||
// Set MCP object
|
||||
jsObject.Set("MCP", ctx.newMCPObject(v8ctx.Isolate()))
|
||||
|
||||
// Note: Space object will be set after instance creation (requires v8ctx)
|
||||
|
||||
// Create instance
|
||||
instance, err := jsObject.NewInstance(v8ctx)
|
||||
if err != nil {
|
||||
|
|
@ -129,6 +131,13 @@ 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()
|
||||
}
|
||||
|
||||
return instance.Value, nil
|
||||
}
|
||||
|
||||
|
|
@ -607,6 +616,87 @@ 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 {
|
||||
iso := v8ctx.Isolate()
|
||||
spaceObj, _ := v8ctx.RunScript("({})", "space-init")
|
||||
obj, _ := spaceObj.AsObject()
|
||||
|
||||
// Get method: space.Get(key)
|
||||
getFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
if ctx.Space == nil {
|
||||
return v8go.Null(iso)
|
||||
}
|
||||
|
||||
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 {
|
||||
return v8go.Null(iso)
|
||||
}
|
||||
|
||||
jsValue, err := bridge.JsValue(info.Context(), value)
|
||||
if err != nil {
|
||||
return v8go.Null(iso)
|
||||
}
|
||||
|
||||
return jsValue
|
||||
})
|
||||
getFuncVal := getFunc.GetFunction(v8ctx)
|
||||
obj.Set("Get", getFuncVal.Value)
|
||||
|
||||
// Set method: space.Set(key, value)
|
||||
setFunc := v8go.NewFunctionTemplate(iso, func(info *v8go.FunctionCallbackInfo) *v8go.Value {
|
||||
if ctx.Space == nil {
|
||||
return bridge.JsException(info.Context(), "Space is not available")
|
||||
}
|
||||
|
||||
if len(info.Args()) < 2 {
|
||||
return bridge.JsException(info.Context(), "Set requires key and value arguments")
|
||||
}
|
||||
|
||||
key := info.Args()[0].String()
|
||||
value, err := bridge.GoValue(info.Args()[1], info.Context())
|
||||
if err != nil {
|
||||
return bridge.JsException(info.Context(), "Failed to convert value: "+err.Error())
|
||||
}
|
||||
|
||||
if err := ctx.Space.Set(key, value); err != nil {
|
||||
return bridge.JsException(info.Context(), "Failed to set value: "+err.Error())
|
||||
}
|
||||
|
||||
return v8go.Undefined(iso)
|
||||
})
|
||||
setFuncVal := setFunc.GetFunction(v8ctx)
|
||||
obj.Set("Set", setFuncVal.Value)
|
||||
|
||||
// Delete method: space.Delete(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")
|
||||
}
|
||||
|
||||
key := info.Args()[0].String()
|
||||
if err := ctx.Space.Delete(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)
|
||||
|
||||
return spaceObj
|
||||
}
|
||||
|
||||
// sendGroupMethod implements ctx.SendGroup(group)
|
||||
// Usage: ctx.SendGroup({ id: "group1", messages: [...] })
|
||||
// Automatically generates IDs, sends group_start/group_end events, and flushes output
|
||||
|
|
|
|||
572
agent/context/jsapi_space_test.go
Normal file
572
agent/context/jsapi_space_test.go
Normal file
|
|
@ -0,0 +1,572 @@
|
|||
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")
|
||||
}
|
||||
|
|
@ -357,6 +357,12 @@ type HookCreateResponse struct {
|
|||
Theme string `json:"theme,omitempty"` // Override theme (session-level)
|
||||
Route string `json:"route,omitempty"` // Override route (session-level)
|
||||
Metadata map[string]interface{} `json:"metadata,omitempty"` // Override or merge metadata (session-level)
|
||||
|
||||
// Uses configuration - allow hook to override wrapper configurations
|
||||
Uses *Uses `json:"uses,omitempty"` // Override wrapper configurations for vision, audio, search, and fetch
|
||||
|
||||
// ForceUses controls whether to force using Uses tools even when model has native capabilities
|
||||
ForceUses *bool `json:"force_uses,omitempty"` // Force using Uses tools regardless of model capabilities
|
||||
}
|
||||
|
||||
// NextHookPayload payload for the next hook
|
||||
|
|
|
|||
|
|
@ -69,6 +69,12 @@ type CompletionOptions struct {
|
|||
// User-specified tools for vision, audio, search, and fetch processing
|
||||
Uses *Uses `json:"uses,omitempty"`
|
||||
|
||||
// ForceUses controls whether to force using Uses tools even when model has native capabilities
|
||||
// When true: Always use tools specified in Uses, ignore model's native multimodal capabilities
|
||||
// When false (default): Use model's native capabilities if available, fallback to Uses tools
|
||||
// This is useful when you want consistent behavior across different models or prefer specific tools
|
||||
ForceUses bool `json:"force_uses,omitempty"`
|
||||
|
||||
// Audio configuration (for models that support audio output)
|
||||
Audio *AudioConfig `json:"audio,omitempty"`
|
||||
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue