- Introduced a new script testing mode to allow testing of agent handler scripts (hooks, tools, etc.) using a Go-like interface, enabling better unit testing of TypeScript/JavaScript code. - Enhanced the `LoadScripts` function to skip test files during script loading, ensuring only relevant scripts are processed. - Refactored the test context creation to support custom context configurations via a JSON file, allowing for flexible authorization and metadata management during tests. - Updated the test runner to handle script tests, including the ability to filter tests using regex patterns and manage custom context data. - Improved documentation to include details on script testing usage, input formats, and available assertions, enhancing developer experience and clarity.
391 lines
10 KiB
Go
391 lines
10 KiB
Go
package assistant
|
|
|
|
import (
|
|
"context"
|
|
"fmt"
|
|
"path/filepath"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/yaoapp/gou/application"
|
|
"github.com/yaoapp/gou/process"
|
|
v8 "github.com/yaoapp/gou/runtime/v8"
|
|
"github.com/yaoapp/kun/exception"
|
|
"github.com/yaoapp/yao/agent/assistant/hook"
|
|
)
|
|
|
|
// scriptsMutex protects concurrent v8.Load calls and Scripts map access
|
|
var scriptsMutex sync.Mutex
|
|
|
|
// Execute execute the script
|
|
func (s *Script) Execute(ctx context.Context, method string, args ...interface{}) (interface{}, error) {
|
|
return s.ExecuteWithAuthorized(ctx, method, nil, args...)
|
|
}
|
|
|
|
// ExecuteWithAuthorized execute the script with authorized information
|
|
func (s *Script) ExecuteWithAuthorized(ctx context.Context, method string, authorized map[string]interface{}, args ...interface{}) (interface{}, error) {
|
|
if s == nil || s.Script == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
scriptCtx, err := s.NewContext("", nil)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
defer scriptCtx.Close()
|
|
|
|
// Set authorized information if available
|
|
if authorized != nil {
|
|
scriptCtx.WithAuthorized(authorized)
|
|
}
|
|
|
|
// Call the method with provided arguments as-is
|
|
result, err := scriptCtx.CallWith(ctx, method, args...)
|
|
|
|
// Return error as-is (including "not defined" errors)
|
|
return result, err
|
|
}
|
|
|
|
// LoadScripts loads all scripts from a src directory path
|
|
// It scans for .ts and .js files (excluding index.ts which is the hook script)
|
|
// Returns the HookScript and a map of other scripts
|
|
func LoadScripts(srcDir string) (*hook.Script, map[string]*Script, error) {
|
|
// Check if src directory exists
|
|
exists, err := application.App.Exists(srcDir)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
if !exists {
|
|
return nil, nil, nil // No src directory
|
|
}
|
|
|
|
var hookScript *hook.Script
|
|
scripts := make(map[string]*Script)
|
|
var loadErr error
|
|
|
|
// Walk through src directory to find all script files
|
|
exts := []string{"*.ts", "*.js"}
|
|
err = application.App.Walk(srcDir, func(root, file string, isdir bool) error {
|
|
if isdir {
|
|
return nil
|
|
}
|
|
|
|
// file is the full path, root is srcDir
|
|
// Get relative path for determining if it's index
|
|
relPath := strings.TrimPrefix(file, root+"/")
|
|
|
|
// Skip test files (*_test.ts, *_test.js)
|
|
if strings.HasSuffix(relPath, "_test.ts") || strings.HasSuffix(relPath, "_test.js") {
|
|
return nil
|
|
}
|
|
|
|
// Check if it's the root index.ts/js (hook script)
|
|
// Only src/index.ts is the hook script, not src/foo/index.ts
|
|
isRootIndex := relPath == "index.ts" || relPath == "index.js"
|
|
|
|
if isRootIndex {
|
|
scriptsMutex.Lock()
|
|
script, err := loadScriptFile(file)
|
|
scriptsMutex.Unlock()
|
|
if err != nil {
|
|
loadErr = fmt.Errorf("failed to load hook script %s: %w", file, err)
|
|
return loadErr
|
|
}
|
|
hookScript = script
|
|
} else {
|
|
// Generate script ID from relative path
|
|
scriptID := generateScriptID(file, root)
|
|
|
|
// Load the script (v8.Load is not thread-safe)
|
|
scriptsMutex.Lock()
|
|
script, err := loadScriptV8(file)
|
|
if err != nil {
|
|
scriptsMutex.Unlock()
|
|
loadErr = fmt.Errorf("failed to load script %s: %w", file, err)
|
|
return loadErr
|
|
}
|
|
scripts[scriptID] = &Script{Script: script}
|
|
scriptsMutex.Unlock()
|
|
}
|
|
|
|
return nil
|
|
}, exts...)
|
|
|
|
if loadErr != nil {
|
|
return nil, nil, loadErr
|
|
}
|
|
|
|
if err != nil {
|
|
return nil, nil, fmt.Errorf("failed to walk src directory: %w", err)
|
|
}
|
|
|
|
return hookScript, scripts, nil
|
|
}
|
|
|
|
// generateScriptID generates a script ID from file path
|
|
// Example: assistants/test/src/foo/bar/test.ts -> foo.bar.test
|
|
func generateScriptID(filePath string, srcDir string) string {
|
|
// Normalize path separators
|
|
filePath = filepath.ToSlash(filePath)
|
|
srcDir = filepath.ToSlash(srcDir)
|
|
|
|
// Remove src directory prefix
|
|
relPath := strings.TrimPrefix(filePath, srcDir+"/")
|
|
relPath = strings.TrimPrefix(relPath, "/")
|
|
|
|
// Remove file extension
|
|
relPath = strings.TrimSuffix(relPath, filepath.Ext(relPath))
|
|
|
|
// Replace path separators with dots
|
|
scriptID := strings.ReplaceAll(relPath, "/", ".")
|
|
|
|
return scriptID
|
|
}
|
|
|
|
// loadScriptFile loads a hook script from file
|
|
func loadScriptFile(file string) (*hook.Script, error) {
|
|
id := makeScriptID(file, "")
|
|
script, err := v8.Load(file, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return &hook.Script{Script: script}, nil
|
|
}
|
|
|
|
// loadScriptFromSource loads a script from source code
|
|
// Uses MakeScriptInMemory which supports TypeScript syntax without file resolution
|
|
func loadScriptFromSource(source string, file string) (*v8.Script, error) {
|
|
script, err := v8.MakeScriptInMemory([]byte(source), file, 5*time.Second, true)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return script, nil
|
|
}
|
|
|
|
// loadScriptV8 loads a v8.Script from file (used for non-hook scripts)
|
|
func loadScriptV8(file string) (*v8.Script, error) {
|
|
id := makeScriptID(file, "")
|
|
script, err := v8.Load(file, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return script, nil
|
|
}
|
|
|
|
// makeScriptID generates the script ID for v8.Load
|
|
// Converts file path to a dot-separated ID
|
|
// Example: assistants/tests/fullfields/src/index.ts -> assistants.tests.fullfields.src.index
|
|
func makeScriptID(file string, root string) string {
|
|
// Remove root prefix if provided
|
|
id := file
|
|
if root != "" {
|
|
id = strings.TrimPrefix(file, root+"/")
|
|
}
|
|
|
|
// Remove extension
|
|
id = strings.TrimSuffix(id, filepath.Ext(id))
|
|
|
|
// Replace path separators with dots
|
|
id = strings.ReplaceAll(id, "/", ".")
|
|
id = strings.ReplaceAll(id, string(filepath.Separator), ".")
|
|
|
|
return id
|
|
}
|
|
|
|
// LoadScriptsFromData loads scripts from data map
|
|
// Handles script/scripts/source fields with priority: script > scripts > source > file system
|
|
func LoadScriptsFromData(data map[string]interface{}, assistantID string) (*hook.Script, map[string]*Script, error) {
|
|
// Priority 1: script field (hook script from string source)
|
|
if data["script"] != nil {
|
|
switch v := data["script"].(type) {
|
|
case string:
|
|
file := fmt.Sprintf("assistants/%s/src/index.ts", assistantID)
|
|
script, err := loadScriptFromSource(v, file)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
hookScript := &hook.Script{Script: script}
|
|
|
|
// Load other scripts if provided
|
|
scripts, err := loadScriptsField(data["scripts"])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return hookScript, scripts, nil
|
|
case *hook.Script:
|
|
scripts, err := loadScriptsField(data["scripts"])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return v, scripts, nil
|
|
case *v8.Script:
|
|
scripts, err := loadScriptsField(data["scripts"])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return &hook.Script{Script: v}, scripts, nil
|
|
}
|
|
}
|
|
|
|
// Priority 2: scripts field (map of scripts)
|
|
if data["scripts"] != nil {
|
|
// First extract index if present
|
|
var hookScript *hook.Script
|
|
if scriptsMap, ok := data["scripts"].(map[string]interface{}); ok {
|
|
if indexSource, hasIndex := scriptsMap["index"]; hasIndex {
|
|
switch v := indexSource.(type) {
|
|
case string:
|
|
file := fmt.Sprintf("assistants/%s/src/index.ts", assistantID)
|
|
script, err := loadScriptFromSource(v, file)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
hookScript = &hook.Script{Script: script}
|
|
case *Script:
|
|
hookScript = &hook.Script{Script: v.Script}
|
|
case *v8.Script:
|
|
hookScript = &hook.Script{Script: v}
|
|
}
|
|
}
|
|
}
|
|
|
|
// Then load other scripts (loadScriptsField automatically filters out index)
|
|
scripts, err := loadScriptsField(data["scripts"])
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
return hookScript, scripts, nil
|
|
}
|
|
|
|
// Priority 3: source field (legacy hook script from source)
|
|
if source, ok := data["source"].(string); ok && source != "" {
|
|
script, err := loadSource(source, assistantID)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
return script, nil, nil
|
|
}
|
|
|
|
// Priority 4: file system (scan src directory)
|
|
srcDir := fmt.Sprintf("assistants/%s/src", assistantID)
|
|
return LoadScripts(srcDir)
|
|
}
|
|
|
|
// loadScriptsField parses scripts field from data
|
|
// Note: "index" is always filtered out as it's reserved for HookScript
|
|
func loadScriptsField(scriptsData interface{}) (map[string]*Script, error) {
|
|
if scriptsData == nil {
|
|
return nil, nil
|
|
}
|
|
|
|
scripts := make(map[string]*Script)
|
|
|
|
switch v := scriptsData.(type) {
|
|
case map[string]*Script:
|
|
for id, script := range v {
|
|
if id == "index" {
|
|
continue // Skip index
|
|
}
|
|
scripts[id] = script
|
|
}
|
|
return scripts, nil
|
|
case map[string]*v8.Script:
|
|
for id, script := range v {
|
|
if id == "index" {
|
|
continue // Skip index
|
|
}
|
|
scripts[id] = &Script{Script: script}
|
|
}
|
|
return scripts, nil
|
|
case map[string]interface{}:
|
|
for id, item := range v {
|
|
if id == "index" {
|
|
continue // Skip index
|
|
}
|
|
switch s := item.(type) {
|
|
case *Script:
|
|
scripts[id] = s
|
|
case *v8.Script:
|
|
scripts[id] = &Script{Script: s}
|
|
case string:
|
|
// Load script from source code
|
|
file := fmt.Sprintf("script_%s", id)
|
|
script, err := loadScriptFromSource(s, file)
|
|
if err != nil {
|
|
return nil, fmt.Errorf("failed to load script %s: %w", id, err)
|
|
}
|
|
scripts[id] = &Script{Script: script}
|
|
}
|
|
}
|
|
return scripts, nil
|
|
}
|
|
|
|
return nil, nil
|
|
}
|
|
|
|
// RegisterScripts registers all scripts as process handlers
|
|
// Handler naming: agents.<assistantID>.<scriptID>
|
|
func (ast *Assistant) RegisterScripts() error {
|
|
if len(ast.Scripts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
assistantID := ast.ID
|
|
handlers := make(map[string]process.Handler)
|
|
|
|
for scriptID, script := range ast.Scripts {
|
|
// Create handler for this script
|
|
handlers[scriptID] = makeScriptHandler(script)
|
|
}
|
|
|
|
// Register the handler group dynamically
|
|
groupName := fmt.Sprintf("agents.%s", assistantID)
|
|
process.RegisterDynamicGroup(groupName, handlers)
|
|
|
|
return nil
|
|
}
|
|
|
|
// UnregisterScripts unregisters all scripts from process handlers
|
|
func (ast *Assistant) UnregisterScripts() error {
|
|
if len(ast.Scripts) == 0 {
|
|
return nil
|
|
}
|
|
|
|
assistantID := ast.ID
|
|
|
|
for scriptID := range ast.Scripts {
|
|
handlerID := fmt.Sprintf("agents.%s.%s", strings.ToLower(assistantID), strings.ToLower(scriptID))
|
|
delete(process.Handlers, handlerID)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
// makeScriptHandler creates a process handler for a script
|
|
func makeScriptHandler(script *Script) process.Handler {
|
|
return func(p *process.Process) interface{} {
|
|
// Extract method name from process
|
|
method := p.Method
|
|
|
|
// Get arguments from process
|
|
args := p.Args
|
|
|
|
// Convert authorized info to map if available
|
|
var authorized map[string]interface{}
|
|
if p.Authorized != nil {
|
|
authorized = p.Authorized.AuthorizedToMap()
|
|
}
|
|
|
|
// Execute the script with authorized information
|
|
result, err := script.ExecuteWithAuthorized(p.Context, method, authorized, args...)
|
|
if err != nil {
|
|
exception.New(err.Error(), 500).Throw()
|
|
}
|
|
|
|
return result
|
|
}
|
|
}
|