Enhance Assistant model with capabilities and sandbox configuration

- Introduce `Capabilities` and `Sandbox` fields in the Assistant model, allowing for detailed descriptions of assistant capabilities and sandbox configurations.
- Update loading and conversion functions to handle the new fields, ensuring they are correctly parsed and stored.
- Modify filtering and response handling to include the new fields, providing better integration with the API.
- Add comprehensive tests to validate the functionality of the new fields, ensuring they are correctly processed in various scenarios.
This commit is contained in:
Max 2026-02-24 10:50:10 +08:00
parent a28d61ed78
commit b4ded8a3ed
12 changed files with 2007 additions and 433 deletions

View file

@ -557,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
@ -568,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

View file

@ -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)

View file

@ -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",

View file

@ -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

View file

@ -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 {

View file

@ -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

File diff suppressed because one or more lines are too long

View file

@ -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

View file

@ -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
}

View file

@ -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)

View file

@ -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)
})

View file

@ -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",