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"
|
||||
"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/context"
|
||||
"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
|
||||
// Note: opts.Connector may be set by Create hook's applyOptionsAdjustments
|
||||
// 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
|
||||
connectorID := ast.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
|
||||
}
|
||||
|
||||
// Get connector capabilities from settings
|
||||
// Uses unified capability getter: 1. User-defined models.yml, 2. connector's Setting()["capabilities"], 3. default
|
||||
capabilities := llm.GetCapabilitiesFromConn(conn, modelCapabilities)
|
||||
capabilities := llm.GetCapabilitiesFromConn(conn)
|
||||
|
||||
return conn, capabilities, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -9,7 +9,6 @@ import (
|
|||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/spf13/cast"
|
||||
"github.com/yaoapp/gou/application"
|
||||
gouOpenAI "github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/gou/fs"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
|
|
@ -21,8 +20,7 @@ import (
|
|||
// loaded the loaded assistant
|
||||
var loaded = NewCache(200) // 200 is the default capacity
|
||||
var storage store.Store = nil
|
||||
var storeSetting *store.Setting = nil // store setting from agent.yml
|
||||
var modelCapabilities map[string]gouOpenAI.Capabilities = map[string]gouOpenAI.Capabilities{}
|
||||
var storeSetting *store.Setting = nil // store setting from agent.yml
|
||||
var defaultConnector string = "" // default connector
|
||||
var globalUses *context.Uses = nil // global uses configuration from agent.yml
|
||||
var globalPrompts []store.Prompt = nil // global prompts from agent/prompts.yml
|
||||
|
|
@ -140,11 +138,6 @@ func GetStorage() store.Store {
|
|||
return storage
|
||||
}
|
||||
|
||||
// SetModelCapabilities set the model capabilities configuration
|
||||
func SetModelCapabilities(capabilities map[string]gouOpenAI.Capabilities) {
|
||||
modelCapabilities = capabilities
|
||||
}
|
||||
|
||||
// SetConnector set the connector
|
||||
func SetConnector(c string) {
|
||||
defaultConnector = c
|
||||
|
|
@ -564,6 +557,11 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
|||
assistant.Description = v
|
||||
}
|
||||
|
||||
// capabilities
|
||||
if v, ok := data["capabilities"].(string); ok {
|
||||
assistant.Capabilities = v
|
||||
}
|
||||
|
||||
// locales
|
||||
if locales, ok := data["locales"].(i18n.Map); ok {
|
||||
assistant.Locales = locales
|
||||
|
|
@ -575,30 +573,39 @@ func loadMap(data map[string]interface{}) (*Assistant, error) {
|
|||
if i18nObj.Messages == nil {
|
||||
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 != "" {
|
||||
i18nObj.Messages["name"] = assistant.Name
|
||||
}
|
||||
if _, exists := i18nObj.Messages["description"]; !exists && assistant.Description != "" {
|
||||
i18nObj.Messages["description"] = assistant.Description
|
||||
}
|
||||
if _, exists := i18nObj.Messages["capabilities"]; !exists && assistant.Capabilities != "" {
|
||||
i18nObj.Messages["capabilities"] = assistant.Capabilities
|
||||
}
|
||||
flattened[locale] = i18nObj
|
||||
}
|
||||
|
||||
i18n.Locales[id] = flattened
|
||||
} else {
|
||||
// No locales defined, create default with name and description for all common locales
|
||||
if assistant.Name != "" || assistant.Description != "" {
|
||||
// No locales defined, create default with name, description, and capabilities for all common locales
|
||||
if assistant.Name != "" || assistant.Description != "" || assistant.Capabilities != "" {
|
||||
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"}
|
||||
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{
|
||||
Locale: locale,
|
||||
Messages: map[string]any{
|
||||
"name": assistant.Name,
|
||||
"description": assistant.Description,
|
||||
},
|
||||
Locale: locale,
|
||||
Messages: messages,
|
||||
}
|
||||
}
|
||||
i18n.Locales[id] = defaultLocales
|
||||
|
|
|
|||
|
|
@ -256,20 +256,11 @@ func resolveSystemConnector(agentID string) string {
|
|||
|
||||
// findCapableConnector finds the first connector that supports tool calling
|
||||
func findCapableConnector() string {
|
||||
// Get all registered connectors
|
||||
for id, conn := range connector.Connectors {
|
||||
if !conn.Is(connector.OPENAI) {
|
||||
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.Options.Capabilities != nil && connOpenAI.Options.Capabilities.ToolCalls {
|
||||
return id
|
||||
|
|
@ -277,7 +268,6 @@ func findCapableConnector() string {
|
|||
}
|
||||
}
|
||||
|
||||
// No capable connector found, return empty
|
||||
return ""
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -181,8 +181,8 @@ func TestHasSandboxMethod(t *testing.T) {
|
|||
require.NoError(t, err)
|
||||
assert.True(t, astWithSandbox.HasSandbox(), "Assistant with sandbox config should return true")
|
||||
|
||||
// Test assistant without sandbox (fullfields doesn't have sandbox)
|
||||
astWithoutSandbox, err := assistant.LoadPath("/assistants/tests/fullfields")
|
||||
// Test assistant without sandbox
|
||||
astWithoutSandbox, err := assistant.LoadPath("/assistants/tests/simple-greeting")
|
||||
require.NoError(t, err)
|
||||
assert.False(t, astWithoutSandbox.HasSandbox(), "Assistant without sandbox config should return false")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package context
|
|||
import (
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/gou/llm"
|
||||
"github.com/yaoapp/yao/agent/output"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
)
|
||||
|
|
@ -571,9 +571,8 @@ func (ctx *Context) getOutput() (*output.Output, error) {
|
|||
Accept: string(ctx.Accept),
|
||||
}
|
||||
|
||||
// Set ModelCapabilities (now using openai.Capabilities directly)
|
||||
if ctx.Capabilities != nil {
|
||||
caps := openai.Capabilities(*ctx.Capabilities)
|
||||
caps := llm.Capabilities(*ctx.Capabilities)
|
||||
options.Capabilities = &caps
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -5,7 +5,7 @@ import (
|
|||
"sync"
|
||||
"time"
|
||||
|
||||
"github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/gou/llm"
|
||||
"github.com/yaoapp/gou/store"
|
||||
"github.com/yaoapp/yao/agent/memory"
|
||||
"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)
|
||||
|
||||
// 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 *InterruptController `json:"-"` // Interrupt controller for handling user interrupts during streaming
|
||||
|
|
|
|||
|
|
@ -1,7 +1,7 @@
|
|||
package context
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/gou/llm"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
)
|
||||
|
||||
|
|
@ -38,7 +38,7 @@ const (
|
|||
)
|
||||
|
||||
// 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 {
|
||||
return false, VisionFormatNone
|
||||
}
|
||||
|
|
@ -70,7 +70,7 @@ func GetVisionSupport(cap *openai.Capabilities) (bool, VisionFormat) {
|
|||
type CompletionOptions struct {
|
||||
// Model capabilities (used by LLM to select appropriate provider)
|
||||
// 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
|
||||
Uses *Uses `json:"uses,omitempty"`
|
||||
|
|
|
|||
|
|
@ -2,92 +2,48 @@ package llm
|
|||
|
||||
import (
|
||||
"github.com/yaoapp/gou/connector"
|
||||
"github.com/yaoapp/gou/connector/anthropic"
|
||||
"github.com/yaoapp/gou/connector/openai"
|
||||
goullm "github.com/yaoapp/gou/llm"
|
||||
)
|
||||
|
||||
// GetCapabilities get the capabilities of a connector by connector ID
|
||||
// This is a unified function to get connector capabilities with proper priority:
|
||||
// 1. User-defined model capabilities from agent/models.yml (passed via modelCapabilities map)
|
||||
// 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 {
|
||||
// Reads capabilities from connector's Setting()["capabilities"], with fallback to defaults.
|
||||
func GetCapabilities(connectorID string) *goullm.Capabilities {
|
||||
if connectorID == "" {
|
||||
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)
|
||||
if err != nil {
|
||||
// If connector not found, return default
|
||||
return getDefaultCapabilities()
|
||||
}
|
||||
|
||||
return GetCapabilitiesFromConn(conn, modelCapabilities)
|
||||
return GetCapabilitiesFromConn(conn)
|
||||
}
|
||||
|
||||
// GetCapabilitiesFromConn get the capabilities from a connector instance
|
||||
// This is useful when you already have the connector object
|
||||
func GetCapabilitiesFromConn(conn connector.Connector, modelCapabilities map[string]openai.Capabilities) *openai.Capabilities {
|
||||
func GetCapabilitiesFromConn(conn connector.Connector) *goullm.Capabilities {
|
||||
if conn == nil {
|
||||
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()
|
||||
if settings != nil {
|
||||
if caps, ok := settings["capabilities"]; ok {
|
||||
// Try to convert to *openai.Capabilities
|
||||
if capabilities, ok := caps.(*openai.Capabilities); ok {
|
||||
if capabilities, ok := caps.(*goullm.Capabilities); ok {
|
||||
return capabilities
|
||||
}
|
||||
// Try to convert to openai.Capabilities (value type)
|
||||
if capabilities, ok := caps.(openai.Capabilities); ok {
|
||||
if capabilities, ok := caps.(goullm.Capabilities); ok {
|
||||
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()
|
||||
}
|
||||
|
||||
// getDefaultCapabilities returns minimal default capabilities
|
||||
// This should rarely be used as modern connectors provide capabilities via Setting()
|
||||
func getDefaultCapabilities() *openai.Capabilities {
|
||||
return &openai.Capabilities{
|
||||
func getDefaultCapabilities() *goullm.Capabilities {
|
||||
return &goullm.Capabilities{
|
||||
Vision: false,
|
||||
ToolCalls: false,
|
||||
Audio: false,
|
||||
|
|
@ -95,14 +51,13 @@ func getDefaultCapabilities() *openai.Capabilities {
|
|||
Streaming: false,
|
||||
JSON: false,
|
||||
Multimodal: false,
|
||||
TemperatureAdjustable: true, // Default to true for non-reasoning models
|
||||
TemperatureAdjustable: true,
|
||||
}
|
||||
}
|
||||
|
||||
// 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, modelCapabilities map[string]openai.Capabilities) map[string]interface{} {
|
||||
caps := GetCapabilities(connectorID, modelCapabilities)
|
||||
func GetCapabilitiesMap(connectorID string) map[string]interface{} {
|
||||
caps := GetCapabilities(connectorID)
|
||||
if caps == nil {
|
||||
return nil
|
||||
}
|
||||
|
|
@ -110,16 +65,14 @@ func GetCapabilitiesMap(connectorID string, modelCapabilities map[string]openai.
|
|||
return ToMap(caps)
|
||||
}
|
||||
|
||||
// ToMap converts openai.Capabilities to map[string]interface{}
|
||||
// This is useful for JSON serialization in API responses
|
||||
func ToMap(caps *openai.Capabilities) map[string]interface{} {
|
||||
// ToMap converts Capabilities to map[string]interface{}
|
||||
func ToMap(caps *goullm.Capabilities) map[string]interface{} {
|
||||
if caps == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
result := make(map[string]interface{})
|
||||
|
||||
// Handle Vision field specially as it can be bool or string
|
||||
if caps.Vision != nil {
|
||||
result["vision"] = caps.Vision
|
||||
}
|
||||
|
|
@ -135,22 +88,3 @@ func ToMap(caps *openai.Capabilities) map[string]interface{} {
|
|||
|
||||
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
|
||||
func buildCompletionOptions(conn connector.Connector, opts map[string]interface{}) *agentContext.CompletionOptions {
|
||||
// Get capabilities from connector
|
||||
capabilities := GetCapabilitiesFromConn(conn, nil)
|
||||
capabilities := GetCapabilitiesFromConn(conn)
|
||||
|
||||
completionOptions := &agentContext.CompletionOptions{
|
||||
Capabilities: capabilities,
|
||||
|
|
@ -737,12 +737,6 @@ func (api *JSAPI) executeRace(requests []*Request) []interface{} {
|
|||
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
|
||||
// This is used by batch operations (All/Any/Race) to avoid race conditions
|
||||
// when multiple goroutines access shared context state
|
||||
|
|
|
|||
|
|
@ -9,9 +9,8 @@ import (
|
|||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/connector"
|
||||
gouAnthropicConn "github.com/yaoapp/gou/connector/anthropic"
|
||||
gouOpenAI "github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/gou/http"
|
||||
goullm "github.com/yaoapp/gou/llm"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
|
|
@ -27,34 +26,15 @@ type Provider struct {
|
|||
}
|
||||
|
||||
// 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{
|
||||
Provider: base.NewProvider(conn, 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
|
||||
func buildAdapters(cap *gouOpenAI.Capabilities) []adapters.CapabilityAdapter {
|
||||
func buildAdapters(cap *goullm.Capabilities) []adapters.CapabilityAdapter {
|
||||
if cap == nil {
|
||||
return []adapters.CapabilityAdapter{}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,7 @@ import (
|
|||
"testing"
|
||||
|
||||
"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/llm"
|
||||
"github.com/yaoapp/yao/agent/output/message"
|
||||
|
|
@ -34,9 +34,8 @@ func TestAnthropicStreamBasic(t *testing.T) {
|
|||
t.Fatal("Connector is not ANTHROPIC type")
|
||||
}
|
||||
|
||||
// Use openai.Capabilities — SelectProvider auto-detects Anthropic format from connector type
|
||||
options := &context.CompletionOptions{
|
||||
Capabilities: &openai.Capabilities{
|
||||
Capabilities: &goullm.Capabilities{
|
||||
Streaming: true,
|
||||
ToolCalls: true,
|
||||
},
|
||||
|
|
@ -111,7 +110,7 @@ func TestAnthropicStreamWithToolCalls(t *testing.T) {
|
|||
}
|
||||
|
||||
options := &context.CompletionOptions{
|
||||
Capabilities: &openai.Capabilities{
|
||||
Capabilities: &goullm.Capabilities{
|
||||
Streaming: true,
|
||||
ToolCalls: true,
|
||||
},
|
||||
|
|
@ -226,7 +225,7 @@ func TestAnthropicStreamRetry(t *testing.T) {
|
|||
}
|
||||
|
||||
options := &context.CompletionOptions{
|
||||
Capabilities: &openai.Capabilities{
|
||||
Capabilities: &goullm.Capabilities{
|
||||
Streaming: true,
|
||||
ToolCalls: true,
|
||||
},
|
||||
|
|
|
|||
|
|
@ -4,7 +4,7 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/connector"
|
||||
"github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/gou/llm"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
)
|
||||
|
||||
|
|
@ -12,11 +12,11 @@ import (
|
|||
// Provides common functionality for all LLM providers
|
||||
type Provider struct {
|
||||
Connector connector.Connector
|
||||
Capabilities *openai.Capabilities
|
||||
Capabilities *llm.Capabilities
|
||||
}
|
||||
|
||||
// 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{
|
||||
Connector: conn,
|
||||
Capabilities: capabilities,
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import (
|
|||
"fmt"
|
||||
|
||||
"github.com/yaoapp/gou/connector"
|
||||
gouAnthropicConn "github.com/yaoapp/gou/connector/anthropic"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"github.com/yaoapp/yao/agent/llm/providers/anthropic"
|
||||
"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
|
||||
|
||||
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
|
||||
|
||||
default:
|
||||
|
|
|
|||
|
|
@ -8,8 +8,8 @@ import (
|
|||
|
||||
jsoniter "github.com/json-iterator/go"
|
||||
"github.com/yaoapp/gou/connector"
|
||||
gouOpenAI "github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/gou/http"
|
||||
goullm "github.com/yaoapp/gou/llm"
|
||||
"github.com/yaoapp/kun/log"
|
||||
"github.com/yaoapp/yao/agent/context"
|
||||
"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
|
||||
func New(conn connector.Connector, capabilities *gouOpenAI.Capabilities) *Provider {
|
||||
func New(conn connector.Connector, capabilities *goullm.Capabilities) *Provider {
|
||||
return &Provider{
|
||||
Provider: base.NewProvider(conn, 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
|
||||
func buildAdapters(cap *gouOpenAI.Capabilities) []adapters.CapabilityAdapter {
|
||||
func buildAdapters(cap *goullm.Capabilities) []adapters.CapabilityAdapter {
|
||||
if cap == nil {
|
||||
return []adapters.CapabilityAdapter{}
|
||||
}
|
||||
|
|
@ -182,7 +182,7 @@ func buildAdapters(cap *gouOpenAI.Capabilities) []adapters.CapabilityAdapter {
|
|||
}
|
||||
|
||||
// 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
|
||||
// For now, default to OpenAI o1 format if reasoning is supported
|
||||
if cap.Reasoning {
|
||||
|
|
|
|||
|
|
@ -6,7 +6,7 @@ import (
|
|||
|
||||
"github.com/yaoapp/gou/application"
|
||||
"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/context"
|
||||
"github.com/yaoapp/yao/agent/i18n"
|
||||
|
|
@ -47,6 +47,9 @@ func Load(cfg config.Config) error {
|
|||
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
|
||||
if setting.Uses == nil {
|
||||
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
|
||||
}
|
||||
|
||||
// Initialize model capabilities
|
||||
err = initModelCapabilities()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
// Initialize Global I18n
|
||||
err = initGlobalI18n()
|
||||
if err != nil {
|
||||
|
|
@ -148,29 +145,6 @@ func GetGlobalPrompts(ctx map[string]string) []store.Prompt {
|
|||
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
|
||||
func initStore() error {
|
||||
|
||||
|
|
@ -231,10 +205,6 @@ func initAssistant() error {
|
|||
assistant.SetGlobalPrompts(agentDSL.GlobalPrompts)
|
||||
}
|
||||
|
||||
if agentDSL.Models != nil {
|
||||
assistant.SetModelCapabilities(agentDSL.Models)
|
||||
}
|
||||
|
||||
if agentDSL.KB != nil {
|
||||
assistant.SetGlobalKBSetting(agentDSL.KB)
|
||||
}
|
||||
|
|
@ -478,3 +448,36 @@ func defaultAssistant() (*assistant.Assistant, error) {
|
|||
}
|
||||
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/require"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
"github.com/yaoapp/yao/agent/types"
|
||||
"github.com/yaoapp/yao/config"
|
||||
"github.com/yaoapp/yao/test"
|
||||
)
|
||||
|
|
@ -54,12 +55,6 @@ func TestLoad(t *testing.T) {
|
|||
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) {
|
||||
// KB configuration should be loaded from agent/kb.yml
|
||||
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) {
|
||||
prepare(t)
|
||||
defer test.Clean()
|
||||
|
|
|
|||
|
|
@ -3,7 +3,7 @@ package message
|
|||
import (
|
||||
"net/http"
|
||||
|
||||
"github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/gou/llm"
|
||||
traceTypes "github.com/yaoapp/yao/trace/types"
|
||||
)
|
||||
|
||||
|
|
@ -13,7 +13,7 @@ type Options struct {
|
|||
Accept string
|
||||
Writer http.ResponseWriter
|
||||
Trace traceTypes.Manager
|
||||
Capabilities *openai.Capabilities
|
||||
Capabilities *llm.Capabilities
|
||||
Locale string
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -291,6 +291,9 @@ func ToAssistantModel(v interface{}) (*AssistantModel, error) {
|
|||
if description, ok := data["description"].(string); ok {
|
||||
model.Description = description
|
||||
}
|
||||
if capabilities, ok := data["capabilities"].(string); ok {
|
||||
model.Capabilities = capabilities
|
||||
}
|
||||
if share, ok := data["share"].(string); ok {
|
||||
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
|
||||
if placeholder, ok := data["placeholder"]; ok && placeholder != nil {
|
||||
raw, err := jsoniter.Marshal(placeholder)
|
||||
|
|
|
|||
|
|
@ -20,10 +20,12 @@ var AssistantAllowedFields = map[string]bool{
|
|||
"prompts": true,
|
||||
"prompt_presets": true,
|
||||
"disable_global_prompts": true,
|
||||
"capabilities": true,
|
||||
"workflow": true,
|
||||
"kb": true,
|
||||
"db": true,
|
||||
"mcp": true,
|
||||
"sandbox": true,
|
||||
"source": true,
|
||||
"tags": true,
|
||||
"modes": true,
|
||||
|
|
@ -53,6 +55,7 @@ var AssistantDefaultFields = []string{
|
|||
"avatar",
|
||||
"connector",
|
||||
"description",
|
||||
"capabilities", // Capabilities description for Robot orchestration (lightweight)
|
||||
"tags", // Tags for categorization (lightweight)
|
||||
"modes", // Supported modes (lightweight)
|
||||
"default_mode", // Default mode (lightweight)
|
||||
|
|
@ -63,9 +66,10 @@ var AssistantDefaultFields = []string{
|
|||
"share",
|
||||
"automated",
|
||||
"mentionable",
|
||||
"kb", // Knowledge base configuration (lightweight)
|
||||
"db", // Database configuration (lightweight)
|
||||
"mcp", // MCP servers configuration (lightweight)
|
||||
"sandbox", // Sandbox configuration presence (lightweight)
|
||||
"kb", // Knowledge base configuration (lightweight)
|
||||
"db", // Database configuration (lightweight)
|
||||
"mcp", // MCP servers configuration (lightweight)
|
||||
"created_at",
|
||||
"updated_at",
|
||||
"__yao_created_by", // Permission: creator user ID
|
||||
|
|
@ -84,6 +88,7 @@ var AssistantFullFields = []string{
|
|||
"connector",
|
||||
"connector_options",
|
||||
"description",
|
||||
"capabilities",
|
||||
"path",
|
||||
"sort",
|
||||
"built_in",
|
||||
|
|
@ -96,6 +101,7 @@ var AssistantFullFields = []string{
|
|||
"kb",
|
||||
"db",
|
||||
"mcp",
|
||||
"sandbox",
|
||||
"source",
|
||||
"tags",
|
||||
"modes",
|
||||
|
|
|
|||
|
|
@ -183,6 +183,7 @@ type AssistantFilter struct {
|
|||
Mentionable *bool `json:"mentionable,omitempty"` // Filter by mentionable status
|
||||
Automated *bool `json:"automated,omitempty"` // Filter by automation 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
|
||||
PageSize int `json:"pagesize,omitempty"` // Items per page
|
||||
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
|
||||
Sort int `json:"sort,omitempty"` // Assistant Sort
|
||||
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
|
||||
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
|
||||
|
|
|
|||
|
|
@ -99,6 +99,11 @@ func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error)
|
|||
} else {
|
||||
data["description"] = nil
|
||||
}
|
||||
if assistant.Capabilities != "" {
|
||||
data["capabilities"] = assistant.Capabilities
|
||||
} else {
|
||||
data["capabilities"] = nil
|
||||
}
|
||||
if assistant.Path != "" {
|
||||
data["path"] = assistant.Path
|
||||
} else {
|
||||
|
|
@ -181,6 +186,7 @@ func (store *Xun) SaveAssistant(assistant *types.AssistantModel) (string, error)
|
|||
"db": assistant.DB,
|
||||
"mcp": assistant.MCP,
|
||||
"workflow": assistant.Workflow,
|
||||
"sandbox": assistant.Sandbox,
|
||||
"placeholder": assistant.Placeholder,
|
||||
"locales": assistant.Locales,
|
||||
"uses": assistant.Uses,
|
||||
|
|
@ -243,14 +249,14 @@ func (store *Xun) UpdateAssistant(assistantID string, updates map[string]interfa
|
|||
data := make(map[string]interface{})
|
||||
|
||||
// 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)
|
||||
for _, field := range jsonFields {
|
||||
jsonFieldSet[field] = true
|
||||
}
|
||||
|
||||
// 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)
|
||||
for _, field := range nullableStringFields {
|
||||
nullableFieldSet[field] = true
|
||||
|
|
@ -349,6 +355,7 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
|
|||
qb.Where(func(qb query.Query) {
|
||||
qb.Where("name", "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))
|
||||
})
|
||||
}
|
||||
|
|
@ -393,6 +400,21 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
|
|||
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)
|
||||
if filter.QueryFilter != nil {
|
||||
qb.Where(filter.QueryFilter)
|
||||
|
|
@ -448,7 +470,7 @@ func (store *Xun) GetAssistants(filter types.AssistantFilter, locale ...string)
|
|||
|
||||
// Convert rows to types.AssistantModel slice
|
||||
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 {
|
||||
data := row.ToMap()
|
||||
|
|
@ -521,7 +543,7 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
|
|||
}
|
||||
|
||||
// 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)
|
||||
|
||||
// Convert map to types.AssistantModel
|
||||
|
|
@ -536,6 +558,7 @@ func (store *Xun) GetAssistant(assistantID string, fields []string, locale ...st
|
|||
BuiltIn: getBool(data, "built_in"),
|
||||
Sort: getInt(data, "sort"),
|
||||
Description: getString(data, "description"),
|
||||
Capabilities: getString(data, "capabilities"),
|
||||
DefaultMode: getString(data, "default_mode"),
|
||||
Readonly: getBool(data, "readonly"),
|
||||
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 {
|
||||
raw, err := jsoniter.Marshal(placeholder)
|
||||
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
|
||||
if model.Prompts != nil {
|
||||
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.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) {
|
||||
// Test assistant with all new fields together
|
||||
optionalFalse := false
|
||||
|
|
|
|||
|
|
@ -168,9 +168,18 @@ func (store *Xun) getAssistantTable() string {
|
|||
func (store *Xun) parseJSONFields(data map[string]interface{}, fields []string) {
|
||||
for _, field := range fields {
|
||||
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{}
|
||||
if err := jsoniter.UnmarshalFromString(strVal, &parsed); err == nil {
|
||||
if err := jsoniter.UnmarshalFromString(jsonStr, &parsed); err == nil {
|
||||
data[field] = parsed
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,7 +1,6 @@
|
|||
package types
|
||||
|
||||
import (
|
||||
"github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/yao/agent/assistant"
|
||||
searchTypes "github.com/yaoapp/yao/agent/search/types"
|
||||
store "github.com/yaoapp/yao/agent/store/types"
|
||||
|
|
@ -23,11 +22,10 @@ type DSL struct {
|
|||
// If not set, fallback to the first connector that supports the required capabilities
|
||||
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
|
||||
Search *searchTypes.Config `json:"search,omitempty" yaml:"search,omitempty"` // The search configuration loaded from agent/search.yao
|
||||
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
|
||||
|
||||
// Internal
|
||||
// ===============================
|
||||
|
|
|
|||
1717
data/bindata.go
1717
data/bindata.go
File diff suppressed because one or more lines are too long
41
event/sub.go
41
event/sub.go
|
|
@ -54,14 +54,25 @@ func (sm *subManager) subscribe(pattern string, ch chan<- *types.Event, opts ...
|
|||
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) {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
entry, ok := 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).
|
||||
// Recovers from send-on-closed-channel panics that may occur if
|
||||
// unsubscribe closes a channel concurrently.
|
||||
func (sm *subManager) notify(ev *types.Event) {
|
||||
sm.mu.RLock()
|
||||
defer sm.mu.RUnlock()
|
||||
|
|
@ -73,19 +84,31 @@ func (sm *subManager) notify(ev *types.Event) {
|
|||
if entry.filter != nil && !entry.filter(ev) {
|
||||
continue
|
||||
}
|
||||
select {
|
||||
case entry.ch <- ev:
|
||||
default:
|
||||
// Subscriber chan full, skip (non-blocking)
|
||||
}
|
||||
func() {
|
||||
defer func() { recover() }()
|
||||
select {
|
||||
case entry.ch <- ev:
|
||||
default:
|
||||
}
|
||||
}()
|
||||
}
|
||||
}
|
||||
|
||||
// clear removes all subscribers. Used during Stop.
|
||||
// clear removes all subscribers and closes their channels. Used during Stop.
|
||||
func (sm *subManager) clear() {
|
||||
sm.mu.Lock()
|
||||
defer sm.mu.Unlock()
|
||||
old := sm.entries
|
||||
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.
|
||||
|
|
|
|||
|
|
@ -164,6 +164,7 @@ func TestSubscribe_StopClearsSubscribers(t *testing.T) {
|
|||
}
|
||||
|
||||
// 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 {
|
||||
var result []*types.Event
|
||||
timer := time.NewTimer(timeout)
|
||||
|
|
@ -171,7 +172,10 @@ func drainChan(ch chan *types.Event, n int, timeout time.Duration) []*types.Even
|
|||
|
||||
for range n {
|
||||
select {
|
||||
case ev := <-ch:
|
||||
case ev, ok := <-ch:
|
||||
if !ok {
|
||||
return result
|
||||
}
|
||||
result = append(result, ev)
|
||||
case <-timer.C:
|
||||
return result
|
||||
|
|
|
|||
|
|
@ -117,7 +117,7 @@ func ListAssistants(c *gin.Context) {
|
|||
}
|
||||
|
||||
// Parse boolean filters
|
||||
var builtIn, mentionable, automated *bool
|
||||
var builtIn, mentionable, automated, sandbox *bool
|
||||
if builtInParam := c.Query("built_in"); builtInParam != "" {
|
||||
builtIn = parseBoolValue(builtInParam)
|
||||
}
|
||||
|
|
@ -127,6 +127,9 @@ func ListAssistants(c *gin.Context) {
|
|||
if automatedParam := c.Query("automated"); automatedParam != "" {
|
||||
automated = parseBoolValue(automatedParam)
|
||||
}
|
||||
if sandboxParam := c.Query("sandbox"); sandboxParam != "" {
|
||||
sandbox = parseBoolValue(sandboxParam)
|
||||
}
|
||||
|
||||
// Note: public and share filters are not yet supported in AssistantFilter
|
||||
// They would need to be added to the store layer for proper filtering
|
||||
|
|
@ -152,6 +155,7 @@ func ListAssistants(c *gin.Context) {
|
|||
BuiltIn: builtIn,
|
||||
Mentionable: mentionable,
|
||||
Automated: automated,
|
||||
Sandbox: sandbox,
|
||||
})
|
||||
|
||||
// Apply permission-based filtering (Scope filtering)
|
||||
|
|
@ -169,12 +173,19 @@ func ListAssistants(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Filter sensitive fields for built-in assistants
|
||||
// For built-in assistants, clear code-level fields (prompts, workflow, tools, kb, mcp, options)
|
||||
FilterBuiltInFields(result.Data)
|
||||
// Convert sandbox to boolean and filter built-in sensitive fields
|
||||
resp := map[string]interface{}{
|
||||
"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
|
||||
response.RespondWithSuccess(c, response.StatusOK, result)
|
||||
response.RespondWithSuccess(c, response.StatusOK, resp)
|
||||
}
|
||||
|
||||
// GetAssistant retrieves a single assistant by ID with permission verification
|
||||
|
|
@ -260,11 +271,13 @@ func GetAssistant(c *gin.Context) {
|
|||
return
|
||||
}
|
||||
|
||||
// Filter sensitive fields for built-in assistants
|
||||
// Convert sandbox to boolean and filter built-in sensitive fields
|
||||
hasSandbox := assistant.Sandbox != nil
|
||||
FilterBuiltInAssistant(assistant)
|
||||
resp := AssistantToResponse(assistant, hasSandbox)
|
||||
|
||||
// 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
|
||||
|
|
|
|||
|
|
@ -1,6 +1,8 @@
|
|||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/gou/model"
|
||||
"github.com/yaoapp/xun/dbal/query"
|
||||
|
|
@ -150,9 +152,49 @@ func FilterBuiltInAssistant(assistant *agenttypes.AssistantModel) {
|
|||
assistant.Prompts = nil
|
||||
assistant.PromptPresets = nil
|
||||
assistant.Workflow = nil
|
||||
assistant.Sandbox = nil
|
||||
assistant.KB = nil
|
||||
assistant.MCP = nil
|
||||
assistant.Options = nil
|
||||
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 = map[string]bool{
|
||||
"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,
|
||||
"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,
|
||||
"automated": true, "mentionable": true,
|
||||
"created_at": true, "updated_at": true, "deleted_at": true,
|
||||
|
|
@ -23,9 +23,9 @@ var (
|
|||
|
||||
// defaultAssistantFields defines the default compact field list
|
||||
defaultAssistantFields = []string{
|
||||
"assistant_id", "type", "name", "avatar", "connector", "description",
|
||||
"assistant_id", "type", "name", "avatar", "connector", "description", "capabilities",
|
||||
"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
|
||||
Mentionable *bool
|
||||
Automated *bool
|
||||
Sandbox *bool
|
||||
Public *bool
|
||||
Share string
|
||||
}
|
||||
|
|
@ -79,6 +80,7 @@ func BuildAssistantFilter(params AssistantFilterParams) agenttypes.AssistantFilt
|
|||
BuiltIn: params.BuiltIn,
|
||||
Mentionable: params.Mentionable,
|
||||
Automated: params.Automated,
|
||||
Sandbox: params.Sandbox,
|
||||
}
|
||||
|
||||
// Set default type if not specified (only when Types is also empty)
|
||||
|
|
|
|||
|
|
@ -5,8 +5,6 @@ import (
|
|||
|
||||
"github.com/gin-gonic/gin"
|
||||
"github.com/yaoapp/gou/connector"
|
||||
"github.com/yaoapp/gou/connector/openai"
|
||||
"github.com/yaoapp/yao/agent"
|
||||
agentllm "github.com/yaoapp/yao/agent/llm"
|
||||
oauthTypes "github.com/yaoapp/yao/openapi/oauth/types"
|
||||
"github.com/yaoapp/yao/openapi/response"
|
||||
|
|
@ -21,16 +19,6 @@ type Provider struct {
|
|||
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
|
||||
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 {
|
||||
connType := getConnectorType(opt.Value)
|
||||
// Include OpenAI-compatible and Anthropic LLM connectors
|
||||
if connType == "openai" || connType == "anthropic" {
|
||||
conn, ok := connector.Connectors[opt.Value]
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
|
||||
// Get capabilities from connector settings
|
||||
capabilities := getCapabilitiesWithModels(conn, modelCapabilities)
|
||||
capabilities := getCapabilitiesFromConn(conn)
|
||||
|
||||
// Apply capability filters
|
||||
if len(filters) > 0 && !matchesFilters(capabilities, filters) {
|
||||
|
|
@ -110,16 +90,13 @@ func getConnectorType(id string) string {
|
|||
return "unknown"
|
||||
}
|
||||
|
||||
// getCapabilitiesWithModels extracts capabilities from connector settings
|
||||
// Uses the unified capability getter from agent/llm package
|
||||
// Takes modelCapabilities as parameter to avoid repeated calls to getModelCapabilities()
|
||||
func getCapabilitiesWithModels(conn connector.Connector, modelCapabilities map[string]openai.Capabilities) map[string]interface{} {
|
||||
// getCapabilitiesFromConn extracts capabilities from connector settings
|
||||
func getCapabilitiesFromConn(conn connector.Connector) map[string]interface{} {
|
||||
if conn == nil {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Use unified capability getter with user-defined model capabilities
|
||||
caps := agentllm.GetCapabilitiesFromConn(conn, modelCapabilities)
|
||||
caps := agentllm.GetCapabilitiesFromConn(conn)
|
||||
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) {
|
||||
// Test with select parameter to limit returned fields
|
||||
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, "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)
|
||||
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")
|
||||
|
||||
// Subscribe to trace updates
|
||||
updates, err := manager.Subscribe()
|
||||
updates, cancel, err := manager.Subscribe()
|
||||
if err != nil {
|
||||
// Send error as SSE event
|
||||
fmt.Fprintf(c.Writer, "event: error\ndata: {\"error\":\"Failed to subscribe: %s\"}\n\n", err.Error())
|
||||
c.Writer.Flush()
|
||||
return
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
// Stream events
|
||||
ctx := c.Request.Context()
|
||||
|
|
@ -73,22 +74,18 @@ func handleStreamMode(c *gin.Context, manager types.Manager, info *types.TraceIn
|
|||
for {
|
||||
select {
|
||||
case <-clientGone:
|
||||
// Client disconnected
|
||||
return
|
||||
|
||||
case update, ok := <-updates:
|
||||
if !ok {
|
||||
// Channel closed
|
||||
return
|
||||
}
|
||||
|
||||
// Format and send SSE event
|
||||
err := sendSSEEvent(c.Writer, *update)
|
||||
if err != nil {
|
||||
return
|
||||
}
|
||||
|
||||
// Check if trace is complete
|
||||
if update.Type == types.UpdateTypeComplete {
|
||||
return
|
||||
}
|
||||
|
|
|
|||
|
|
@ -2,6 +2,7 @@ package trace
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"sync"
|
||||
|
||||
"github.com/yaoapp/yao/event"
|
||||
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)
|
||||
}
|
||||
|
||||
// Subscribe creates a new subscription for trace updates (replays all historical events from the beginning)
|
||||
func (m *manager) Subscribe() (<-chan *types.TraceUpdate, error) {
|
||||
// Subscribe creates a new subscription for trace updates (replays all historical events from the beginning).
|
||||
// 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)
|
||||
}
|
||||
|
||||
// SubscribeFrom creates a subscription starting from a specific timestamp
|
||||
func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, error) {
|
||||
// SubscribeFrom creates a subscription starting from a specific timestamp.
|
||||
// Returns the update channel and a cancel function.
|
||||
func (m *manager) SubscribeFrom(since int64) (<-chan *types.TraceUpdate, func(), error) {
|
||||
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.
|
||||
// The subscriber is registered BEFORE reading historical state to prevent
|
||||
// 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
|
||||
|
||||
out := make(chan *types.TraceUpdate, bufferSize)
|
||||
|
||||
// Register live subscriber FIRST to avoid missing events between snapshot and subscribe.
|
||||
liveCh := make(chan *eventTypes.Event, bufferSize)
|
||||
traceID := m.traceID
|
||||
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
|
||||
}))
|
||||
|
||||
// THEN snapshot historical updates (may overlap with live events).
|
||||
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))
|
||||
for _, u := range historical {
|
||||
histSeen[dedupKey(u)] = struct{}{}
|
||||
}
|
||||
|
||||
var cancelOnce sync.Once
|
||||
cancel := func() {
|
||||
cancelOnce.Do(func() {
|
||||
event.Unsubscribe(subID)
|
||||
})
|
||||
}
|
||||
|
||||
go func() {
|
||||
defer close(out)
|
||||
defer event.Unsubscribe(subID)
|
||||
defer cancel()
|
||||
|
||||
for _, update := range historical {
|
||||
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...)
|
||||
|
||||
// Subscribe to updates
|
||||
updates, err := manager.Subscribe()
|
||||
updates, cancel, err := manager.Subscribe()
|
||||
assert.NoError(t, err)
|
||||
defer cancel()
|
||||
|
||||
// Collect updates in background
|
||||
var receivedUpdates []*types.TraceUpdate
|
||||
|
|
|
|||
|
|
@ -266,7 +266,7 @@ func BenchmarkSubscription(b *testing.B) {
|
|||
}
|
||||
|
||||
// Subscribe
|
||||
updates, err := manager.Subscribe()
|
||||
updates, cancel, err := manager.Subscribe()
|
||||
if err != nil {
|
||||
b.Fatalf("Failed to subscribe: %s", err.Error())
|
||||
}
|
||||
|
|
@ -301,6 +301,7 @@ func BenchmarkSubscription(b *testing.B) {
|
|||
}
|
||||
}
|
||||
|
||||
cancel()
|
||||
trace.Release(traceID)
|
||||
trace.Remove(ctx, trace.Local, traceID)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -179,6 +179,7 @@ func TestConcurrentSubscribers(t *testing.T) {
|
|||
var wg sync.WaitGroup
|
||||
numSubscribers := 5
|
||||
subscribers := make([]<-chan *types.TraceUpdate, numSubscribers)
|
||||
cancels := make([]func(), numSubscribers)
|
||||
var mu sync.Mutex
|
||||
|
||||
for i := 0; i < numSubscribers; i++ {
|
||||
|
|
@ -186,14 +187,22 @@ func TestConcurrentSubscribers(t *testing.T) {
|
|||
go func(idx int) {
|
||||
defer wg.Done()
|
||||
|
||||
sub, err := manager.Subscribe()
|
||||
sub, cancelSub, err := manager.Subscribe()
|
||||
assert.NoError(t, err)
|
||||
|
||||
mu.Lock()
|
||||
subscribers[idx] = sub
|
||||
cancels[idx] = cancelSub
|
||||
mu.Unlock()
|
||||
}(i)
|
||||
}
|
||||
defer func() {
|
||||
for _, c := range cancels {
|
||||
if c != nil {
|
||||
c()
|
||||
}
|
||||
}
|
||||
}()
|
||||
wg.Wait()
|
||||
|
||||
// Verify all subscriptions were created
|
||||
|
|
|
|||
|
|
@ -259,10 +259,11 @@ func TestMemoryLeakComplexScenarios(t *testing.T) {
|
|||
{
|
||||
name: "WithSubscription",
|
||||
execute: func(m types.Manager) error {
|
||||
updates, err := m.Subscribe()
|
||||
updates, cancel, err := m.Subscribe()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer cancel()
|
||||
|
||||
// Drain updates in background with timeout
|
||||
done := make(chan bool)
|
||||
|
|
@ -563,7 +564,7 @@ func TestGoroutineLeak(t *testing.T) {
|
|||
}
|
||||
|
||||
// Subscribe (creates goroutines)
|
||||
updates, err := manager.Subscribe()
|
||||
updates, cancel, err := manager.Subscribe()
|
||||
if err != nil {
|
||||
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.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...)
|
||||
|
||||
// Subscribe to updates
|
||||
updates, err := manager.Subscribe()
|
||||
updates, cancel, err := manager.Subscribe()
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, updates)
|
||||
defer cancel()
|
||||
|
||||
// Collect updates in background
|
||||
var receivedUpdates []*types.TraceUpdate
|
||||
|
|
@ -146,9 +147,10 @@ func TestSubscribeFrom(t *testing.T) {
|
|||
|
||||
// Real scenario: User refreshes page and resumes from last known timestamp
|
||||
// This should replay events from resumeTimestamp onwards
|
||||
updates, err := manager.SubscribeFrom(resumeTimestamp)
|
||||
updates, cancelSub, err := manager.SubscribeFrom(resumeTimestamp)
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, updates)
|
||||
defer cancelSub()
|
||||
|
||||
// Collect updates
|
||||
var receivedUpdates []*types.TraceUpdate
|
||||
|
|
@ -233,14 +235,17 @@ func TestMultipleSubscribers(t *testing.T) {
|
|||
defer trace.Remove(ctx, d.DriverType, traceID, d.DriverOptions...)
|
||||
|
||||
// Create multiple subscribers
|
||||
sub1, err := manager.Subscribe()
|
||||
sub1, cancel1, err := manager.Subscribe()
|
||||
assert.NoError(t, err)
|
||||
defer cancel1()
|
||||
|
||||
sub2, err := manager.Subscribe()
|
||||
sub2, cancel2, err := manager.Subscribe()
|
||||
assert.NoError(t, err)
|
||||
defer cancel2()
|
||||
|
||||
sub3, err := manager.Subscribe()
|
||||
sub3, cancel3, err := manager.Subscribe()
|
||||
assert.NoError(t, err)
|
||||
defer cancel3()
|
||||
|
||||
// Collect updates from all subscribers
|
||||
var wg sync.WaitGroup
|
||||
|
|
|
|||
|
|
@ -48,10 +48,13 @@ type Manager interface {
|
|||
MarkComplete() error
|
||||
|
||||
// Subscription Operations
|
||||
// Subscribe subscribes to trace updates (replay history + real-time)
|
||||
Subscribe() (<-chan *TraceUpdate, error)
|
||||
// SubscribeFrom subscribes from a specific timestamp (for resume)
|
||||
SubscribeFrom(since int64) (<-chan *TraceUpdate, error)
|
||||
// Subscribe subscribes to trace updates (replay history + real-time).
|
||||
// Returns the update channel and a cancel function. The caller MUST call
|
||||
// cancel when done (e.g., client disconnect) to release the goroutine.
|
||||
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() bool
|
||||
|
||||
|
|
|
|||
|
|
@ -73,6 +73,15 @@
|
|||
"nullable": 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",
|
||||
"type": "string",
|
||||
|
|
@ -161,6 +170,13 @@
|
|||
"comment": "MCP servers available for the assistant to use",
|
||||
"nullable": true
|
||||
},
|
||||
{
|
||||
"name": "sandbox",
|
||||
"type": "json",
|
||||
"label": "Sandbox",
|
||||
"comment": "Sandbox configuration for coding agents (command, image, timeout, etc.)",
|
||||
"nullable": true
|
||||
},
|
||||
{
|
||||
"name": "source",
|
||||
"type": "text",
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue