Merge pull request #1474 from trheyi/main
Enhance Assistant model with capabilities and sandbox configuration
This commit is contained in:
commit
c022c3cc3e
42 changed files with 2465 additions and 712 deletions
|
|
@ -6,7 +6,7 @@ import (
|
||||||
|
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
goullm "github.com/yaoapp/gou/llm"
|
||||||
"github.com/yaoapp/yao/agent/assistant/handlers"
|
"github.com/yaoapp/yao/agent/assistant/handlers"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
|
@ -570,7 +570,7 @@ func (ast *Assistant) Stream(ctx *context.Context, inputMessages []context.Messa
|
||||||
// GetConnector get the connector object, capabilities, and error with priority: opts.Connector > ast.Connector
|
// GetConnector get the connector object, capabilities, and error with priority: opts.Connector > ast.Connector
|
||||||
// Note: opts.Connector may be set by Create hook's applyOptionsAdjustments
|
// Note: opts.Connector may be set by Create hook's applyOptionsAdjustments
|
||||||
// Returns: (connector, capabilities, error)
|
// Returns: (connector, capabilities, error)
|
||||||
func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *openai.Capabilities, error) {
|
func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Options) (connector.Connector, *goullm.Capabilities, error) {
|
||||||
// Determine connector ID with priority: opts.Connector > ast.Connector
|
// Determine connector ID with priority: opts.Connector > ast.Connector
|
||||||
connectorID := ast.Connector
|
connectorID := ast.Connector
|
||||||
if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" {
|
if len(opts) > 0 && opts[0] != nil && opts[0].Connector != "" {
|
||||||
|
|
@ -588,9 +588,7 @@ func (ast *Assistant) GetConnector(ctx *context.Context, opts ...*context.Option
|
||||||
return nil, nil, err
|
return nil, nil, err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get connector capabilities from settings
|
capabilities := llm.GetCapabilitiesFromConn(conn)
|
||||||
// Uses unified capability getter: 1. User-defined models.yml, 2. connector's Setting()["capabilities"], 3. default
|
|
||||||
capabilities := llm.GetCapabilitiesFromConn(conn, modelCapabilities)
|
|
||||||
|
|
||||||
return conn, capabilities, nil
|
return conn, capabilities, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -9,7 +9,6 @@ import (
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/spf13/cast"
|
"github.com/spf13/cast"
|
||||||
"github.com/yaoapp/gou/application"
|
"github.com/yaoapp/gou/application"
|
||||||
gouOpenAI "github.com/yaoapp/gou/connector/openai"
|
|
||||||
"github.com/yaoapp/gou/fs"
|
"github.com/yaoapp/gou/fs"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
|
@ -22,7 +21,6 @@ import (
|
||||||
var loaded = NewCache(200) // 200 is the default capacity
|
var loaded = NewCache(200) // 200 is the default capacity
|
||||||
var storage store.Store = nil
|
var storage store.Store = nil
|
||||||
var storeSetting *store.Setting = nil // store setting from agent.yml
|
var storeSetting *store.Setting = nil // store setting from agent.yml
|
||||||
var modelCapabilities map[string]gouOpenAI.Capabilities = map[string]gouOpenAI.Capabilities{}
|
|
||||||
var defaultConnector string = "" // default connector
|
var defaultConnector string = "" // default connector
|
||||||
var globalUses *context.Uses = nil // global uses configuration from agent.yml
|
var globalUses *context.Uses = nil // global uses configuration from agent.yml
|
||||||
var globalPrompts []store.Prompt = nil // global prompts from agent/prompts.yml
|
var globalPrompts []store.Prompt = nil // global prompts from agent/prompts.yml
|
||||||
|
|
@ -140,11 +138,6 @@ func GetStorage() store.Store {
|
||||||
return storage
|
return storage
|
||||||
}
|
}
|
||||||
|
|
||||||
// SetModelCapabilities set the model capabilities configuration
|
|
||||||
func SetModelCapabilities(capabilities map[string]gouOpenAI.Capabilities) {
|
|
||||||
modelCapabilities = capabilities
|
|
||||||
}
|
|
||||||
|
|
||||||
// SetConnector set the connector
|
// SetConnector set the connector
|
||||||
func SetConnector(c string) {
|
func SetConnector(c string) {
|
||||||
defaultConnector = c
|
defaultConnector = c
|
||||||
|
|
@ -564,6 +557,11 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
||||||
assistant.Description = v
|
assistant.Description = v
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// capabilities
|
||||||
|
if v, ok := data["capabilities"].(string); ok {
|
||||||
|
assistant.Capabilities = v
|
||||||
|
}
|
||||||
|
|
||||||
// locales
|
// locales
|
||||||
if locales, ok := data["locales"].(i18n.Map); ok {
|
if locales, ok := data["locales"].(i18n.Map); ok {
|
||||||
assistant.Locales = locales
|
assistant.Locales = locales
|
||||||
|
|
@ -575,30 +573,39 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
||||||
if i18nObj.Messages == nil {
|
if i18nObj.Messages == nil {
|
||||||
i18nObj.Messages = make(map[string]any)
|
i18nObj.Messages = make(map[string]any)
|
||||||
}
|
}
|
||||||
// Add name and description if not already present
|
// Add name, description, and capabilities if not already present
|
||||||
if _, exists := i18nObj.Messages["name"]; !exists && assistant.Name != "" {
|
if _, exists := i18nObj.Messages["name"]; !exists && assistant.Name != "" {
|
||||||
i18nObj.Messages["name"] = assistant.Name
|
i18nObj.Messages["name"] = assistant.Name
|
||||||
}
|
}
|
||||||
if _, exists := i18nObj.Messages["description"]; !exists && assistant.Description != "" {
|
if _, exists := i18nObj.Messages["description"]; !exists && assistant.Description != "" {
|
||||||
i18nObj.Messages["description"] = assistant.Description
|
i18nObj.Messages["description"] = assistant.Description
|
||||||
}
|
}
|
||||||
|
if _, exists := i18nObj.Messages["capabilities"]; !exists && assistant.Capabilities != "" {
|
||||||
|
i18nObj.Messages["capabilities"] = assistant.Capabilities
|
||||||
|
}
|
||||||
flattened[locale] = i18nObj
|
flattened[locale] = i18nObj
|
||||||
}
|
}
|
||||||
|
|
||||||
i18n.Locales[id] = flattened
|
i18n.Locales[id] = flattened
|
||||||
} else {
|
} else {
|
||||||
// No locales defined, create default with name and description for all common locales
|
// No locales defined, create default with name, description, and capabilities for all common locales
|
||||||
if assistant.Name != "" || assistant.Description != "" {
|
if assistant.Name != "" || assistant.Description != "" || assistant.Capabilities != "" {
|
||||||
defaultLocales := make(map[string]i18n.I18n)
|
defaultLocales := make(map[string]i18n.I18n)
|
||||||
// Create entries for all common locales so {{name}} can be resolved
|
|
||||||
commonLocales := []string{"en", "en-us", "zh", "zh-cn", "zh-tw"}
|
commonLocales := []string{"en", "en-us", "zh", "zh-cn", "zh-tw"}
|
||||||
for _, locale := range commonLocales {
|
for _, locale := range commonLocales {
|
||||||
|
messages := map[string]any{}
|
||||||
|
if assistant.Name != "" {
|
||||||
|
messages["name"] = assistant.Name
|
||||||
|
}
|
||||||
|
if assistant.Description != "" {
|
||||||
|
messages["description"] = assistant.Description
|
||||||
|
}
|
||||||
|
if assistant.Capabilities != "" {
|
||||||
|
messages["capabilities"] = assistant.Capabilities
|
||||||
|
}
|
||||||
defaultLocales[locale] = i18n.I18n{
|
defaultLocales[locale] = i18n.I18n{
|
||||||
Locale: locale,
|
Locale: locale,
|
||||||
Messages: map[string]any{
|
Messages: messages,
|
||||||
"name": assistant.Name,
|
|
||||||
"description": assistant.Description,
|
|
||||||
},
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
i18n.Locales[id] = defaultLocales
|
i18n.Locales[id] = defaultLocales
|
||||||
|
|
|
||||||
|
|
@ -256,20 +256,11 @@ func resolveSystemConnector(agentID string) string {
|
||||||
|
|
||||||
// findCapableConnector finds the first connector that supports tool calling
|
// findCapableConnector finds the first connector that supports tool calling
|
||||||
func findCapableConnector() string {
|
func findCapableConnector() string {
|
||||||
// Get all registered connectors
|
|
||||||
for id, conn := range connector.Connectors {
|
for id, conn := range connector.Connectors {
|
||||||
if !conn.Is(connector.OPENAI) {
|
if !conn.Is(connector.OPENAI) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check from modelCapabilities (user-defined in models.yml)
|
|
||||||
if caps, exists := modelCapabilities[id]; exists {
|
|
||||||
if caps.ToolCalls {
|
|
||||||
return id
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Check capabilities from connector's Options
|
|
||||||
if connOpenAI, ok := conn.(*gouOpenAI.Connector); ok {
|
if connOpenAI, ok := conn.(*gouOpenAI.Connector); ok {
|
||||||
if connOpenAI.Options.Capabilities != nil && connOpenAI.Options.Capabilities.ToolCalls {
|
if connOpenAI.Options.Capabilities != nil && connOpenAI.Options.Capabilities.ToolCalls {
|
||||||
return id
|
return id
|
||||||
|
|
@ -277,7 +268,6 @@ func findCapableConnector() string {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// No capable connector found, return empty
|
|
||||||
return ""
|
return ""
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -181,8 +181,8 @@ func TestHasSandboxMethod(t *testing.T) {
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.True(t, astWithSandbox.HasSandbox(), "Assistant with sandbox config should return true")
|
assert.True(t, astWithSandbox.HasSandbox(), "Assistant with sandbox config should return true")
|
||||||
|
|
||||||
// Test assistant without sandbox (fullfields doesn't have sandbox)
|
// Test assistant without sandbox
|
||||||
astWithoutSandbox, err := assistant.LoadPath("/assistants/tests/fullfields")
|
astWithoutSandbox, err := assistant.LoadPath("/assistants/tests/simple-greeting")
|
||||||
require.NoError(t, err)
|
require.NoError(t, err)
|
||||||
assert.False(t, astWithoutSandbox.HasSandbox(), "Assistant without sandbox config should return false")
|
assert.False(t, astWithoutSandbox.HasSandbox(), "Assistant without sandbox config should return false")
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package context
|
||||||
import (
|
import (
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
"github.com/yaoapp/gou/llm"
|
||||||
"github.com/yaoapp/yao/agent/output"
|
"github.com/yaoapp/yao/agent/output"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
)
|
)
|
||||||
|
|
@ -571,9 +571,8 @@ func (ctx *Context) getOutput() (*output.Output, error) {
|
||||||
Accept: string(ctx.Accept),
|
Accept: string(ctx.Accept),
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set ModelCapabilities (now using openai.Capabilities directly)
|
|
||||||
if ctx.Capabilities != nil {
|
if ctx.Capabilities != nil {
|
||||||
caps := openai.Capabilities(*ctx.Capabilities)
|
caps := llm.Capabilities(*ctx.Capabilities)
|
||||||
options.Capabilities = &caps
|
options.Capabilities = &caps
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import (
|
||||||
"sync"
|
"sync"
|
||||||
"time"
|
"time"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
"github.com/yaoapp/gou/llm"
|
||||||
"github.com/yaoapp/gou/store"
|
"github.com/yaoapp/gou/store"
|
||||||
"github.com/yaoapp/yao/agent/memory"
|
"github.com/yaoapp/yao/agent/memory"
|
||||||
"github.com/yaoapp/yao/agent/output"
|
"github.com/yaoapp/yao/agent/output"
|
||||||
|
|
@ -253,7 +253,7 @@ type Context struct {
|
||||||
sandboxExecutor SandboxExecutor `json:"-"` // Sandbox executor for hooks (set by assistant when sandbox is configured)
|
sandboxExecutor SandboxExecutor `json:"-"` // Sandbox executor for hooks (set by assistant when sandbox is configured)
|
||||||
|
|
||||||
// Model capabilities (set by assistant, used by output adapters)
|
// Model capabilities (set by assistant, used by output adapters)
|
||||||
Capabilities *openai.Capabilities `json:"-"` // Model capabilities for the current connector
|
Capabilities *llm.Capabilities `json:"-"` // Model capabilities for the current connector
|
||||||
|
|
||||||
// Interrupt control (all interrupt-related logic is encapsulated in InterruptController)
|
// Interrupt control (all interrupt-related logic is encapsulated in InterruptController)
|
||||||
Interrupt *InterruptController `json:"-"` // Interrupt controller for handling user interrupts during streaming
|
Interrupt *InterruptController `json:"-"` // Interrupt controller for handling user interrupts during streaming
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
package context
|
package context
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
"github.com/yaoapp/gou/llm"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -38,7 +38,7 @@ const (
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetVisionSupport returns whether vision is supported and the format
|
// GetVisionSupport returns whether vision is supported and the format
|
||||||
func GetVisionSupport(cap *openai.Capabilities) (bool, VisionFormat) {
|
func GetVisionSupport(cap *llm.Capabilities) (bool, VisionFormat) {
|
||||||
if cap == nil || cap.Vision == nil {
|
if cap == nil || cap.Vision == nil {
|
||||||
return false, VisionFormatNone
|
return false, VisionFormatNone
|
||||||
}
|
}
|
||||||
|
|
@ -70,7 +70,7 @@ func GetVisionSupport(cap *openai.Capabilities) (bool, VisionFormat) {
|
||||||
type CompletionOptions struct {
|
type CompletionOptions struct {
|
||||||
// Model capabilities (used by LLM to select appropriate provider)
|
// Model capabilities (used by LLM to select appropriate provider)
|
||||||
// nil means capabilities are not specified/checked
|
// nil means capabilities are not specified/checked
|
||||||
Capabilities *openai.Capabilities `json:"capabilities,omitempty"`
|
Capabilities *llm.Capabilities `json:"capabilities,omitempty"`
|
||||||
|
|
||||||
// User-specified tools for vision, audio, search, and fetch processing
|
// User-specified tools for vision, audio, search, and fetch processing
|
||||||
Uses *Uses `json:"uses,omitempty"`
|
Uses *Uses `json:"uses,omitempty"`
|
||||||
|
|
|
||||||
|
|
@ -2,92 +2,48 @@ package llm
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
"github.com/yaoapp/gou/connector/anthropic"
|
goullm "github.com/yaoapp/gou/llm"
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
|
||||||
)
|
)
|
||||||
|
|
||||||
// GetCapabilities get the capabilities of a connector by connector ID
|
// GetCapabilities get the capabilities of a connector by connector ID
|
||||||
// This is a unified function to get connector capabilities with proper priority:
|
// Reads capabilities from connector's Setting()["capabilities"], with fallback to defaults.
|
||||||
// 1. User-defined model capabilities from agent/models.yml (passed via modelCapabilities map)
|
func GetCapabilities(connectorID string) *goullm.Capabilities {
|
||||||
// 2. Connector's Setting()["capabilities"] (default capabilities from connector)
|
|
||||||
// 3. Fallback to minimal default capabilities
|
|
||||||
//
|
|
||||||
// Usage in Agent with user-defined models:
|
|
||||||
//
|
|
||||||
// capabilities := llm.GetCapabilities(connectorID, modelCapabilities)
|
|
||||||
//
|
|
||||||
// Usage in API (without user-defined models):
|
|
||||||
//
|
|
||||||
// capabilities := llm.GetCapabilities(connectorID, nil)
|
|
||||||
func GetCapabilities(connectorID string, modelCapabilities map[string]openai.Capabilities) *openai.Capabilities {
|
|
||||||
if connectorID == "" {
|
if connectorID == "" {
|
||||||
return getDefaultCapabilities()
|
return getDefaultCapabilities()
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 1: Check user-defined model capabilities from agent/models.yml
|
|
||||||
if modelCapabilities != nil {
|
|
||||||
if modelCaps, exists := modelCapabilities[connectorID]; exists {
|
|
||||||
return &modelCaps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: Get connector and extract capabilities from Setting()
|
|
||||||
conn, err := connector.Select(connectorID)
|
conn, err := connector.Select(connectorID)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// If connector not found, return default
|
|
||||||
return getDefaultCapabilities()
|
return getDefaultCapabilities()
|
||||||
}
|
}
|
||||||
|
|
||||||
return GetCapabilitiesFromConn(conn, modelCapabilities)
|
return GetCapabilitiesFromConn(conn)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCapabilitiesFromConn get the capabilities from a connector instance
|
// GetCapabilitiesFromConn get the capabilities from a connector instance
|
||||||
// This is useful when you already have the connector object
|
func GetCapabilitiesFromConn(conn connector.Connector) *goullm.Capabilities {
|
||||||
func GetCapabilitiesFromConn(conn connector.Connector, modelCapabilities map[string]openai.Capabilities) *openai.Capabilities {
|
|
||||||
if conn == nil {
|
if conn == nil {
|
||||||
return getDefaultCapabilities()
|
return getDefaultCapabilities()
|
||||||
}
|
}
|
||||||
|
|
||||||
connectorID := conn.ID()
|
|
||||||
|
|
||||||
// Priority 1: Check user-defined model capabilities from agent/models.yml
|
|
||||||
if modelCapabilities != nil {
|
|
||||||
if modelCaps, exists := modelCapabilities[connectorID]; exists {
|
|
||||||
return &modelCaps
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Priority 2: Get capabilities from connector's Setting() method
|
|
||||||
settings := conn.Setting()
|
settings := conn.Setting()
|
||||||
if settings != nil {
|
if settings != nil {
|
||||||
if caps, ok := settings["capabilities"]; ok {
|
if caps, ok := settings["capabilities"]; ok {
|
||||||
// Try to convert to *openai.Capabilities
|
if capabilities, ok := caps.(*goullm.Capabilities); ok {
|
||||||
if capabilities, ok := caps.(*openai.Capabilities); ok {
|
|
||||||
return capabilities
|
return capabilities
|
||||||
}
|
}
|
||||||
// Try to convert to openai.Capabilities (value type)
|
if capabilities, ok := caps.(goullm.Capabilities); ok {
|
||||||
if capabilities, ok := caps.(openai.Capabilities); ok {
|
|
||||||
return &capabilities
|
return &capabilities
|
||||||
}
|
}
|
||||||
// Try to convert from *anthropic.Capabilities
|
|
||||||
if capabilities, ok := caps.(*anthropic.Capabilities); ok {
|
|
||||||
return convertAnthropicCaps(capabilities)
|
|
||||||
}
|
|
||||||
// Try to convert from anthropic.Capabilities (value type)
|
|
||||||
if capabilities, ok := caps.(anthropic.Capabilities); ok {
|
|
||||||
return convertAnthropicCaps(&capabilities)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Priority 3: Fallback to minimal default capabilities
|
|
||||||
return getDefaultCapabilities()
|
return getDefaultCapabilities()
|
||||||
}
|
}
|
||||||
|
|
||||||
// getDefaultCapabilities returns minimal default capabilities
|
// getDefaultCapabilities returns minimal default capabilities
|
||||||
// This should rarely be used as modern connectors provide capabilities via Setting()
|
func getDefaultCapabilities() *goullm.Capabilities {
|
||||||
func getDefaultCapabilities() *openai.Capabilities {
|
return &goullm.Capabilities{
|
||||||
return &openai.Capabilities{
|
|
||||||
Vision: false,
|
Vision: false,
|
||||||
ToolCalls: false,
|
ToolCalls: false,
|
||||||
Audio: false,
|
Audio: false,
|
||||||
|
|
@ -95,14 +51,13 @@ func getDefaultCapabilities() *openai.Capabilities {
|
||||||
Streaming: false,
|
Streaming: false,
|
||||||
JSON: false,
|
JSON: false,
|
||||||
Multimodal: false,
|
Multimodal: false,
|
||||||
TemperatureAdjustable: true, // Default to true for non-reasoning models
|
TemperatureAdjustable: true,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetCapabilitiesMap get capabilities as map[string]interface{} for API responses
|
// GetCapabilitiesMap get capabilities as map[string]interface{} for API responses
|
||||||
// This is useful for OpenAPI responses that need JSON-serializable format
|
func GetCapabilitiesMap(connectorID string) map[string]interface{} {
|
||||||
func GetCapabilitiesMap(connectorID string, modelCapabilities map[string]openai.Capabilities) map[string]interface{} {
|
caps := GetCapabilities(connectorID)
|
||||||
caps := GetCapabilities(connectorID, modelCapabilities)
|
|
||||||
if caps == nil {
|
if caps == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
@ -110,16 +65,14 @@ func GetCapabilitiesMap(connectorID string, modelCapabilities map[string]openai.
|
||||||
return ToMap(caps)
|
return ToMap(caps)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ToMap converts openai.Capabilities to map[string]interface{}
|
// ToMap converts Capabilities to map[string]interface{}
|
||||||
// This is useful for JSON serialization in API responses
|
func ToMap(caps *goullm.Capabilities) map[string]interface{} {
|
||||||
func ToMap(caps *openai.Capabilities) map[string]interface{} {
|
|
||||||
if caps == nil {
|
if caps == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
result := make(map[string]interface{})
|
result := make(map[string]interface{})
|
||||||
|
|
||||||
// Handle Vision field specially as it can be bool or string
|
|
||||||
if caps.Vision != nil {
|
if caps.Vision != nil {
|
||||||
result["vision"] = caps.Vision
|
result["vision"] = caps.Vision
|
||||||
}
|
}
|
||||||
|
|
@ -135,22 +88,3 @@ func ToMap(caps *openai.Capabilities) map[string]interface{} {
|
||||||
|
|
||||||
return result
|
return result
|
||||||
}
|
}
|
||||||
|
|
||||||
// convertAnthropicCaps converts anthropic.Capabilities to openai.Capabilities
|
|
||||||
// This provides a unified capabilities interface across connector types
|
|
||||||
func convertAnthropicCaps(caps *anthropic.Capabilities) *openai.Capabilities {
|
|
||||||
if caps == nil {
|
|
||||||
return getDefaultCapabilities()
|
|
||||||
}
|
|
||||||
return &openai.Capabilities{
|
|
||||||
Vision: caps.Vision,
|
|
||||||
Audio: caps.Audio,
|
|
||||||
STT: caps.STT,
|
|
||||||
ToolCalls: caps.ToolCalls,
|
|
||||||
Reasoning: caps.Reasoning,
|
|
||||||
Streaming: caps.Streaming,
|
|
||||||
JSON: caps.JSON,
|
|
||||||
Multimodal: caps.Multimodal,
|
|
||||||
TemperatureAdjustable: caps.TemperatureAdjustable,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
|
||||||
|
|
@ -203,7 +203,7 @@ func parseToolCalls(toolCalls []interface{}) []agentContext.ToolCall {
|
||||||
// buildCompletionOptions creates CompletionOptions from JS opts map
|
// buildCompletionOptions creates CompletionOptions from JS opts map
|
||||||
func buildCompletionOptions(conn connector.Connector, opts map[string]interface{}) *agentContext.CompletionOptions {
|
func buildCompletionOptions(conn connector.Connector, opts map[string]interface{}) *agentContext.CompletionOptions {
|
||||||
// Get capabilities from connector
|
// Get capabilities from connector
|
||||||
capabilities := GetCapabilitiesFromConn(conn, nil)
|
capabilities := GetCapabilitiesFromConn(conn)
|
||||||
|
|
||||||
completionOptions := &agentContext.CompletionOptions{
|
completionOptions := &agentContext.CompletionOptions{
|
||||||
Capabilities: capabilities,
|
Capabilities: capabilities,
|
||||||
|
|
@ -737,12 +737,6 @@ func (api *JSAPI) executeRace(requests []*Request) []interface{} {
|
||||||
return []interface{}{result}
|
return []interface{}{result}
|
||||||
}
|
}
|
||||||
|
|
||||||
// executeSingleRequest executes a single LLM request using the original context
|
|
||||||
// This is used for single calls (not batch)
|
|
||||||
func (api *JSAPI) executeSingleRequest(request *Request) interface{} {
|
|
||||||
return api.StreamWithHandler(request.Connector, request.Messages, request.Options, request.Handler)
|
|
||||||
}
|
|
||||||
|
|
||||||
// executeSingleRequestWithForkedContext executes a single LLM request with a forked context
|
// executeSingleRequestWithForkedContext executes a single LLM request with a forked context
|
||||||
// This is used by batch operations (All/Any/Race) to avoid race conditions
|
// This is used by batch operations (All/Any/Race) to avoid race conditions
|
||||||
// when multiple goroutines access shared context state
|
// when multiple goroutines access shared context state
|
||||||
|
|
|
||||||
|
|
@ -9,9 +9,8 @@ import (
|
||||||
|
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
gouAnthropicConn "github.com/yaoapp/gou/connector/anthropic"
|
|
||||||
gouOpenAI "github.com/yaoapp/gou/connector/openai"
|
|
||||||
"github.com/yaoapp/gou/http"
|
"github.com/yaoapp/gou/http"
|
||||||
|
goullm "github.com/yaoapp/gou/llm"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
|
@ -27,34 +26,15 @@ type Provider struct {
|
||||||
}
|
}
|
||||||
|
|
||||||
// New create a new Anthropic provider
|
// New create a new Anthropic provider
|
||||||
func New(conn connector.Connector, capabilities *gouOpenAI.Capabilities) *Provider {
|
func New(conn connector.Connector, capabilities *goullm.Capabilities) *Provider {
|
||||||
return &Provider{
|
return &Provider{
|
||||||
Provider: base.NewProvider(conn, capabilities),
|
Provider: base.NewProvider(conn, capabilities),
|
||||||
adapters: buildAdapters(capabilities),
|
adapters: buildAdapters(capabilities),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewFromAnthropicCaps create a new Anthropic provider from Anthropic capabilities
|
|
||||||
func NewFromAnthropicCaps(conn connector.Connector, caps *gouAnthropicConn.Capabilities) *Provider {
|
|
||||||
// Convert anthropic capabilities to openai capabilities for base provider compatibility
|
|
||||||
openaiCaps := &gouOpenAI.Capabilities{
|
|
||||||
Vision: caps.Vision,
|
|
||||||
Audio: caps.Audio,
|
|
||||||
ToolCalls: caps.ToolCalls,
|
|
||||||
Reasoning: caps.Reasoning,
|
|
||||||
Streaming: caps.Streaming,
|
|
||||||
JSON: caps.JSON,
|
|
||||||
Multimodal: caps.Multimodal,
|
|
||||||
TemperatureAdjustable: caps.TemperatureAdjustable,
|
|
||||||
}
|
|
||||||
return &Provider{
|
|
||||||
Provider: base.NewProvider(conn, openaiCaps),
|
|
||||||
adapters: buildAdapters(openaiCaps),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// buildAdapters builds capability adapters based on model capabilities
|
// buildAdapters builds capability adapters based on model capabilities
|
||||||
func buildAdapters(cap *gouOpenAI.Capabilities) []adapters.CapabilityAdapter {
|
func buildAdapters(cap *goullm.Capabilities) []adapters.CapabilityAdapter {
|
||||||
if cap == nil {
|
if cap == nil {
|
||||||
return []adapters.CapabilityAdapter{}
|
return []adapters.CapabilityAdapter{}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import (
|
||||||
"testing"
|
"testing"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
goullm "github.com/yaoapp/gou/llm"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/llm"
|
"github.com/yaoapp/yao/agent/llm"
|
||||||
"github.com/yaoapp/yao/agent/output/message"
|
"github.com/yaoapp/yao/agent/output/message"
|
||||||
|
|
@ -34,9 +34,8 @@ func TestAnthropicStreamBasic(t *testing.T) {
|
||||||
t.Fatal("Connector is not ANTHROPIC type")
|
t.Fatal("Connector is not ANTHROPIC type")
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use openai.Capabilities — SelectProvider auto-detects Anthropic format from connector type
|
|
||||||
options := &context.CompletionOptions{
|
options := &context.CompletionOptions{
|
||||||
Capabilities: &openai.Capabilities{
|
Capabilities: &goullm.Capabilities{
|
||||||
Streaming: true,
|
Streaming: true,
|
||||||
ToolCalls: true,
|
ToolCalls: true,
|
||||||
},
|
},
|
||||||
|
|
@ -111,7 +110,7 @@ func TestAnthropicStreamWithToolCalls(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
options := &context.CompletionOptions{
|
options := &context.CompletionOptions{
|
||||||
Capabilities: &openai.Capabilities{
|
Capabilities: &goullm.Capabilities{
|
||||||
Streaming: true,
|
Streaming: true,
|
||||||
ToolCalls: true,
|
ToolCalls: true,
|
||||||
},
|
},
|
||||||
|
|
@ -226,7 +225,7 @@ func TestAnthropicStreamRetry(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
options := &context.CompletionOptions{
|
options := &context.CompletionOptions{
|
||||||
Capabilities: &openai.Capabilities{
|
Capabilities: &goullm.Capabilities{
|
||||||
Streaming: true,
|
Streaming: true,
|
||||||
ToolCalls: true,
|
ToolCalls: true,
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,7 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
"github.com/yaoapp/gou/llm"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -12,11 +12,11 @@ import (
|
||||||
// Provides common functionality for all LLM providers
|
// Provides common functionality for all LLM providers
|
||||||
type Provider struct {
|
type Provider struct {
|
||||||
Connector connector.Connector
|
Connector connector.Connector
|
||||||
Capabilities *openai.Capabilities
|
Capabilities *llm.Capabilities
|
||||||
}
|
}
|
||||||
|
|
||||||
// NewProvider create a new base provider
|
// NewProvider create a new base provider
|
||||||
func NewProvider(conn connector.Connector, capabilities *openai.Capabilities) *Provider {
|
func NewProvider(conn connector.Connector, capabilities *llm.Capabilities) *Provider {
|
||||||
return &Provider{
|
return &Provider{
|
||||||
Connector: conn,
|
Connector: conn,
|
||||||
Capabilities: capabilities,
|
Capabilities: capabilities,
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
gouAnthropicConn "github.com/yaoapp/gou/connector/anthropic"
|
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/llm/providers/anthropic"
|
"github.com/yaoapp/yao/agent/llm/providers/anthropic"
|
||||||
"github.com/yaoapp/yao/agent/llm/providers/openai"
|
"github.com/yaoapp/yao/agent/llm/providers/openai"
|
||||||
|
|
@ -43,13 +42,6 @@ func SelectProvider(conn connector.Connector, options *context.CompletionOptions
|
||||||
return openai.New(conn, options.Capabilities), nil
|
return openai.New(conn, options.Capabilities), nil
|
||||||
|
|
||||||
case "anthropic":
|
case "anthropic":
|
||||||
// Anthropic Messages API (Claude, Kimi Code, etc.)
|
|
||||||
// Check if connector has native Anthropic capabilities
|
|
||||||
settings := conn.Setting()
|
|
||||||
if caps, ok := settings["capabilities"].(*gouAnthropicConn.Capabilities); ok {
|
|
||||||
return anthropic.NewFromAnthropicCaps(conn, caps), nil
|
|
||||||
}
|
|
||||||
// Fallback: use OpenAI capabilities (converted from connector settings)
|
|
||||||
return anthropic.New(conn, options.Capabilities), nil
|
return anthropic.New(conn, options.Capabilities), nil
|
||||||
|
|
||||||
default:
|
default:
|
||||||
|
|
|
||||||
|
|
@ -8,8 +8,8 @@ import (
|
||||||
|
|
||||||
jsoniter "github.com/json-iterator/go"
|
jsoniter "github.com/json-iterator/go"
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
gouOpenAI "github.com/yaoapp/gou/connector/openai"
|
|
||||||
"github.com/yaoapp/gou/http"
|
"github.com/yaoapp/gou/http"
|
||||||
|
goullm "github.com/yaoapp/gou/llm"
|
||||||
"github.com/yaoapp/kun/log"
|
"github.com/yaoapp/kun/log"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
|
@ -137,7 +137,7 @@ func buildAPIURL(host, endpoint string) string {
|
||||||
}
|
}
|
||||||
|
|
||||||
// New create a new OpenAI provider with capability adapters
|
// New create a new OpenAI provider with capability adapters
|
||||||
func New(conn connector.Connector, capabilities *gouOpenAI.Capabilities) *Provider {
|
func New(conn connector.Connector, capabilities *goullm.Capabilities) *Provider {
|
||||||
return &Provider{
|
return &Provider{
|
||||||
Provider: base.NewProvider(conn, capabilities),
|
Provider: base.NewProvider(conn, capabilities),
|
||||||
adapters: buildAdapters(capabilities),
|
adapters: buildAdapters(capabilities),
|
||||||
|
|
@ -145,7 +145,7 @@ func New(conn connector.Connector, capabilities *gouOpenAI.Capabilities) *Provid
|
||||||
}
|
}
|
||||||
|
|
||||||
// buildAdapters builds capability adapters based on model capabilities
|
// buildAdapters builds capability adapters based on model capabilities
|
||||||
func buildAdapters(cap *gouOpenAI.Capabilities) []adapters.CapabilityAdapter {
|
func buildAdapters(cap *goullm.Capabilities) []adapters.CapabilityAdapter {
|
||||||
if cap == nil {
|
if cap == nil {
|
||||||
return []adapters.CapabilityAdapter{}
|
return []adapters.CapabilityAdapter{}
|
||||||
}
|
}
|
||||||
|
|
@ -182,7 +182,7 @@ func buildAdapters(cap *gouOpenAI.Capabilities) []adapters.CapabilityAdapter {
|
||||||
}
|
}
|
||||||
|
|
||||||
// detectReasoningFormat detects the reasoning format based on capabilities
|
// detectReasoningFormat detects the reasoning format based on capabilities
|
||||||
func detectReasoningFormat(cap *gouOpenAI.Capabilities) adapters.ReasoningFormat {
|
func detectReasoningFormat(cap *goullm.Capabilities) adapters.ReasoningFormat {
|
||||||
// TODO: Implement better detection logic
|
// TODO: Implement better detection logic
|
||||||
// For now, default to OpenAI o1 format if reasoning is supported
|
// For now, default to OpenAI o1 format if reasoning is supported
|
||||||
if cap.Reasoning {
|
if cap.Reasoning {
|
||||||
|
|
|
||||||
|
|
@ -6,7 +6,7 @@ import (
|
||||||
|
|
||||||
"github.com/yaoapp/gou/application"
|
"github.com/yaoapp/gou/application"
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
gouOpenAI "github.com/yaoapp/gou/connector/openai"
|
"github.com/yaoapp/gou/helper"
|
||||||
"github.com/yaoapp/yao/agent/assistant"
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
"github.com/yaoapp/yao/agent/context"
|
"github.com/yaoapp/yao/agent/context"
|
||||||
"github.com/yaoapp/yao/agent/i18n"
|
"github.com/yaoapp/yao/agent/i18n"
|
||||||
|
|
@ -47,6 +47,9 @@ func Load(cfg config.Config) error {
|
||||||
setting.StoreSetting.MaxSize = 20 // default is 20
|
setting.StoreSetting.MaxSize = 20 // default is 20
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Resolve $ENV.XXX references in system and uses fields
|
||||||
|
resolveEnvStrings(&setting)
|
||||||
|
|
||||||
// Default Assistant, Agent is the developer name, Mohe is the brand name of the assistant
|
// Default Assistant, Agent is the developer name, Mohe is the brand name of the assistant
|
||||||
if setting.Uses == nil {
|
if setting.Uses == nil {
|
||||||
setting.Uses = &types.Uses{Default: "mohe"} // Agent is the developer name, Mohe is the brand name of the assistant
|
setting.Uses = &types.Uses{Default: "mohe"} // Agent is the developer name, Mohe is the brand name of the assistant
|
||||||
|
|
@ -75,12 +78,6 @@ func Load(cfg config.Config) error {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
|
||||||
// Initialize model capabilities
|
|
||||||
err = initModelCapabilities()
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
// Initialize Global I18n
|
// Initialize Global I18n
|
||||||
err = initGlobalI18n()
|
err = initGlobalI18n()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
|
|
@ -148,29 +145,6 @@ func GetGlobalPrompts(ctx map[string]string) []store.Prompt {
|
||||||
return store.Prompts(agentDSL.GlobalPrompts).Parse(ctx)
|
return store.Prompts(agentDSL.GlobalPrompts).Parse(ctx)
|
||||||
}
|
}
|
||||||
|
|
||||||
// initModelCapabilities initialize the model capabilities configuration
|
|
||||||
func initModelCapabilities() error {
|
|
||||||
path := filepath.Join("agent", "models.yml")
|
|
||||||
if exists, _ := application.App.Exists(path); !exists {
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Read the model capabilities configuration
|
|
||||||
bytes, err := application.App.Read(path)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
var models map[string]gouOpenAI.Capabilities = map[string]gouOpenAI.Capabilities{}
|
|
||||||
err = application.Parse("models.yml", bytes, &models)
|
|
||||||
if err != nil {
|
|
||||||
return err
|
|
||||||
}
|
|
||||||
|
|
||||||
agentDSL.Models = models
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// initStore initialize the store
|
// initStore initialize the store
|
||||||
func initStore() error {
|
func initStore() error {
|
||||||
|
|
||||||
|
|
@ -231,10 +205,6 @@ func initAssistant() error {
|
||||||
assistant.SetGlobalPrompts(agentDSL.GlobalPrompts)
|
assistant.SetGlobalPrompts(agentDSL.GlobalPrompts)
|
||||||
}
|
}
|
||||||
|
|
||||||
if agentDSL.Models != nil {
|
|
||||||
assistant.SetModelCapabilities(agentDSL.Models)
|
|
||||||
}
|
|
||||||
|
|
||||||
if agentDSL.KB != nil {
|
if agentDSL.KB != nil {
|
||||||
assistant.SetGlobalKBSetting(agentDSL.KB)
|
assistant.SetGlobalKBSetting(agentDSL.KB)
|
||||||
}
|
}
|
||||||
|
|
@ -478,3 +448,36 @@ func defaultAssistant() (*assistant.Assistant, error) {
|
||||||
}
|
}
|
||||||
return assistant.Get(agentDSL.Uses.Default)
|
return assistant.Get(agentDSL.Uses.Default)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// resolveEnvStrings resolves $ENV.XXX references in agent.yml string fields.
|
||||||
|
// agent.yml is parsed via yaml.Unmarshal which does not handle $ENV substitution,
|
||||||
|
// unlike connector files which call helper.EnvString explicitly during Register.
|
||||||
|
func resolveEnvStrings(setting *types.DSL) {
|
||||||
|
if setting.System != nil {
|
||||||
|
setting.System.Default = helper.EnvString(setting.System.Default)
|
||||||
|
setting.System.Keyword = helper.EnvString(setting.System.Keyword)
|
||||||
|
setting.System.QueryDSL = helper.EnvString(setting.System.QueryDSL)
|
||||||
|
setting.System.Title = helper.EnvString(setting.System.Title)
|
||||||
|
setting.System.Prompt = helper.EnvString(setting.System.Prompt)
|
||||||
|
setting.System.RobotPrompt = helper.EnvString(setting.System.RobotPrompt)
|
||||||
|
setting.System.NeedSearch = helper.EnvString(setting.System.NeedSearch)
|
||||||
|
setting.System.Entity = helper.EnvString(setting.System.Entity)
|
||||||
|
}
|
||||||
|
|
||||||
|
if setting.Uses != nil {
|
||||||
|
setting.Uses.Default = helper.EnvString(setting.Uses.Default)
|
||||||
|
setting.Uses.Title = helper.EnvString(setting.Uses.Title)
|
||||||
|
setting.Uses.Prompt = helper.EnvString(setting.Uses.Prompt)
|
||||||
|
setting.Uses.RobotPrompt = helper.EnvString(setting.Uses.RobotPrompt)
|
||||||
|
setting.Uses.Vision = helper.EnvString(setting.Uses.Vision)
|
||||||
|
setting.Uses.Audio = helper.EnvString(setting.Uses.Audio)
|
||||||
|
setting.Uses.Search = helper.EnvString(setting.Uses.Search)
|
||||||
|
setting.Uses.Fetch = helper.EnvString(setting.Uses.Fetch)
|
||||||
|
setting.Uses.Web = helper.EnvString(setting.Uses.Web)
|
||||||
|
setting.Uses.Keyword = helper.EnvString(setting.Uses.Keyword)
|
||||||
|
setting.Uses.QueryDSL = helper.EnvString(setting.Uses.QueryDSL)
|
||||||
|
setting.Uses.Rerank = helper.EnvString(setting.Uses.Rerank)
|
||||||
|
}
|
||||||
|
|
||||||
|
setting.Cache = helper.EnvString(setting.Cache)
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -8,6 +8,7 @@ import (
|
||||||
"github.com/stretchr/testify/assert"
|
"github.com/stretchr/testify/assert"
|
||||||
"github.com/stretchr/testify/require"
|
"github.com/stretchr/testify/require"
|
||||||
"github.com/yaoapp/yao/agent/assistant"
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
|
"github.com/yaoapp/yao/agent/types"
|
||||||
"github.com/yaoapp/yao/config"
|
"github.com/yaoapp/yao/config"
|
||||||
"github.com/yaoapp/yao/test"
|
"github.com/yaoapp/yao/test"
|
||||||
)
|
)
|
||||||
|
|
@ -54,12 +55,6 @@ func TestLoad(t *testing.T) {
|
||||||
assert.Contains(t, agent.GlobalPrompts[0].Content, "$SYS.")
|
assert.Contains(t, agent.GlobalPrompts[0].Content, "$SYS.")
|
||||||
})
|
})
|
||||||
|
|
||||||
t.Run("LoadModelCapabilities", func(t *testing.T) {
|
|
||||||
// Model capabilities should be loaded from agent/models.yml
|
|
||||||
assert.NotNil(t, agent.Models)
|
|
||||||
assert.Greater(t, len(agent.Models), 0)
|
|
||||||
})
|
|
||||||
|
|
||||||
t.Run("LoadKBConfig", func(t *testing.T) {
|
t.Run("LoadKBConfig", func(t *testing.T) {
|
||||||
// KB configuration should be loaded from agent/kb.yml
|
// KB configuration should be loaded from agent/kb.yml
|
||||||
assert.NotNil(t, agent.KB)
|
assert.NotNil(t, agent.KB)
|
||||||
|
|
@ -217,6 +212,112 @@ func TestGetGlobalPromptsWithDisableFlag(t *testing.T) {
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
func TestResolveEnvStrings(t *testing.T) {
|
||||||
|
t.Setenv("TEST_CONNECTOR", "openai.gpt-5")
|
||||||
|
t.Setenv("TEST_ASSISTANT", "my-assistant")
|
||||||
|
t.Setenv("TEST_CACHE", "my-cache")
|
||||||
|
|
||||||
|
t.Run("SystemFields", func(t *testing.T) {
|
||||||
|
setting := &types.DSL{
|
||||||
|
System: &types.System{
|
||||||
|
Default: "$ENV.TEST_CONNECTOR",
|
||||||
|
Keyword: "$ENV.TEST_CONNECTOR",
|
||||||
|
QueryDSL: "$ENV.TEST_CONNECTOR",
|
||||||
|
Title: "$ENV.TEST_CONNECTOR",
|
||||||
|
Prompt: "$ENV.TEST_CONNECTOR",
|
||||||
|
RobotPrompt: "$ENV.TEST_CONNECTOR",
|
||||||
|
NeedSearch: "$ENV.TEST_CONNECTOR",
|
||||||
|
Entity: "$ENV.TEST_CONNECTOR",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resolveEnvStrings(setting)
|
||||||
|
|
||||||
|
assert.Equal(t, "openai.gpt-5", setting.System.Default)
|
||||||
|
assert.Equal(t, "openai.gpt-5", setting.System.Keyword)
|
||||||
|
assert.Equal(t, "openai.gpt-5", setting.System.QueryDSL)
|
||||||
|
assert.Equal(t, "openai.gpt-5", setting.System.Title)
|
||||||
|
assert.Equal(t, "openai.gpt-5", setting.System.Prompt)
|
||||||
|
assert.Equal(t, "openai.gpt-5", setting.System.RobotPrompt)
|
||||||
|
assert.Equal(t, "openai.gpt-5", setting.System.NeedSearch)
|
||||||
|
assert.Equal(t, "openai.gpt-5", setting.System.Entity)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UsesFields", func(t *testing.T) {
|
||||||
|
setting := &types.DSL{
|
||||||
|
Uses: &types.Uses{
|
||||||
|
Default: "$ENV.TEST_ASSISTANT",
|
||||||
|
Title: "$ENV.TEST_ASSISTANT",
|
||||||
|
Prompt: "$ENV.TEST_ASSISTANT",
|
||||||
|
RobotPrompt: "$ENV.TEST_ASSISTANT",
|
||||||
|
Vision: "$ENV.TEST_ASSISTANT",
|
||||||
|
Audio: "$ENV.TEST_ASSISTANT",
|
||||||
|
Search: "$ENV.TEST_ASSISTANT",
|
||||||
|
Fetch: "$ENV.TEST_ASSISTANT",
|
||||||
|
Web: "$ENV.TEST_ASSISTANT",
|
||||||
|
Keyword: "$ENV.TEST_ASSISTANT",
|
||||||
|
QueryDSL: "$ENV.TEST_ASSISTANT",
|
||||||
|
Rerank: "$ENV.TEST_ASSISTANT",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resolveEnvStrings(setting)
|
||||||
|
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.Default)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.Title)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.Prompt)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.RobotPrompt)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.Vision)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.Audio)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.Search)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.Fetch)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.Web)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.Keyword)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.QueryDSL)
|
||||||
|
assert.Equal(t, "my-assistant", setting.Uses.Rerank)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CacheField", func(t *testing.T) {
|
||||||
|
setting := &types.DSL{Cache: "$ENV.TEST_CACHE"}
|
||||||
|
resolveEnvStrings(setting)
|
||||||
|
assert.Equal(t, "my-cache", setting.Cache)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("PlainStringsUnchanged", func(t *testing.T) {
|
||||||
|
setting := &types.DSL{
|
||||||
|
Cache: "plain-cache",
|
||||||
|
System: &types.System{
|
||||||
|
Default: "openai.gpt-5",
|
||||||
|
},
|
||||||
|
Uses: &types.Uses{
|
||||||
|
Default: "mohe",
|
||||||
|
Title: "__yao.title",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resolveEnvStrings(setting)
|
||||||
|
|
||||||
|
assert.Equal(t, "plain-cache", setting.Cache)
|
||||||
|
assert.Equal(t, "openai.gpt-5", setting.System.Default)
|
||||||
|
assert.Equal(t, "mohe", setting.Uses.Default)
|
||||||
|
assert.Equal(t, "__yao.title", setting.Uses.Title)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NilSystemAndUses", func(t *testing.T) {
|
||||||
|
setting := &types.DSL{Cache: "test"}
|
||||||
|
assert.NotPanics(t, func() {
|
||||||
|
resolveEnvStrings(setting)
|
||||||
|
})
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UndefinedEnvReturnsEmpty", func(t *testing.T) {
|
||||||
|
setting := &types.DSL{
|
||||||
|
System: &types.System{
|
||||||
|
Default: "$ENV.UNDEFINED_VAR_12345",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
resolveEnvStrings(setting)
|
||||||
|
assert.Equal(t, "", setting.System.Default)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
func TestGlobalPromptsContent(t *testing.T) {
|
func TestGlobalPromptsContent(t *testing.T) {
|
||||||
prepare(t)
|
prepare(t)
|
||||||
defer test.Clean()
|
defer test.Clean()
|
||||||
|
|
|
||||||
|
|
@ -3,7 +3,7 @@ package message
|
||||||
import (
|
import (
|
||||||
"net/http"
|
"net/http"
|
||||||
|
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
"github.com/yaoapp/gou/llm"
|
||||||
traceTypes "github.com/yaoapp/yao/trace/types"
|
traceTypes "github.com/yaoapp/yao/trace/types"
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -13,7 +13,7 @@ type Options struct {
|
||||||
Accept string
|
Accept string
|
||||||
Writer http.ResponseWriter
|
Writer http.ResponseWriter
|
||||||
Trace traceTypes.Manager
|
Trace traceTypes.Manager
|
||||||
Capabilities *openai.Capabilities
|
Capabilities *llm.Capabilities
|
||||||
Locale string
|
Locale string
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -291,6 +291,9 @@ func ToAssistantModel(v interface{}) (*AssistantModel, error) {
|
||||||
if description, ok := data["description"].(string); ok {
|
if description, ok := data["description"].(string); ok {
|
||||||
model.Description = description
|
model.Description = description
|
||||||
}
|
}
|
||||||
|
if capabilities, ok := data["capabilities"].(string); ok {
|
||||||
|
model.Capabilities = capabilities
|
||||||
|
}
|
||||||
if share, ok := data["share"].(string); ok {
|
if share, ok := data["share"].(string); ok {
|
||||||
model.Share = share
|
model.Share = share
|
||||||
}
|
}
|
||||||
|
|
@ -421,6 +424,14 @@ func ToAssistantModel(v interface{}) (*AssistantModel, error) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Sandbox
|
||||||
|
if sandbox, ok := data["sandbox"]; ok && sandbox != nil {
|
||||||
|
sb, err := ToSandbox(sandbox)
|
||||||
|
if err == nil {
|
||||||
|
model.Sandbox = sb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Placeholder
|
// Placeholder
|
||||||
if placeholder, ok := data["placeholder"]; ok && placeholder != nil {
|
if placeholder, ok := data["placeholder"]; ok && placeholder != nil {
|
||||||
raw, err := jsoniter.Marshal(placeholder)
|
raw, err := jsoniter.Marshal(placeholder)
|
||||||
|
|
|
||||||
|
|
@ -20,10 +20,12 @@ var AssistantAllowedFields = map[string]bool{
|
||||||
"prompts": true,
|
"prompts": true,
|
||||||
"prompt_presets": true,
|
"prompt_presets": true,
|
||||||
"disable_global_prompts": true,
|
"disable_global_prompts": true,
|
||||||
|
"capabilities": true,
|
||||||
"workflow": true,
|
"workflow": true,
|
||||||
"kb": true,
|
"kb": true,
|
||||||
"db": true,
|
"db": true,
|
||||||
"mcp": true,
|
"mcp": true,
|
||||||
|
"sandbox": true,
|
||||||
"source": true,
|
"source": true,
|
||||||
"tags": true,
|
"tags": true,
|
||||||
"modes": true,
|
"modes": true,
|
||||||
|
|
@ -53,6 +55,7 @@ var AssistantDefaultFields = []string{
|
||||||
"avatar",
|
"avatar",
|
||||||
"connector",
|
"connector",
|
||||||
"description",
|
"description",
|
||||||
|
"capabilities", // Capabilities description for Robot orchestration (lightweight)
|
||||||
"tags", // Tags for categorization (lightweight)
|
"tags", // Tags for categorization (lightweight)
|
||||||
"modes", // Supported modes (lightweight)
|
"modes", // Supported modes (lightweight)
|
||||||
"default_mode", // Default mode (lightweight)
|
"default_mode", // Default mode (lightweight)
|
||||||
|
|
@ -63,6 +66,7 @@ var AssistantDefaultFields = []string{
|
||||||
"share",
|
"share",
|
||||||
"automated",
|
"automated",
|
||||||
"mentionable",
|
"mentionable",
|
||||||
|
"sandbox", // Sandbox configuration presence (lightweight)
|
||||||
"kb", // Knowledge base configuration (lightweight)
|
"kb", // Knowledge base configuration (lightweight)
|
||||||
"db", // Database configuration (lightweight)
|
"db", // Database configuration (lightweight)
|
||||||
"mcp", // MCP servers configuration (lightweight)
|
"mcp", // MCP servers configuration (lightweight)
|
||||||
|
|
@ -84,6 +88,7 @@ var AssistantFullFields = []string{
|
||||||
"connector",
|
"connector",
|
||||||
"connector_options",
|
"connector_options",
|
||||||
"description",
|
"description",
|
||||||
|
"capabilities",
|
||||||
"path",
|
"path",
|
||||||
"sort",
|
"sort",
|
||||||
"built_in",
|
"built_in",
|
||||||
|
|
@ -96,6 +101,7 @@ var AssistantFullFields = []string{
|
||||||
"kb",
|
"kb",
|
||||||
"db",
|
"db",
|
||||||
"mcp",
|
"mcp",
|
||||||
|
"sandbox",
|
||||||
"source",
|
"source",
|
||||||
"tags",
|
"tags",
|
||||||
"modes",
|
"modes",
|
||||||
|
|
|
||||||
|
|
@ -183,6 +183,7 @@ type AssistantFilter struct {
|
||||||
Mentionable *bool `json:"mentionable,omitempty"` // Filter by mentionable status
|
Mentionable *bool `json:"mentionable,omitempty"` // Filter by mentionable status
|
||||||
Automated *bool `json:"automated,omitempty"` // Filter by automation status
|
Automated *bool `json:"automated,omitempty"` // Filter by automation status
|
||||||
BuiltIn *bool `json:"built_in,omitempty"` // Filter by built-in status
|
BuiltIn *bool `json:"built_in,omitempty"` // Filter by built-in status
|
||||||
|
Sandbox *bool `json:"sandbox,omitempty"` // Filter by sandbox configuration (true=has sandbox, false=no sandbox)
|
||||||
Page int `json:"page,omitempty"` // Page number, starting from 1
|
Page int `json:"page,omitempty"` // Page number, starting from 1
|
||||||
PageSize int `json:"pagesize,omitempty"` // Items per page
|
PageSize int `json:"pagesize,omitempty"` // Items per page
|
||||||
Select []string `json:"select,omitempty"` // Fields to return, returns all fields if empty
|
Select []string `json:"select,omitempty"` // Fields to return, returns all fields if empty
|
||||||
|
|
@ -429,6 +430,7 @@ type AssistantModel struct {
|
||||||
BuiltIn bool `json:"built_in,omitempty"` // Whether this is a built-in assistant
|
BuiltIn bool `json:"built_in,omitempty"` // Whether this is a built-in assistant
|
||||||
Sort int `json:"sort,omitempty"` // Assistant Sort
|
Sort int `json:"sort,omitempty"` // Assistant Sort
|
||||||
Description string `json:"description,omitempty"` // Assistant Description
|
Description string `json:"description,omitempty"` // Assistant Description
|
||||||
|
Capabilities string `json:"capabilities,omitempty"` // Assistant capabilities description (useful for Robot orchestration)
|
||||||
Tags []string `json:"tags,omitempty"` // Assistant Tags
|
Tags []string `json:"tags,omitempty"` // Assistant Tags
|
||||||
Modes []string `json:"modes,omitempty"` // Supported modes (e.g., ["task", "chat"]), null means all modes are supported
|
Modes []string `json:"modes,omitempty"` // Supported modes (e.g., ["task", "chat"]), null means all modes are supported
|
||||||
DefaultMode string `json:"default_mode,omitempty"` // Default mode, can be empty
|
DefaultMode string `json:"default_mode,omitempty"` // Default mode, can be empty
|
||||||
|
|
|
||||||
|
|
@ -99,6 +99,11 @@ func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error)
|
||||||
} else {
|
} else {
|
||||||
data["description"] = nil
|
data["description"] = nil
|
||||||
}
|
}
|
||||||
|
if assistant.Capabilities != "" {
|
||||||
|
data["capabilities"] = assistant.Capabilities
|
||||||
|
} else {
|
||||||
|
data["capabilities"] = nil
|
||||||
|
}
|
||||||
if assistant.Path != "" {
|
if assistant.Path != "" {
|
||||||
data["path"] = assistant.Path
|
data["path"] = assistant.Path
|
||||||
} else {
|
} else {
|
||||||
|
|
@ -181,6 +186,7 @@ func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error)
|
||||||
"db": assistant.DB,
|
"db": assistant.DB,
|
||||||
"mcp": assistant.MCP,
|
"mcp": assistant.MCP,
|
||||||
"workflow": assistant.Workflow,
|
"workflow": assistant.Workflow,
|
||||||
|
"sandbox": assistant.Sandbox,
|
||||||
"placeholder": assistant.Placeholder,
|
"placeholder": assistant.Placeholder,
|
||||||
"locales": assistant.Locales,
|
"locales": assistant.Locales,
|
||||||
"uses": assistant.Uses,
|
"uses": assistant.Uses,
|
||||||
|
|
@ -243,14 +249,14 @@ func (store *Xun) UpdateAssistant(assistantID string, updates map[string]interfa
|
||||||
data := make(map[string]interface{})
|
data := make(map[string]interface{})
|
||||||
|
|
||||||
// List of fields that need JSON marshaling
|
// List of fields that need JSON marshaling
|
||||||
jsonFields := []string{"options", "tags", "modes", "prompts", "prompt_presets", "connector_options", "kb", "db", "mcp", "workflow", "placeholder", "locales", "uses", "search"}
|
jsonFields := []string{"options", "tags", "modes", "prompts", "prompt_presets", "connector_options", "kb", "db", "mcp", "workflow", "sandbox", "placeholder", "locales", "uses", "search"}
|
||||||
jsonFieldSet := make(map[string]bool)
|
jsonFieldSet := make(map[string]bool)
|
||||||
for _, field := range jsonFields {
|
for _, field := range jsonFields {
|
||||||
jsonFieldSet[field] = true
|
jsonFieldSet[field] = true
|
||||||
}
|
}
|
||||||
|
|
||||||
// List of nullable string fields
|
// List of nullable string fields
|
||||||
nullableStringFields := []string{"name", "avatar", "description", "path", "source", "default_mode", "__yao_created_by", "__yao_updated_by", "__yao_team_id", "__yao_tenant_id"}
|
nullableStringFields := []string{"name", "avatar", "description", "capabilities", "path", "source", "default_mode", "__yao_created_by", "__yao_updated_by", "__yao_team_id", "__yao_tenant_id"}
|
||||||
nullableFieldSet := make(map[string]bool)
|
nullableFieldSet := make(map[string]bool)
|
||||||
for _, field := range nullableStringFields {
|
for _, field := range nullableStringFields {
|
||||||
nullableFieldSet[field] = true
|
nullableFieldSet[field] = true
|
||||||
|
|
@ -349,6 +355,7 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
|
||||||
qb.Where(func(qb query.Query) {
|
qb.Where(func(qb query.Query) {
|
||||||
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Keywords)).
|
qb.Where("name", "like", fmt.Sprintf("%%%s%%", filter.Keywords)).
|
||||||
OrWhere("description", "like", fmt.Sprintf("%%%s%%", filter.Keywords)).
|
OrWhere("description", "like", fmt.Sprintf("%%%s%%", filter.Keywords)).
|
||||||
|
OrWhere("capabilities", "like", fmt.Sprintf("%%%s%%", filter.Keywords)).
|
||||||
OrWhere("locales", "like", fmt.Sprintf("%%%s%%", filter.Keywords))
|
OrWhere("locales", "like", fmt.Sprintf("%%%s%%", filter.Keywords))
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
@ -393,6 +400,21 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
|
||||||
qb.Where("built_in", *filter.BuiltIn)
|
qb.Where("built_in", *filter.BuiltIn)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Apply sandbox filter (true = has sandbox config, false = no sandbox config)
|
||||||
|
// MySQL JSON columns distinguish between SQL NULL and JSON literal null.
|
||||||
|
// CAST(sandbox AS CHAR) returns 'null' for JSON null and NULL for SQL NULL.
|
||||||
|
if filter.Sandbox != nil {
|
||||||
|
if *filter.Sandbox {
|
||||||
|
qb.WhereNotNull("sandbox").
|
||||||
|
WhereRaw("CAST(`sandbox` AS CHAR) <> 'null'")
|
||||||
|
} else {
|
||||||
|
qb.Where(func(qb query.Query) {
|
||||||
|
qb.WhereNull("sandbox").
|
||||||
|
OrWhereRaw("CAST(`sandbox` AS CHAR) = 'null'")
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Apply custom query filter function (for permission filtering)
|
// Apply custom query filter function (for permission filtering)
|
||||||
if filter.QueryFilter != nil {
|
if filter.QueryFilter != nil {
|
||||||
qb.Where(filter.QueryFilter)
|
qb.Where(filter.QueryFilter)
|
||||||
|
|
@ -448,7 +470,7 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
|
||||||
|
|
||||||
// Convert rows to types.AssistantModel slice
|
// Convert rows to types.AssistantModel slice
|
||||||
assistants := make([]*types.AssistantModel, 0, len(rows))
|
assistants := make([]*types.AssistantModel, 0, len(rows))
|
||||||
jsonFields := []string{"tags", "options", "prompts", "prompt_presets", "connector_options", "workflow", "kb", "mcp", "placeholder", "locales", "uses", "search"}
|
jsonFields := []string{"tags", "options", "prompts", "prompt_presets", "connector_options", "workflow", "sandbox", "kb", "mcp", "placeholder", "locales", "uses", "search"}
|
||||||
|
|
||||||
for _, row := range rows {
|
for _, row := range rows {
|
||||||
data := row.ToMap()
|
data := row.ToMap()
|
||||||
|
|
@ -521,7 +543,7 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse JSON fields
|
// Parse JSON fields
|
||||||
jsonFields := []string{"tags", "modes", "options", "prompts", "prompt_presets", "connector_options", "workflow", "kb", "db", "mcp", "placeholder", "locales", "uses", "search"}
|
jsonFields := []string{"tags", "modes", "options", "prompts", "prompt_presets", "connector_options", "workflow", "sandbox", "kb", "db", "mcp", "placeholder", "locales", "uses", "search"}
|
||||||
store.parseJSONFields(data, jsonFields)
|
store.parseJSONFields(data, jsonFields)
|
||||||
|
|
||||||
// Convert map to types.AssistantModel
|
// Convert map to types.AssistantModel
|
||||||
|
|
@ -536,6 +558,7 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
|
||||||
BuiltIn: getBool(data, "built_in"),
|
BuiltIn: getBool(data, "built_in"),
|
||||||
Sort: getInt(data, "sort"),
|
Sort: getInt(data, "sort"),
|
||||||
Description: getString(data, "description"),
|
Description: getString(data, "description"),
|
||||||
|
Capabilities: getString(data, "capabilities"),
|
||||||
DefaultMode: getString(data, "default_mode"),
|
DefaultMode: getString(data, "default_mode"),
|
||||||
Readonly: getBool(data, "readonly"),
|
Readonly: getBool(data, "readonly"),
|
||||||
Public: getBool(data, "public"),
|
Public: getBool(data, "public"),
|
||||||
|
|
@ -636,6 +659,13 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if sandbox, has := data["sandbox"]; has && sandbox != nil {
|
||||||
|
sb, err := types.ToSandbox(sandbox)
|
||||||
|
if err == nil {
|
||||||
|
model.Sandbox = sb
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if placeholder, has := data["placeholder"]; has && placeholder != nil {
|
if placeholder, has := data["placeholder"]; has && placeholder != nil {
|
||||||
raw, err := jsoniter.Marshal(placeholder)
|
raw, err := jsoniter.Marshal(placeholder)
|
||||||
if err == nil {
|
if err == nil {
|
||||||
|
|
@ -839,6 +869,13 @@ func (store *Xun) translate(model *types.AssistantModel, assistantID string, loc
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Translate capabilities
|
||||||
|
if translated := i18n.Translate(assistantID, locale, model.Capabilities); translated != nil {
|
||||||
|
if s, ok := translated.(string); ok {
|
||||||
|
model.Capabilities = s
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// Translate prompts
|
// Translate prompts
|
||||||
if model.Prompts != nil {
|
if model.Prompts != nil {
|
||||||
for i := range model.Prompts {
|
for i := range model.Prompts {
|
||||||
|
|
|
||||||
|
|
@ -788,6 +788,467 @@ func TestSaveAssistant(t *testing.T) {
|
||||||
t.Logf("Successfully saved and retrieved source code for assistant %s", id)
|
t.Logf("Successfully saved and retrieved source code for assistant %s", id)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("SandboxConfiguration", func(t *testing.T) {
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "Sandbox Test Assistant",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
Sandbox: &types.Sandbox{
|
||||||
|
Command: "claude",
|
||||||
|
Timeout: "5m",
|
||||||
|
Arguments: map[string]interface{}{
|
||||||
|
"max_turns": 10,
|
||||||
|
"permission_mode": "bypassPermissions",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save assistant with sandbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox == nil {
|
||||||
|
t.Fatal("Expected sandbox to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox.Command != "claude" {
|
||||||
|
t.Errorf("Expected command 'claude', got '%s'", retrieved.Sandbox.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox.Timeout != "5m" {
|
||||||
|
t.Errorf("Expected timeout '5m', got '%s'", retrieved.Sandbox.Timeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox.Arguments == nil {
|
||||||
|
t.Fatal("Expected sandbox arguments to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if maxTurns, ok := retrieved.Sandbox.Arguments["max_turns"].(float64); !ok || maxTurns != 10 {
|
||||||
|
t.Errorf("Expected max_turns 10, got %v", retrieved.Sandbox.Arguments["max_turns"])
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully saved and retrieved sandbox configuration for assistant %s", id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("SandboxWithImage", func(t *testing.T) {
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "Sandbox Image Test",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
Sandbox: &types.Sandbox{
|
||||||
|
Command: "claude",
|
||||||
|
Image: "yaoapp/sandbox-claude-desktop:latest",
|
||||||
|
Timeout: "20m",
|
||||||
|
MaxMemory: "4g",
|
||||||
|
MaxCPU: 2.0,
|
||||||
|
Arguments: map[string]interface{}{
|
||||||
|
"max_turns": 500,
|
||||||
|
"permission_mode": "bypassPermissions",
|
||||||
|
"disallowed_tools": "WebSearch",
|
||||||
|
},
|
||||||
|
Secrets: map[string]string{
|
||||||
|
"GITHUB_TOKEN": "$ENV.GITHUB_TOKEN",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save assistant with sandbox image: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox == nil {
|
||||||
|
t.Fatal("Expected sandbox to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox.Image != "yaoapp/sandbox-claude-desktop:latest" {
|
||||||
|
t.Errorf("Expected image 'yaoapp/sandbox-claude-desktop:latest', got '%s'", retrieved.Sandbox.Image)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox.MaxMemory != "4g" {
|
||||||
|
t.Errorf("Expected max_memory '4g', got '%s'", retrieved.Sandbox.MaxMemory)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox.MaxCPU != 2.0 {
|
||||||
|
t.Errorf("Expected max_cpu 2.0, got %f", retrieved.Sandbox.MaxCPU)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox.Secrets == nil || retrieved.Sandbox.Secrets["GITHUB_TOKEN"] != "$ENV.GITHUB_TOKEN" {
|
||||||
|
t.Errorf("Expected secrets to contain GITHUB_TOKEN, got %v", retrieved.Sandbox.Secrets)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully saved and retrieved sandbox with image for assistant %s", id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("NilSandbox", func(t *testing.T) {
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "No Sandbox Assistant",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save assistant without sandbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox != nil {
|
||||||
|
t.Errorf("Expected sandbox to be nil, got %+v", retrieved.Sandbox)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CapabilitiesField", func(t *testing.T) {
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "Capabilities Test",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
Description: "A test assistant",
|
||||||
|
Capabilities: "Can search the web, analyze data, write code, and summarize documents.",
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save assistant with capabilities: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Capabilities != "Can search the web, analyze data, write code, and summarize documents." {
|
||||||
|
t.Errorf("Expected capabilities to match, got '%s'", retrieved.Capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Description != "A test assistant" {
|
||||||
|
t.Errorf("Expected description 'A test assistant', got '%s'", retrieved.Description)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Successfully saved and retrieved capabilities for assistant %s", id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("EmptyCapabilities", func(t *testing.T) {
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "No Capabilities Assistant",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save assistant without capabilities: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Capabilities != "" {
|
||||||
|
t.Errorf("Expected empty capabilities, got '%s'", retrieved.Capabilities)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CapabilitiesWithI18n", func(t *testing.T) {
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "{{name}}",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
Description: "{{description}}",
|
||||||
|
Capabilities: "{{capabilities}}",
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save assistant with i18n capabilities: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Setup i18n
|
||||||
|
i18n.Locales[id] = map[string]i18n.I18n{
|
||||||
|
"en": {
|
||||||
|
Locale: "en",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"name": "i18n Test",
|
||||||
|
"description": "Description in English",
|
||||||
|
"capabilities": "Can do X, Y, and Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
"zh-cn": {
|
||||||
|
Locale: "zh-cn",
|
||||||
|
Messages: map[string]any{
|
||||||
|
"name": "国际化测试",
|
||||||
|
"description": "中文描述",
|
||||||
|
"capabilities": "可以做X、Y和Z",
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
retrievedEN, err := store.GetAssistant(id, types.AssistantFullFields, "en")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant with EN locale: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrievedEN.Capabilities != "Can do X, Y, and Z" {
|
||||||
|
t.Errorf("Expected capabilities 'Can do X, Y, and Z', got '%s'", retrievedEN.Capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrievedZH, err := store.GetAssistant(id, types.AssistantFullFields, "zh-cn")
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to get assistant with ZH locale: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrievedZH.Capabilities != "可以做X、Y和Z" {
|
||||||
|
t.Errorf("Expected capabilities '可以做X、Y和Z', got '%s'", retrievedZH.Capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Cleanup
|
||||||
|
delete(i18n.Locales, id)
|
||||||
|
t.Logf("Successfully tested capabilities i18n for assistant %s", id)
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("CapabilitiesInKeywordSearch", func(t *testing.T) {
|
||||||
|
uniqueCapability := fmt.Sprintf("unique-cap-%d", time.Now().UnixNano())
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "Capabilities Search Test",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
Capabilities: uniqueCapability,
|
||||||
|
}
|
||||||
|
|
||||||
|
_, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
response, err := store.GetAssistants(types.AssistantFilter{
|
||||||
|
Keywords: uniqueCapability,
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 20,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to search by capabilities keyword: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if len(response.Data) < 1 {
|
||||||
|
t.Error("Expected to find assistant by capabilities keyword search")
|
||||||
|
}
|
||||||
|
|
||||||
|
found := false
|
||||||
|
for _, a := range response.Data {
|
||||||
|
if a.Capabilities == uniqueCapability {
|
||||||
|
found = true
|
||||||
|
break
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if !found {
|
||||||
|
t.Error("Expected to find assistant with matching capabilities")
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UpdateSandbox", func(t *testing.T) {
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "Update Sandbox Test",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update with sandbox
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"sandbox": &types.Sandbox{
|
||||||
|
Command: "claude",
|
||||||
|
Timeout: "10m",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.UpdateAssistant(id, updates)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to update sandbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox == nil {
|
||||||
|
t.Fatal("Expected sandbox to be set")
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Sandbox.Command != "claude" {
|
||||||
|
t.Errorf("Expected command 'claude', got '%s'", retrieved.Sandbox.Command)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update to remove sandbox
|
||||||
|
updates2 := map[string]interface{}{
|
||||||
|
"sandbox": nil,
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.UpdateAssistant(id, updates2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to remove sandbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved2, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved2.Sandbox != nil {
|
||||||
|
t.Errorf("Expected sandbox to be nil, got %+v", retrieved2.Sandbox)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("UpdateCapabilities", func(t *testing.T) {
|
||||||
|
assistant := &types.AssistantModel{
|
||||||
|
Name: "Update Capabilities Test",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
Capabilities: "Original capabilities",
|
||||||
|
}
|
||||||
|
|
||||||
|
id, err := store.SaveAssistant(assistant)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to create assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update capabilities
|
||||||
|
updates := map[string]interface{}{
|
||||||
|
"capabilities": "Updated capabilities: can search, analyze, and write code",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.UpdateAssistant(id, updates)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to update capabilities: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved.Capabilities != "Updated capabilities: can search, analyze, and write code" {
|
||||||
|
t.Errorf("Expected updated capabilities, got '%s'", retrieved.Capabilities)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Update to clear capabilities
|
||||||
|
updates2 := map[string]interface{}{
|
||||||
|
"capabilities": "",
|
||||||
|
}
|
||||||
|
|
||||||
|
err = store.UpdateAssistant(id, updates2)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to clear capabilities: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
retrieved2, err := store.GetAssistant(id, types.AssistantFullFields)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to retrieve assistant: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
if retrieved2.Capabilities != "" {
|
||||||
|
t.Errorf("Expected empty capabilities, got '%s'", retrieved2.Capabilities)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("FilterBySandbox", func(t *testing.T) {
|
||||||
|
// Create one assistant with sandbox
|
||||||
|
withSandbox := &types.AssistantModel{
|
||||||
|
Name: "Filter Sandbox Yes",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
Sandbox: &types.Sandbox{
|
||||||
|
Command: "claude",
|
||||||
|
Timeout: "5m",
|
||||||
|
},
|
||||||
|
}
|
||||||
|
idWith, err := store.SaveAssistant(withSandbox)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save assistant with sandbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Create one assistant without sandbox
|
||||||
|
withoutSandbox := &types.AssistantModel{
|
||||||
|
Name: "Filter Sandbox No",
|
||||||
|
Type: "assistant",
|
||||||
|
Connector: "openai",
|
||||||
|
Share: "private",
|
||||||
|
}
|
||||||
|
idWithout, err := store.SaveAssistant(withoutSandbox)
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to save assistant without sandbox: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
testIDs := []string{idWith, idWithout}
|
||||||
|
|
||||||
|
// Filter: sandbox=true, scoped to test IDs
|
||||||
|
trueVal := true
|
||||||
|
result, err := store.GetAssistants(types.AssistantFilter{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 100,
|
||||||
|
Sandbox: &trueVal,
|
||||||
|
AssistantIDs: testIDs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to filter with sandbox=true: %v", err)
|
||||||
|
}
|
||||||
|
if len(result.Data) != 1 {
|
||||||
|
t.Errorf("Expected 1 result for sandbox=true, got %d", len(result.Data))
|
||||||
|
} else if result.Data[0].ID != idWith {
|
||||||
|
t.Errorf("Expected assistant %s, got %s", idWith, result.Data[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Filter: sandbox=false, scoped to test IDs
|
||||||
|
falseVal := false
|
||||||
|
result2, err := store.GetAssistants(types.AssistantFilter{
|
||||||
|
Page: 1,
|
||||||
|
PageSize: 100,
|
||||||
|
Sandbox: &falseVal,
|
||||||
|
AssistantIDs: testIDs,
|
||||||
|
})
|
||||||
|
if err != nil {
|
||||||
|
t.Fatalf("Failed to filter with sandbox=false: %v", err)
|
||||||
|
}
|
||||||
|
if len(result2.Data) != 1 {
|
||||||
|
t.Errorf("Expected 1 result for sandbox=false, got %d", len(result2.Data))
|
||||||
|
} else if result2.Data[0].ID != idWithout {
|
||||||
|
t.Errorf("Expected assistant %s, got %s", idWithout, result2.Data[0].ID)
|
||||||
|
}
|
||||||
|
|
||||||
|
t.Logf("Sandbox filter test passed: sandbox=true returned %d, sandbox=false returned %d", len(result.Data), len(result2.Data))
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("AllNewFieldsTogether", func(t *testing.T) {
|
t.Run("AllNewFieldsTogether", func(t *testing.T) {
|
||||||
// Test assistant with all new fields together
|
// Test assistant with all new fields together
|
||||||
optionalFalse := false
|
optionalFalse := false
|
||||||
|
|
|
||||||
|
|
@ -168,9 +168,18 @@ func (store *Xun) getAssistantTable() string {
|
||||||
func (store *Xun) parseJSONFields(data map[string]interface{}, fields []string) {
|
func (store *Xun) parseJSONFields(data map[string]interface{}, fields []string) {
|
||||||
for _, field := range fields {
|
for _, field := range fields {
|
||||||
if val := data[field]; val != nil {
|
if val := data[field]; val != nil {
|
||||||
if strVal, ok := val.(string); ok && strVal != "" {
|
var jsonStr string
|
||||||
|
switch v := val.(type) {
|
||||||
|
case string:
|
||||||
|
jsonStr = v
|
||||||
|
case []byte:
|
||||||
|
jsonStr = string(v)
|
||||||
|
default:
|
||||||
|
continue
|
||||||
|
}
|
||||||
|
if jsonStr != "" {
|
||||||
var parsed interface{}
|
var parsed interface{}
|
||||||
if err := jsoniter.UnmarshalFromString(strVal, &parsed); err == nil {
|
if err := jsoniter.UnmarshalFromString(jsonStr, &parsed); err == nil {
|
||||||
data[field] = parsed
|
data[field] = parsed
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,6 @@
|
||||||
package types
|
package types
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
|
||||||
"github.com/yaoapp/yao/agent/assistant"
|
"github.com/yaoapp/yao/agent/assistant"
|
||||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||||
store "github.com/yaoapp/yao/agent/store/types"
|
store "github.com/yaoapp/yao/agent/store/types"
|
||||||
|
|
@ -23,9 +22,8 @@ type DSL struct {
|
||||||
// If not set, fallback to the first connector that supports the required capabilities
|
// If not set, fallback to the first connector that supports the required capabilities
|
||||||
System *System `json:"system,omitempty" yaml:"system,omitempty"`
|
System *System `json:"system,omitempty" yaml:"system,omitempty"`
|
||||||
|
|
||||||
// Global External Settings - model capabilities, tools, etc.
|
// Global External Settings
|
||||||
// ===============================
|
// ===============================
|
||||||
Models map[string]openai.Capabilities `json:"models,omitempty" yaml:"models,omitempty"` // The model capabilities configuration
|
|
||||||
KB *store.KBSetting `json:"kb,omitempty" yaml:"kb,omitempty"` // The knowledge base configuration loaded from agent/kb.yml
|
KB *store.KBSetting `json:"kb,omitempty" yaml:"kb,omitempty"` // The knowledge base configuration loaded from agent/kb.yml
|
||||||
Search *searchTypes.Config `json:"search,omitempty" yaml:"search,omitempty"` // The search configuration loaded from agent/search.yao
|
Search *searchTypes.Config `json:"search,omitempty" yaml:"search,omitempty"` // The search configuration loaded from agent/search.yao
|
||||||
|
|
||||||
|
|
|
||||||
1605
data/bindata.go
1605
data/bindata.go
File diff suppressed because one or more lines are too long
33
event/sub.go
33
event/sub.go
|
|
@ -54,14 +54,25 @@ func (sm *subManager) subscribe(pattern string, ch chan<- *types.Event, opts ...
|
||||||
return id
|
return id
|
||||||
}
|
}
|
||||||
|
|
||||||
// unsubscribe removes a subscriber by ID.
|
// unsubscribe removes a subscriber by ID and closes its channel
|
||||||
|
// so that any goroutine blocked on `range ch` will unblock and exit.
|
||||||
func (sm *subManager) unsubscribe(id string) {
|
func (sm *subManager) unsubscribe(id string) {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
entry, ok := sm.entries[id]
|
||||||
delete(sm.entries, id)
|
delete(sm.entries, id)
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
if ok && entry.ch != nil {
|
||||||
|
func() {
|
||||||
|
defer func() { recover() }()
|
||||||
|
close(entry.ch)
|
||||||
|
}()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// notify sends an event to all matching subscribers (non-blocking).
|
// notify sends an event to all matching subscribers (non-blocking).
|
||||||
|
// Recovers from send-on-closed-channel panics that may occur if
|
||||||
|
// unsubscribe closes a channel concurrently.
|
||||||
func (sm *subManager) notify(ev *types.Event) {
|
func (sm *subManager) notify(ev *types.Event) {
|
||||||
sm.mu.RLock()
|
sm.mu.RLock()
|
||||||
defer sm.mu.RUnlock()
|
defer sm.mu.RUnlock()
|
||||||
|
|
@ -73,19 +84,31 @@ func (sm *subManager) notify(ev *types.Event) {
|
||||||
if entry.filter != nil && !entry.filter(ev) {
|
if entry.filter != nil && !entry.filter(ev) {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
func() {
|
||||||
|
defer func() { recover() }()
|
||||||
select {
|
select {
|
||||||
case entry.ch <- ev:
|
case entry.ch <- ev:
|
||||||
default:
|
default:
|
||||||
// Subscriber chan full, skip (non-blocking)
|
|
||||||
}
|
}
|
||||||
|
}()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// clear removes all subscribers. Used during Stop.
|
// clear removes all subscribers and closes their channels. Used during Stop.
|
||||||
func (sm *subManager) clear() {
|
func (sm *subManager) clear() {
|
||||||
sm.mu.Lock()
|
sm.mu.Lock()
|
||||||
defer sm.mu.Unlock()
|
old := sm.entries
|
||||||
sm.entries = make(map[string]*subEntry)
|
sm.entries = make(map[string]*subEntry)
|
||||||
|
sm.mu.Unlock()
|
||||||
|
|
||||||
|
for _, entry := range old {
|
||||||
|
if entry.ch != nil {
|
||||||
|
func() {
|
||||||
|
defer func() { recover() }()
|
||||||
|
close(entry.ch)
|
||||||
|
}()
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe dynamically subscribes to events matching the given pattern.
|
// Subscribe dynamically subscribes to events matching the given pattern.
|
||||||
|
|
|
||||||
|
|
@ -164,6 +164,7 @@ func TestSubscribe_StopClearsSubscribers(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// drainChan reads up to n events from ch within timeout.
|
// drainChan reads up to n events from ch within timeout.
|
||||||
|
// Stops early if the channel is closed.
|
||||||
func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Event {
|
func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Event {
|
||||||
var result []*types.Event
|
var result []*types.Event
|
||||||
timer := time.NewTimer(timeout)
|
timer := time.NewTimer(timeout)
|
||||||
|
|
@ -171,7 +172,10 @@ func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Even
|
||||||
|
|
||||||
for range n {
|
for range n {
|
||||||
select {
|
select {
|
||||||
case ev := <-ch:
|
case ev, ok := <-ch:
|
||||||
|
if !ok {
|
||||||
|
return result
|
||||||
|
}
|
||||||
result = append(result, ev)
|
result = append(result, ev)
|
||||||
case <-timer.C:
|
case <-timer.C:
|
||||||
return result
|
return result
|
||||||
|
|
|
||||||
|
|
@ -117,7 +117,7 @@ func ListAssistants(c *gin.Context) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Parse boolean filters
|
// Parse boolean filters
|
||||||
var builtIn, mentionable, automated *bool
|
var builtIn, mentionable, automated, sandbox *bool
|
||||||
if builtInParam := c.Query("built_in"); builtInParam != "" {
|
if builtInParam := c.Query("built_in"); builtInParam != "" {
|
||||||
builtIn = parseBoolValue(builtInParam)
|
builtIn = parseBoolValue(builtInParam)
|
||||||
}
|
}
|
||||||
|
|
@ -127,6 +127,9 @@ func ListAssistants(c *gin.Context) {
|
||||||
if automatedParam := c.Query("automated"); automatedParam != "" {
|
if automatedParam := c.Query("automated"); automatedParam != "" {
|
||||||
automated = parseBoolValue(automatedParam)
|
automated = parseBoolValue(automatedParam)
|
||||||
}
|
}
|
||||||
|
if sandboxParam := c.Query("sandbox"); sandboxParam != "" {
|
||||||
|
sandbox = parseBoolValue(sandboxParam)
|
||||||
|
}
|
||||||
|
|
||||||
// Note: public and share filters are not yet supported in AssistantFilter
|
// Note: public and share filters are not yet supported in AssistantFilter
|
||||||
// They would need to be added to the store layer for proper filtering
|
// They would need to be added to the store layer for proper filtering
|
||||||
|
|
@ -152,6 +155,7 @@ func ListAssistants(c *gin.Context) {
|
||||||
BuiltIn: builtIn,
|
BuiltIn: builtIn,
|
||||||
Mentionable: mentionable,
|
Mentionable: mentionable,
|
||||||
Automated: automated,
|
Automated: automated,
|
||||||
|
Sandbox: sandbox,
|
||||||
})
|
})
|
||||||
|
|
||||||
// Apply permission-based filtering (Scope filtering)
|
// Apply permission-based filtering (Scope filtering)
|
||||||
|
|
@ -169,12 +173,19 @@ func ListAssistants(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter sensitive fields for built-in assistants
|
// Convert sandbox to boolean and filter built-in sensitive fields
|
||||||
// For built-in assistants, clear code-level fields (prompts, workflow, tools, kb, mcp, options)
|
resp := map[string]interface{}{
|
||||||
FilterBuiltInFields(result.Data)
|
"data": AssistantsToResponse(result.Data),
|
||||||
|
"total": result.Total,
|
||||||
|
"page": result.Page,
|
||||||
|
"pagesize": result.PageSize,
|
||||||
|
"pagecount": result.PageCount,
|
||||||
|
"next": result.Next,
|
||||||
|
"prev": result.Prev,
|
||||||
|
}
|
||||||
|
|
||||||
// Return the result with standard response format
|
// Return the result with standard response format
|
||||||
response.RespondWithSuccess(c, response.StatusOK, result)
|
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// GetAssistant retrieves a single assistant by ID with permission verification
|
// GetAssistant retrieves a single assistant by ID with permission verification
|
||||||
|
|
@ -260,11 +271,13 @@ func GetAssistant(c *gin.Context) {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Filter sensitive fields for built-in assistants
|
// Convert sandbox to boolean and filter built-in sensitive fields
|
||||||
|
hasSandbox := assistant.Sandbox != nil
|
||||||
FilterBuiltInAssistant(assistant)
|
FilterBuiltInAssistant(assistant)
|
||||||
|
resp := AssistantToResponse(assistant, hasSandbox)
|
||||||
|
|
||||||
// Return the result with standard response format
|
// Return the result with standard response format
|
||||||
response.RespondWithSuccess(c, response.StatusOK, assistant)
|
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// ListAssistantTags lists assistant tags with permission-based filtering
|
// ListAssistantTags lists assistant tags with permission-based filtering
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,8 @@
|
||||||
package agent
|
package agent
|
||||||
|
|
||||||
import (
|
import (
|
||||||
|
"encoding/json"
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/gou/model"
|
"github.com/yaoapp/gou/model"
|
||||||
"github.com/yaoapp/xun/dbal/query"
|
"github.com/yaoapp/xun/dbal/query"
|
||||||
|
|
@ -150,9 +152,49 @@ func FilterBuiltInAssistant(assistant *agenttypes.AssistantModel) {
|
||||||
assistant.Prompts = nil
|
assistant.Prompts = nil
|
||||||
assistant.PromptPresets = nil
|
assistant.PromptPresets = nil
|
||||||
assistant.Workflow = nil
|
assistant.Workflow = nil
|
||||||
|
assistant.Sandbox = nil
|
||||||
assistant.KB = nil
|
assistant.KB = nil
|
||||||
assistant.MCP = nil
|
assistant.MCP = nil
|
||||||
assistant.Options = nil
|
assistant.Options = nil
|
||||||
assistant.Source = ""
|
assistant.Source = ""
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// AssistantToResponse converts an AssistantModel to a response map,
|
||||||
|
// replacing the sandbox JSON object with a boolean indicating whether sandbox is configured.
|
||||||
|
// hasSandbox must be captured before FilterBuiltInAssistant clears the Sandbox field.
|
||||||
|
func AssistantToResponse(assistant *agenttypes.AssistantModel, hasSandbox bool) map[string]interface{} {
|
||||||
|
if assistant == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
raw, err := json.Marshal(assistant)
|
||||||
|
if err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
var result map[string]interface{}
|
||||||
|
if err := json.Unmarshal(raw, &result); err != nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result["sandbox"] = hasSandbox
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
||||||
|
// AssistantsToResponse converts a slice of AssistantModel to response maps,
|
||||||
|
// replacing sandbox with a boolean for each assistant.
|
||||||
|
// Captures sandbox state before filtering, then applies FilterBuiltInAssistant.
|
||||||
|
func AssistantsToResponse(assistants []*agenttypes.AssistantModel) []map[string]interface{} {
|
||||||
|
if assistants == nil {
|
||||||
|
return nil
|
||||||
|
}
|
||||||
|
|
||||||
|
result := make([]map[string]interface{}, 0, len(assistants))
|
||||||
|
for _, a := range assistants {
|
||||||
|
hasSandbox := a.Sandbox != nil
|
||||||
|
FilterBuiltInAssistant(a)
|
||||||
|
result = append(result, AssistantToResponse(a, hasSandbox))
|
||||||
|
}
|
||||||
|
return result
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -12,9 +12,9 @@ var (
|
||||||
// availableAssistantFields defines all available fields for security filtering
|
// availableAssistantFields defines all available fields for security filtering
|
||||||
availableAssistantFields = map[string]bool{
|
availableAssistantFields = map[string]bool{
|
||||||
"id": true, "assistant_id": true, "type": true, "name": true, "avatar": true,
|
"id": true, "assistant_id": true, "type": true, "name": true, "avatar": true,
|
||||||
"connector": true, "description": true, "path": true, "sort": true,
|
"connector": true, "description": true, "capabilities": true, "path": true, "sort": true,
|
||||||
"built_in": true, "placeholder": true, "options": true, "prompts": true,
|
"built_in": true, "placeholder": true, "options": true, "prompts": true,
|
||||||
"workflow": true, "kb": true, "mcp": true, "tools": true, "tags": true,
|
"workflow": true, "sandbox": true, "kb": true, "mcp": true, "tools": true, "tags": true,
|
||||||
"readonly": true, "public": true, "share": true, "locales": true,
|
"readonly": true, "public": true, "share": true, "locales": true,
|
||||||
"automated": true, "mentionable": true,
|
"automated": true, "mentionable": true,
|
||||||
"created_at": true, "updated_at": true, "deleted_at": true,
|
"created_at": true, "updated_at": true, "deleted_at": true,
|
||||||
|
|
@ -23,9 +23,9 @@ var (
|
||||||
|
|
||||||
// defaultAssistantFields defines the default compact field list
|
// defaultAssistantFields defines the default compact field list
|
||||||
defaultAssistantFields = []string{
|
defaultAssistantFields = []string{
|
||||||
"assistant_id", "type", "name", "avatar", "connector", "description",
|
"assistant_id", "type", "name", "avatar", "connector", "description", "capabilities",
|
||||||
"sort", "built_in", "tags", "readonly", "public", "share",
|
"sort", "built_in", "tags", "readonly", "public", "share",
|
||||||
"automated", "mentionable", "created_at", "updated_at",
|
"automated", "mentionable", "sandbox", "created_at", "updated_at",
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
@ -59,6 +59,7 @@ type AssistantFilterParams struct {
|
||||||
BuiltIn *bool
|
BuiltIn *bool
|
||||||
Mentionable *bool
|
Mentionable *bool
|
||||||
Automated *bool
|
Automated *bool
|
||||||
|
Sandbox *bool
|
||||||
Public *bool
|
Public *bool
|
||||||
Share string
|
Share string
|
||||||
}
|
}
|
||||||
|
|
@ -79,6 +80,7 @@ func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilt
|
||||||
BuiltIn: params.BuiltIn,
|
BuiltIn: params.BuiltIn,
|
||||||
Mentionable: params.Mentionable,
|
Mentionable: params.Mentionable,
|
||||||
Automated: params.Automated,
|
Automated: params.Automated,
|
||||||
|
Sandbox: params.Sandbox,
|
||||||
}
|
}
|
||||||
|
|
||||||
// Set default type if not specified (only when Types is also empty)
|
// Set default type if not specified (only when Types is also empty)
|
||||||
|
|
|
||||||
|
|
@ -5,8 +5,6 @@ import (
|
||||||
|
|
||||||
"github.com/gin-gonic/gin"
|
"github.com/gin-gonic/gin"
|
||||||
"github.com/yaoapp/gou/connector"
|
"github.com/yaoapp/gou/connector"
|
||||||
"github.com/yaoapp/gou/connector/openai"
|
|
||||||
"github.com/yaoapp/yao/agent"
|
|
||||||
agentllm "github.com/yaoapp/yao/agent/llm"
|
agentllm "github.com/yaoapp/yao/agent/llm"
|
||||||
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
|
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||||
"github.com/yaoapp/yao/openapi/response"
|
"github.com/yaoapp/yao/openapi/response"
|
||||||
|
|
@ -21,16 +19,6 @@ type Provider struct {
|
||||||
Capabilities map[string]interface{} `json:"capabilities"` // Model capabilities from connector settings
|
Capabilities map[string]interface{} `json:"capabilities"` // Model capabilities from connector settings
|
||||||
}
|
}
|
||||||
|
|
||||||
// getModelCapabilities returns user-defined model capabilities from agent DSL
|
|
||||||
// Returns nil if agent not initialized or no models configured
|
|
||||||
func getModelCapabilities() map[string]openai.Capabilities {
|
|
||||||
agentDSL := agent.GetAgent()
|
|
||||||
if agentDSL != nil && agentDSL.Models != nil {
|
|
||||||
return agentDSL.Models
|
|
||||||
}
|
|
||||||
return nil
|
|
||||||
}
|
|
||||||
|
|
||||||
// Attach attaches the LLM management handlers to the router with OAuth protection
|
// Attach attaches the LLM management handlers to the router with OAuth protection
|
||||||
func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) {
|
func Attach(group *gin.RouterGroup, oauth oauthTypes.OAuth) {
|
||||||
|
|
||||||
|
|
@ -56,23 +44,15 @@ func listProviders(c *gin.Context) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get user-defined model capabilities once at the start of request
|
|
||||||
modelCapabilities := getModelCapabilities()
|
|
||||||
|
|
||||||
// Get all LLM connectors from AIConnectors
|
|
||||||
// Note: All AI type connectors (openai, anthropic, fastembed) are automatically added to AIConnectors during loading
|
|
||||||
// See gou/connector/connector.go LoadSource() for details
|
|
||||||
for _, opt := range connector.AIConnectors {
|
for _, opt := range connector.AIConnectors {
|
||||||
connType := getConnectorType(opt.Value)
|
connType := getConnectorType(opt.Value)
|
||||||
// Include OpenAI-compatible and Anthropic LLM connectors
|
|
||||||
if connType == "openai" || connType == "anthropic" {
|
if connType == "openai" || connType == "anthropic" {
|
||||||
conn, ok := connector.Connectors[opt.Value]
|
conn, ok := connector.Connectors[opt.Value]
|
||||||
if !ok {
|
if !ok {
|
||||||
continue
|
continue
|
||||||
}
|
}
|
||||||
|
|
||||||
// Get capabilities from connector settings
|
capabilities := getCapabilitiesFromConn(conn)
|
||||||
capabilities := getCapabilitiesWithModels(conn, modelCapabilities)
|
|
||||||
|
|
||||||
// Apply capability filters
|
// Apply capability filters
|
||||||
if len(filters) > 0 && !matchesFilters(capabilities, filters) {
|
if len(filters) > 0 && !matchesFilters(capabilities, filters) {
|
||||||
|
|
@ -110,16 +90,13 @@ func getConnectorType(id string) string {
|
||||||
return "unknown"
|
return "unknown"
|
||||||
}
|
}
|
||||||
|
|
||||||
// getCapabilitiesWithModels extracts capabilities from connector settings
|
// getCapabilitiesFromConn extracts capabilities from connector settings
|
||||||
// Uses the unified capability getter from agent/llm package
|
func getCapabilitiesFromConn(conn connector.Connector) map[string]interface{} {
|
||||||
// Takes modelCapabilities as parameter to avoid repeated calls to getModelCapabilities()
|
|
||||||
func getCapabilitiesWithModels(conn connector.Connector, modelCapabilities map[string]openai.Capabilities) map[string]interface{} {
|
|
||||||
if conn == nil {
|
if conn == nil {
|
||||||
return nil
|
return nil
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use unified capability getter with user-defined model capabilities
|
caps := agentllm.GetCapabilitiesFromConn(conn)
|
||||||
caps := agentllm.GetCapabilitiesFromConn(conn, modelCapabilities)
|
|
||||||
return agentllm.ToMap(caps)
|
return agentllm.ToMap(caps)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -264,6 +264,67 @@ func TestListAssistants(t *testing.T) {
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
|
t.Run("ListAssistantsWithSandboxFilter", func(t *testing.T) {
|
||||||
|
// Test with sandbox=true filter
|
||||||
|
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?sandbox=true&types=assistant", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
data, hasData := response["data"].([]interface{})
|
||||||
|
if hasData {
|
||||||
|
for _, item := range data {
|
||||||
|
a, ok := item.(map[string]interface{})
|
||||||
|
if ok {
|
||||||
|
sandboxVal, exists := a["sandbox"]
|
||||||
|
assert.True(t, exists, "sandbox field should be present in list response")
|
||||||
|
assert.Equal(t, true, sandboxVal, "sandbox should be true when filtering sandbox=true")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
t.Logf("Successfully retrieved %d assistants with sandbox filter", len(data))
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
t.Run("ListAssistantsSandboxReturnsBool", func(t *testing.T) {
|
||||||
|
// Verify sandbox field is returned as boolean (not JSON object) in list response
|
||||||
|
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?pagesize=5&types=assistant", nil)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
req.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
|
||||||
|
|
||||||
|
resp, err := http.DefaultClient.Do(req)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, resp)
|
||||||
|
defer resp.Body.Close()
|
||||||
|
|
||||||
|
assert.Equal(t, http.StatusOK, resp.StatusCode)
|
||||||
|
|
||||||
|
var response map[string]interface{}
|
||||||
|
err = json.NewDecoder(resp.Body).Decode(&response)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
data, hasData := response["data"].([]interface{})
|
||||||
|
if hasData && len(data) > 0 {
|
||||||
|
a, ok := data[0].(map[string]interface{})
|
||||||
|
if ok {
|
||||||
|
sandboxVal, exists := a["sandbox"]
|
||||||
|
assert.True(t, exists, "sandbox field should be present in default list fields")
|
||||||
|
_, isBool := sandboxVal.(bool)
|
||||||
|
assert.True(t, isBool, "sandbox should be a boolean value, got %T", sandboxVal)
|
||||||
|
t.Logf("sandbox field correctly returned as bool: %v", sandboxVal)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
t.Run("ListAssistantsWithSelectFields", func(t *testing.T) {
|
t.Run("ListAssistantsWithSelectFields", func(t *testing.T) {
|
||||||
// Test with select parameter to limit returned fields
|
// Test with select parameter to limit returned fields
|
||||||
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?select=assistant_id,name,avatar,type", nil)
|
req, err := http.NewRequest("GET", serverURL+baseURL+"/agent/assistants?select=assistant_id,name,avatar,type", nil)
|
||||||
|
|
@ -1413,6 +1474,10 @@ func TestGetAssistantResponseStructure(t *testing.T) {
|
||||||
assert.Contains(t, responseAssistant, "name", "Assistant should have name")
|
assert.Contains(t, responseAssistant, "name", "Assistant should have name")
|
||||||
assert.Contains(t, responseAssistant, "type", "Assistant should have type")
|
assert.Contains(t, responseAssistant, "type", "Assistant should have type")
|
||||||
|
|
||||||
|
// Verify capabilities field is present in response (may be empty/null)
|
||||||
|
// capabilities is a default field that should always be returned
|
||||||
|
t.Logf("capabilities field value: %v", responseAssistant["capabilities"])
|
||||||
|
|
||||||
responseAssistantID := responseAssistant["assistant_id"].(string)
|
responseAssistantID := responseAssistant["assistant_id"].(string)
|
||||||
t.Logf("Response structure is correct for assistant: %s", responseAssistantID)
|
t.Logf("Response structure is correct for assistant: %s", responseAssistantID)
|
||||||
})
|
})
|
||||||
|
|
|
||||||
|
|
@ -58,13 +58,14 @@ func handleStreamMode(c *gin.Context, manager types.Manager, info *types.TraceIn
|
||||||
c.Header("X-Accel-Buffering", "no")
|
c.Header("X-Accel-Buffering", "no")
|
||||||
|
|
||||||
// Subscribe to trace updates
|
// Subscribe to trace updates
|
||||||
updates, err := manager.Subscribe()
|
updates, cancel, err := manager.Subscribe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
// Send error as SSE event
|
// Send error as SSE event
|
||||||
fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"Failed to subscribe: %s\"}\n\n", err.Error())
|
fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"Failed to subscribe: %s\"}\n\n", err.Error())
|
||||||
c.Writer.Flush()
|
c.Writer.Flush()
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Stream events
|
// Stream events
|
||||||
ctx := c.Request.Context()
|
ctx := c.Request.Context()
|
||||||
|
|
@ -73,22 +74,18 @@ func handleStreamMode(c *gin.Context, manager types.Manager, info *types.TraceIn
|
||||||
for {
|
for {
|
||||||
select {
|
select {
|
||||||
case <-clientGone:
|
case <-clientGone:
|
||||||
// Client disconnected
|
|
||||||
return
|
return
|
||||||
|
|
||||||
case update, ok := <-updates:
|
case update, ok := <-updates:
|
||||||
if !ok {
|
if !ok {
|
||||||
// Channel closed
|
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Format and send SSE event
|
|
||||||
err := sendSSEEvent(c.Writer, *update)
|
err := sendSSEEvent(c.Writer, *update)
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if trace is complete
|
|
||||||
if update.Type == types.UpdateTypeComplete {
|
if update.Type == types.UpdateTypeComplete {
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ package trace
|
||||||
|
|
||||||
import (
|
import (
|
||||||
"fmt"
|
"fmt"
|
||||||
|
"sync"
|
||||||
|
|
||||||
"github.com/yaoapp/yao/event"
|
"github.com/yaoapp/yao/event"
|
||||||
eventTypes "github.com/yaoapp/yao/event/types"
|
eventTypes "github.com/yaoapp/yao/event/types"
|
||||||
|
|
@ -12,13 +13,16 @@ func dedupKey(u *types.TraceUpdate) string {
|
||||||
return fmt.Sprintf("%s:%s:%d", u.Type, u.NodeID, u.Timestamp)
|
return fmt.Sprintf("%s:%s:%d", u.Type, u.NodeID, u.Timestamp)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe creates a new subscription for trace updates (replays all historical events from the beginning)
|
// Subscribe creates a new subscription for trace updates (replays all historical events from the beginning).
|
||||||
func (m *manager) Subscribe() (<-chan *types.TraceUpdate, error) {
|
// Returns the update channel and a cancel function. The caller MUST call
|
||||||
|
// cancel when done (e.g., client disconnect) to release the goroutine.
|
||||||
|
func (m *manager) Subscribe() (<-chan *types.TraceUpdate, func(), error) {
|
||||||
return m.subscribe(0)
|
return m.subscribe(0)
|
||||||
}
|
}
|
||||||
|
|
||||||
// SubscribeFrom creates a subscription starting from a specific timestamp
|
// SubscribeFrom creates a subscription starting from a specific timestamp.
|
||||||
func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, error) {
|
// Returns the update channel and a cancel function.
|
||||||
|
func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, func(), error) {
|
||||||
return m.subscribe(since)
|
return m.subscribe(since)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -26,12 +30,14 @@ func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, error)
|
||||||
// updates, then streams live events via the event service's Subscriber.
|
// updates, then streams live events via the event service's Subscriber.
|
||||||
// The subscriber is registered BEFORE reading historical state to prevent
|
// The subscriber is registered BEFORE reading historical state to prevent
|
||||||
// missing events that occur between the state snapshot and subscriber setup.
|
// missing events that occur between the state snapshot and subscriber setup.
|
||||||
func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) {
|
//
|
||||||
|
// The returned cancel function triggers event.Unsubscribe which closes
|
||||||
|
// liveCh, causing the goroutine to exit via `for range liveCh`.
|
||||||
|
func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, func(), error) {
|
||||||
bufferSize := 1000
|
bufferSize := 1000
|
||||||
|
|
||||||
out := make(chan *types.TraceUpdate, bufferSize)
|
out := make(chan *types.TraceUpdate, bufferSize)
|
||||||
|
|
||||||
// Register live subscriber FIRST to avoid missing events between snapshot and subscribe.
|
|
||||||
liveCh := make(chan *eventTypes.Event, bufferSize)
|
liveCh := make(chan *eventTypes.Event, bufferSize)
|
||||||
traceID := m.traceID
|
traceID := m.traceID
|
||||||
subID := event.Subscribe("trace.*", liveCh, event.Filter(func(ev *eventTypes.Event) bool {
|
subID := event.Subscribe("trace.*", liveCh, event.Filter(func(ev *eventTypes.Event) bool {
|
||||||
|
|
@ -42,19 +48,23 @@ func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) {
|
||||||
return update.TraceID == traceID
|
return update.TraceID == traceID
|
||||||
}))
|
}))
|
||||||
|
|
||||||
// THEN snapshot historical updates (may overlap with live events).
|
|
||||||
historical := m.stateGetUpdates(since)
|
historical := m.stateGetUpdates(since)
|
||||||
|
|
||||||
// Build a set of historical event identifiers for dedup.
|
|
||||||
// Key: "type:nodeID:timestamp" is unique enough for trace events.
|
|
||||||
histSeen := make(map[string]struct{}, len(historical))
|
histSeen := make(map[string]struct{}, len(historical))
|
||||||
for _, u := range historical {
|
for _, u := range historical {
|
||||||
histSeen[dedupKey(u)] = struct{}{}
|
histSeen[dedupKey(u)] = struct{}{}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
var cancelOnce sync.Once
|
||||||
|
cancel := func() {
|
||||||
|
cancelOnce.Do(func() {
|
||||||
|
event.Unsubscribe(subID)
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
go func() {
|
go func() {
|
||||||
defer close(out)
|
defer close(out)
|
||||||
defer event.Unsubscribe(subID)
|
defer cancel()
|
||||||
|
|
||||||
for _, update := range historical {
|
for _, update := range historical {
|
||||||
out <- update
|
out <- update
|
||||||
|
|
@ -77,5 +87,5 @@ func (m *manager) subscribe(since int64) (<-chan *types.TraceUpdate, error) {
|
||||||
}
|
}
|
||||||
}()
|
}()
|
||||||
|
|
||||||
return out, nil
|
return out, cancel, nil
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -331,8 +331,9 @@ func TestAutoCompleteParentEvents(t *testing.T) {
|
||||||
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
// Subscribe to updates
|
// Subscribe to updates
|
||||||
updates, err := manager.Subscribe()
|
updates, cancel, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Collect updates in background
|
// Collect updates in background
|
||||||
var receivedUpdates []*types.TraceUpdate
|
var receivedUpdates []*types.TraceUpdate
|
||||||
|
|
|
||||||
|
|
@ -266,7 +266,7 @@ func BenchmarkSubscription(b *testing.B) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe
|
// Subscribe
|
||||||
updates, err := manager.Subscribe()
|
updates, cancel, err := manager.Subscribe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
b.Fatalf("Failed to subscribe: %s", err.Error())
|
b.Fatalf("Failed to subscribe: %s", err.Error())
|
||||||
}
|
}
|
||||||
|
|
@ -301,6 +301,7 @@ func BenchmarkSubscription(b *testing.B) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
trace.Release(traceID)
|
trace.Release(traceID)
|
||||||
trace.Remove(ctx, trace.Local, traceID)
|
trace.Remove(ctx, trace.Local, traceID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -179,6 +179,7 @@ func TestConcurrentSubscribers(t *testing.T) {
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
numSubscribers := 5
|
numSubscribers := 5
|
||||||
subscribers := make([]<-chan *types.TraceUpdate, numSubscribers)
|
subscribers := make([]<-chan *types.TraceUpdate, numSubscribers)
|
||||||
|
cancels := make([]func(), numSubscribers)
|
||||||
var mu sync.Mutex
|
var mu sync.Mutex
|
||||||
|
|
||||||
for i := 0; i < numSubscribers; i++ {
|
for i := 0; i < numSubscribers; i++ {
|
||||||
|
|
@ -186,14 +187,22 @@ func TestConcurrentSubscribers(t *testing.T) {
|
||||||
go func(idx int) {
|
go func(idx int) {
|
||||||
defer wg.Done()
|
defer wg.Done()
|
||||||
|
|
||||||
sub, err := manager.Subscribe()
|
sub, cancelSub, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
|
||||||
mu.Lock()
|
mu.Lock()
|
||||||
subscribers[idx] = sub
|
subscribers[idx] = sub
|
||||||
|
cancels[idx] = cancelSub
|
||||||
mu.Unlock()
|
mu.Unlock()
|
||||||
}(i)
|
}(i)
|
||||||
}
|
}
|
||||||
|
defer func() {
|
||||||
|
for _, c := range cancels {
|
||||||
|
if c != nil {
|
||||||
|
c()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}()
|
||||||
wg.Wait()
|
wg.Wait()
|
||||||
|
|
||||||
// Verify all subscriptions were created
|
// Verify all subscriptions were created
|
||||||
|
|
|
||||||
|
|
@ -259,10 +259,11 @@ func TestMemoryLeakComplexScenarios(t *testing.T) {
|
||||||
{
|
{
|
||||||
name: "WithSubscription",
|
name: "WithSubscription",
|
||||||
execute: func(m types.Manager) error {
|
execute: func(m types.Manager) error {
|
||||||
updates, err := m.Subscribe()
|
updates, cancel, err := m.Subscribe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
return err
|
return err
|
||||||
}
|
}
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Drain updates in background with timeout
|
// Drain updates in background with timeout
|
||||||
done := make(chan bool)
|
done := make(chan bool)
|
||||||
|
|
@ -563,7 +564,7 @@ func TestGoroutineLeak(t *testing.T) {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Subscribe (creates goroutines)
|
// Subscribe (creates goroutines)
|
||||||
updates, err := manager.Subscribe()
|
updates, cancel, err := manager.Subscribe()
|
||||||
if err != nil {
|
if err != nil {
|
||||||
t.Errorf("Subscribe failed at iteration %d: %s", i, err.Error())
|
t.Errorf("Subscribe failed at iteration %d: %s", i, err.Error())
|
||||||
}
|
}
|
||||||
|
|
@ -598,6 +599,7 @@ func TestGoroutineLeak(t *testing.T) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
cancel()
|
||||||
trace.Release(traceID)
|
trace.Release(traceID)
|
||||||
trace.Remove(ctx, trace.Local, traceID)
|
trace.Remove(ctx, trace.Local, traceID)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
157
trace/trace_subscription_leak_test.go
Normal file
157
trace/trace_subscription_leak_test.go
Normal file
|
|
@ -0,0 +1,157 @@
|
||||||
|
package trace_test
|
||||||
|
|
||||||
|
import (
|
||||||
|
"context"
|
||||||
|
"runtime"
|
||||||
|
"testing"
|
||||||
|
"time"
|
||||||
|
|
||||||
|
"github.com/stretchr/testify/assert"
|
||||||
|
"github.com/yaoapp/yao/event"
|
||||||
|
"github.com/yaoapp/yao/trace"
|
||||||
|
"github.com/yaoapp/yao/trace/types"
|
||||||
|
)
|
||||||
|
|
||||||
|
// stableGoroutines waits for runtime to settle and returns goroutine count.
|
||||||
|
func stableGoroutines() int {
|
||||||
|
for i := 0; i < 5; i++ {
|
||||||
|
runtime.GC()
|
||||||
|
runtime.Gosched()
|
||||||
|
time.Sleep(10 * time.Millisecond)
|
||||||
|
}
|
||||||
|
return runtime.NumGoroutine()
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLeak_SubscriptionClientDisconnect reproduces the goroutine leak that
|
||||||
|
// occurs when an SSE client subscribes to a trace and then disconnects
|
||||||
|
// without the trace ever completing (no UpdateTypeComplete sent).
|
||||||
|
//
|
||||||
|
// The subscription goroutine in subscription.go blocks on
|
||||||
|
// `for ev := range liveCh` and never exits because:
|
||||||
|
// 1. liveCh is never closed (event.Unsubscribe only deletes the map entry)
|
||||||
|
// 2. The goroutine only returns on UpdateTypeComplete
|
||||||
|
// 3. No context/cancellation mechanism exists
|
||||||
|
//
|
||||||
|
// This simulates the real-world scenario: SSE handler returns on client
|
||||||
|
// disconnect, but the subscription goroutine keeps running forever.
|
||||||
|
func TestLeak_SubscriptionClientDisconnect(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
before := stableGoroutines()
|
||||||
|
|
||||||
|
const numClients = 10
|
||||||
|
|
||||||
|
for i := 0; i < numClients; i++ {
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Client subscribes (like SSE handler calling manager.Subscribe())
|
||||||
|
updates, cancel, err := manager.Subscribe()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
assert.NotNil(t, updates)
|
||||||
|
|
||||||
|
// Simulate some trace activity
|
||||||
|
_, err = manager.Add("step", types.TraceNodeOption{Label: "Processing"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Read a couple of events (like the SSE handler would)
|
||||||
|
timeout := time.After(500 * time.Millisecond)
|
||||||
|
drain:
|
||||||
|
for {
|
||||||
|
select {
|
||||||
|
case _, ok := <-updates:
|
||||||
|
if !ok {
|
||||||
|
break drain
|
||||||
|
}
|
||||||
|
case <-timeout:
|
||||||
|
break drain
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Client disconnects: SSE handler calls cancel (deferred).
|
||||||
|
// This triggers event.Unsubscribe which closes liveCh,
|
||||||
|
// allowing the subscription goroutine to exit.
|
||||||
|
cancel()
|
||||||
|
|
||||||
|
trace.Release(traceID)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Wait for goroutines to settle
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
after := stableGoroutines()
|
||||||
|
|
||||||
|
leaked := after - before
|
||||||
|
t.Logf("goroutines: before=%d after=%d leaked=%d (over %d simulated client disconnects)", before, after, leaked, numClients)
|
||||||
|
|
||||||
|
// Each Subscribe() spawns a goroutine that should eventually exit.
|
||||||
|
// If it doesn't, we'll see roughly numClients leaked goroutines.
|
||||||
|
if leaked >= numClients {
|
||||||
|
t.Errorf("goroutine leak detected: %d goroutines leaked after %d client disconnects. "+
|
||||||
|
"Subscription goroutines are not cleaned up when clients disconnect without trace completion.",
|
||||||
|
leaked, numClients)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// TestLeak_SubscriptionEventServiceStop reproduces the goroutine leak when
|
||||||
|
// event.Stop() is called (e.g., during shutdown) while subscriptions are active.
|
||||||
|
//
|
||||||
|
// event.Stop() calls smgr.clear() which deletes all subscriber entries but
|
||||||
|
// does NOT close their channels, leaving goroutines blocked on `range liveCh`.
|
||||||
|
func TestLeak_SubscriptionEventServiceStop(t *testing.T) {
|
||||||
|
drivers := trace.GetTestDrivers()
|
||||||
|
|
||||||
|
for _, d := range drivers {
|
||||||
|
t.Run(d.Name, func(t *testing.T) {
|
||||||
|
ctx := context.Background()
|
||||||
|
|
||||||
|
before := stableGoroutines()
|
||||||
|
|
||||||
|
const numSubs = 5
|
||||||
|
|
||||||
|
traceID, manager, err := trace.New(ctx, d.DriverType, nil, d.DriverOptions...)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Create multiple subscriptions (simulating multiple SSE clients)
|
||||||
|
for i := 0; i < numSubs; i++ {
|
||||||
|
_, _, err := manager.Subscribe()
|
||||||
|
assert.NoError(t, err)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Simulate some activity
|
||||||
|
_, err = manager.Add("work", types.TraceNodeOption{Label: "Working"})
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
time.Sleep(200 * time.Millisecond)
|
||||||
|
|
||||||
|
// Stop event service (like during server shutdown)
|
||||||
|
err = event.Stop(ctx)
|
||||||
|
assert.NoError(t, err)
|
||||||
|
|
||||||
|
// Restart event service for other tests
|
||||||
|
err = event.Start()
|
||||||
|
if err != nil && err != event.ErrAlreadyStart {
|
||||||
|
t.Fatalf("Failed to restart event service: %v", err)
|
||||||
|
}
|
||||||
|
|
||||||
|
trace.Release(traceID)
|
||||||
|
|
||||||
|
time.Sleep(1 * time.Second)
|
||||||
|
after := stableGoroutines()
|
||||||
|
|
||||||
|
leaked := after - before
|
||||||
|
t.Logf("goroutines: before=%d after=%d leaked=%d (over %d subscriptions + event.Stop)", before, after, leaked, numSubs)
|
||||||
|
|
||||||
|
if leaked >= numSubs {
|
||||||
|
t.Errorf("goroutine leak detected: %d goroutines leaked after event.Stop() with %d active subscriptions. "+
|
||||||
|
"smgr.clear() does not close subscriber channels.",
|
||||||
|
leaked, numSubs)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -24,9 +24,10 @@ func TestSubscription(t *testing.T) {
|
||||||
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
// Subscribe to updates
|
// Subscribe to updates
|
||||||
updates, err := manager.Subscribe()
|
updates, cancel, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, updates)
|
assert.NotNil(t, updates)
|
||||||
|
defer cancel()
|
||||||
|
|
||||||
// Collect updates in background
|
// Collect updates in background
|
||||||
var receivedUpdates []*types.TraceUpdate
|
var receivedUpdates []*types.TraceUpdate
|
||||||
|
|
@ -146,9 +147,10 @@ func TestSubscribeFrom(t *testing.T) {
|
||||||
|
|
||||||
// Real scenario: User refreshes page and resumes from last known timestamp
|
// Real scenario: User refreshes page and resumes from last known timestamp
|
||||||
// This should replay events from resumeTimestamp onwards
|
// This should replay events from resumeTimestamp onwards
|
||||||
updates, err := manager.SubscribeFrom(resumeTimestamp)
|
updates, cancelSub, err := manager.SubscribeFrom(resumeTimestamp)
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
assert.NotNil(t, updates)
|
assert.NotNil(t, updates)
|
||||||
|
defer cancelSub()
|
||||||
|
|
||||||
// Collect updates
|
// Collect updates
|
||||||
var receivedUpdates []*types.TraceUpdate
|
var receivedUpdates []*types.TraceUpdate
|
||||||
|
|
@ -233,14 +235,17 @@ func TestMultipleSubscribers(t *testing.T) {
|
||||||
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||||
|
|
||||||
// Create multiple subscribers
|
// Create multiple subscribers
|
||||||
sub1, err := manager.Subscribe()
|
sub1, cancel1, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
defer cancel1()
|
||||||
|
|
||||||
sub2, err := manager.Subscribe()
|
sub2, cancel2, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
defer cancel2()
|
||||||
|
|
||||||
sub3, err := manager.Subscribe()
|
sub3, cancel3, err := manager.Subscribe()
|
||||||
assert.NoError(t, err)
|
assert.NoError(t, err)
|
||||||
|
defer cancel3()
|
||||||
|
|
||||||
// Collect updates from all subscribers
|
// Collect updates from all subscribers
|
||||||
var wg sync.WaitGroup
|
var wg sync.WaitGroup
|
||||||
|
|
|
||||||
|
|
@ -48,10 +48,13 @@ type Manager interface {
|
||||||
MarkComplete() error
|
MarkComplete() error
|
||||||
|
|
||||||
// Subscription Operations
|
// Subscription Operations
|
||||||
// Subscribe subscribes to trace updates (replay history + real-time)
|
// Subscribe subscribes to trace updates (replay history + real-time).
|
||||||
Subscribe() (<-chan *TraceUpdate, error)
|
// Returns the update channel and a cancel function. The caller MUST call
|
||||||
// SubscribeFrom subscribes from a specific timestamp (for resume)
|
// cancel when done (e.g., client disconnect) to release the goroutine.
|
||||||
SubscribeFrom(since int64) (<-chan *TraceUpdate, error)
|
Subscribe() (<-chan *TraceUpdate, func(), error)
|
||||||
|
// SubscribeFrom subscribes from a specific timestamp (for resume).
|
||||||
|
// Returns the update channel and a cancel function.
|
||||||
|
SubscribeFrom(since int64) (<-chan *TraceUpdate, func(), error)
|
||||||
// IsComplete checks if the trace is completed
|
// IsComplete checks if the trace is completed
|
||||||
IsComplete() bool
|
IsComplete() bool
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -73,6 +73,15 @@
|
||||||
"nullable": true,
|
"nullable": true,
|
||||||
"index": true
|
"index": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "capabilities",
|
||||||
|
"type": "string",
|
||||||
|
"label": "Capabilities",
|
||||||
|
"comment": "Assistant capabilities description, useful for Robot orchestration",
|
||||||
|
"length": 600,
|
||||||
|
"nullable": true,
|
||||||
|
"index": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "path",
|
"name": "path",
|
||||||
"type": "string",
|
"type": "string",
|
||||||
|
|
@ -161,6 +170,13 @@
|
||||||
"comment": "MCP servers available for the assistant to use",
|
"comment": "MCP servers available for the assistant to use",
|
||||||
"nullable": true
|
"nullable": true
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
"name": "sandbox",
|
||||||
|
"type": "json",
|
||||||
|
"label": "Sandbox",
|
||||||
|
"comment": "Sandbox configuration for coding agents (command, image, timeout, etc.)",
|
||||||
|
"nullable": true
|
||||||
|
},
|
||||||
{
|
{
|
||||||
"name": "source",
|
"name": "source",
|
||||||
"type": "text",
|
"type": "text",
|
||||||
|
|
|
||||||
Loading…
Add table
Reference in a new issue