feat(robot): add workspace support to robot management

- Introduced a new `workspace` field across various robot-related structures, including `CreateRobotRequest`, `UpdateRobotRequest`, and `RobotResponse`, allowing for better organization and management of robots within specific workspaces.
- Updated database queries and response mappings to accommodate the new workspace field, ensuring seamless integration with existing functionalities.
- Enhanced agent execution context to include workspace information, improving the contextual awareness of agents during operations.
- Added tests to validate the creation and updating of robots with workspace data, ensuring robust functionality and backward compatibility.
This commit is contained in:
Max 2026-03-28 16:59:52 +08:00
parent 79cd95e6cc
commit 8fdd1a3a6c
26 changed files with 538 additions and 392 deletions

View file

@ -199,7 +199,7 @@ func loadRobotFromDB(memberID string) (*types.Robot, error) {
"id", "member_id", "team_id", "display_name", "bio",
"system_prompt", "robot_status", "autonomous_mode",
"robot_config", "robot_email", "agents", "mcp_servers",
"manager_id", "language_model",
"manager_id", "language_model", "workspace",
},
Wheres: []model.QueryWhere{
{Column: "member_id", Value: memberID},
@ -268,7 +268,7 @@ func ListRobotsFromDB(query *ListQuery) (*ListResult, error) {
"id", "member_id", "team_id", "display_name", "bio",
"system_prompt", "robot_status", "autonomous_mode",
"robot_config", "robot_email", "agents", "mcp_servers",
"language_model",
"language_model", "workspace",
},
Wheres: wheres,
Orders: orders,
@ -444,6 +444,7 @@ func CreateRobot(ctx *types.Context, req *CreateRobotRequest) (*RobotResponse, e
Agents: req.Agents,
MCPServers: req.MCPServers,
LanguageModel: req.LanguageModel,
Workspace: req.Workspace,
// Limits
CostLimit: req.CostLimit,
@ -559,6 +560,9 @@ func UpdateRobot(ctx *types.Context, memberID string, req *UpdateRobotRequest) (
if req.LanguageModel != nil {
existing.LanguageModel = *req.LanguageModel
}
if req.Workspace != nil {
existing.Workspace = *req.Workspace
}
// Limits
if req.CostLimit != nil {
@ -684,6 +688,7 @@ func recordToResponse(record *store.RobotRecord) *RobotResponse {
Agents: record.Agents,
MCPServers: record.MCPServers,
LanguageModel: record.LanguageModel,
Workspace: record.Workspace,
CostLimit: record.CostLimit,
InvitedBy: record.InvitedBy,

View file

@ -144,7 +144,7 @@ type CreateRobotRequest struct {
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Whether autonomous mode is enabled
// Communication
RobotEmail string `json:"robot_email,omitempty"` // Robot email address
RobotEmail string `json:"robot_email,omitempty"` // Deprecated: Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array)
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array)
@ -153,6 +153,7 @@ type CreateRobotRequest struct {
Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array)
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array)
LanguageModel string `json:"language_model,omitempty"` // Language model name
Workspace string `json:"workspace,omitempty"` // Workspace ID bound to this robot
// Limits
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
@ -179,7 +180,7 @@ type UpdateRobotRequest struct {
AutonomousMode *bool `json:"autonomous_mode,omitempty"` // Autonomous mode
// Communication
RobotEmail *string `json:"robot_email,omitempty"` // Robot email address
RobotEmail *string `json:"robot_email,omitempty"` // Deprecated: Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules
@ -188,6 +189,7 @@ type UpdateRobotRequest struct {
Agents interface{} `json:"agents,omitempty"` // Accessible agents
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers
LanguageModel *string `json:"language_model,omitempty"` // Language model name
Workspace *string `json:"workspace,omitempty"` // Workspace ID (nil=no change, ""=unbind)
// Limits
CostLimit *float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
@ -226,6 +228,7 @@ type RobotResponse struct {
Agents interface{} `json:"agents,omitempty"`
MCPServers interface{} `json:"mcp_servers,omitempty"`
LanguageModel string `json:"language_model,omitempty"`
Workspace string `json:"workspace,omitempty"`
// Limits
CostLimit float64 `json:"cost_limit,omitempty"`

View file

@ -28,6 +28,7 @@ var memberFields = []interface{}{
"mcp_servers",
"manager_id",
"language_model",
"workspace",
}
// SetMemberModel sets the member model name

View file

@ -49,6 +49,10 @@ type AgentCaller struct {
// When non-empty, passed as opts.Connector to ast.Stream so the agent uses the Robot's model.
Connector string
// Workspace is the workspace ID bound to the Robot.
// When non-empty, injected into agentCtx.Metadata["workspace_id"] for sandbox node resolution.
Workspace string
// log is an optional structured logger; when set, Call emits agent-call logs.
log *execLogger
}
@ -444,6 +448,13 @@ func (c *AgentCaller) buildAgentContext(ctx *robottypes.Context, assistantID str
}
agentCtx.Logger = agentcontext.Noop()
if c.Workspace != "" {
if agentCtx.Metadata == nil {
agentCtx.Metadata = map[string]interface{}{}
}
agentCtx.Metadata["workspace_id"] = c.Workspace
}
kunlog.Trace("[robot-agent] context built: assistantID=%s chatID=%s contextID=%s", assistantID, c.ChatID, agentCtx.ID)
return agentCtx
}

View file

@ -43,6 +43,7 @@ func (e *Executor) RunDelivery(ctx *robottypes.Context, exec *robottypes.Executi
caller := NewAgentCaller()
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil {
return fmt.Errorf("delivery agent (%s) call failed: %w", agentID, err)

View file

@ -85,6 +85,7 @@ func (e *Executor) RunGoals(ctx *robottypes.Context, exec *robottypes.Execution,
// Call agent
caller := NewAgentCaller()
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil {
return fmt.Errorf("goals agent (%s) call failed: %w", agentID, err)

View file

@ -32,6 +32,8 @@ func (e *Executor) CallHostAgent(ctx *robottypes.Context, robot *robottypes.Robo
kunlog.Info("calling Host Agent %s for scenario=%s chatID=%s", agentID, input.Scenario, chatID)
caller := NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, string(inputJSON))
if err != nil {
return nil, fmt.Errorf("host agent (%s) call failed: %w", agentID, err)

View file

@ -54,6 +54,7 @@ func (e *Executor) RunInspiration(ctx *robottypes.Context, exec *robottypes.Exec
// Call agent
caller := NewAgentCaller()
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil {
return fmt.Errorf("inspiration agent (%s) call failed: %w", agentID, err)

View file

@ -37,6 +37,13 @@ func (l *execLogger) connector() string {
return ""
}
func (l *execLogger) workspace() string {
if l.robot != nil {
return l.robot.Workspace
}
return ""
}
// ---------------------------------------------------------------------------
// P2: Task Overview
// ---------------------------------------------------------------------------
@ -51,6 +58,7 @@ func (l *execLogger) logTaskOverview(tasks []robottypes.Task) {
"phase": "tasks",
"task_count": len(tasks),
"language_model": l.connector(),
"workspace": l.workspace(),
}).Info("P2 task overview: %d tasks generated", len(tasks))
}
@ -69,6 +77,9 @@ func (l *execLogger) devTaskOverview(tasks []robottypes.Task) {
if l.connector() != "" {
sb.WriteString(fmt.Sprintf("%s Model: %s%s%s\n", w, v, l.connector(), r))
}
if l.workspace() != "" {
sb.WriteString(fmt.Sprintf("%s Workspace: %s%s%s\n", w, v, l.workspace(), r))
}
sb.WriteString(fmt.Sprintf("%s%s%s\n", w, strings.Repeat("─", 60), r))
for i, t := range tasks {
desc := t.Description

View file

@ -134,6 +134,7 @@ func (r *Runner) executeAssistantTask(task *robottypes.Task, taskCtx *RunnerCont
caller := NewAgentCaller()
caller.log = r.log
caller.Connector = r.robot.LanguageModel
caller.Workspace = r.robot.Workspace
caller.ChatID = r.chatID
messages := r.BuildAssistantMessages(task, taskCtx)

View file

@ -55,6 +55,7 @@ func (e *Executor) RunTasks(ctx *robottypes.Context, exec *robottypes.Execution,
caller := NewAgentCaller()
caller.log = newExecLogger(robot, exec.ID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, userContent)
if err != nil {
return fmt.Errorf("tasks agent (%s) call failed: %w", agentID, err)

View file

@ -434,6 +434,7 @@ func (v *Validator) validateSemantic(task *robottypes.Task, output interface{})
// Call validation agent
caller := NewAgentCaller()
caller.Connector = v.robot.LanguageModel
caller.Workspace = v.robot.Workspace
result, err := caller.CallWithMessages(v.ctx, validationAgentID, validationPrompt)
if err != nil {
return &robottypes.ValidationResult{
@ -641,6 +642,7 @@ func (av *robotAgentValidator) Validate(agentID string, output, input, criteria
// Call agent
caller := NewAgentCaller()
caller.Connector = av.v.robot.LanguageModel
caller.Workspace = av.v.robot.Workspace
callResult, err := caller.CallWithMessages(av.v.ctx, agentID, string(inputJSON))
if err != nil {
result.Passed = false

View file

@ -326,17 +326,19 @@ func (m *Manager) callHostAgentForScenario(ctx *types.Context, robot *types.Robo
Scenario: scenario,
Messages: []agentcontext.Message{{Role: "user", Content: message}},
Context: hostCtx,
}, chatID)
}, chatID, robot)
}
// callHostAgent calls the Host Agent assistant and parses output.
func (m *Manager) callHostAgent(ctx *types.Context, agentID string, input *types.HostInput, chatID string) (*types.HostOutput, error) {
func (m *Manager) callHostAgent(ctx *types.Context, agentID string, input *types.HostInput, chatID string, robot *types.Robot) (*types.HostOutput, error) {
inputJSON, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("failed to marshal host input: %w", err)
}
caller := standard.NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessages(ctx, agentID, string(inputJSON))
if err != nil {
return nil, fmt.Errorf("host agent (%s) call failed: %w", agentID, err)
@ -728,16 +730,18 @@ func (m *Manager) callHostAgentForScenarioStream(ctx *types.Context, robot *type
Scenario: scenario,
Messages: []agentcontext.Message{{Role: "user", Content: msg}},
Context: hostCtx,
}, chatID, streamFn)
}, chatID, robot, streamFn)
}
func (m *Manager) callHostAgentStream(ctx *types.Context, agentID string, input *types.HostInput, chatID string, streamFn standard.StreamCallback) (*types.HostOutput, error) {
func (m *Manager) callHostAgentStream(ctx *types.Context, agentID string, input *types.HostInput, chatID string, robot *types.Robot, streamFn standard.StreamCallback) (*types.HostOutput, error) {
inputJSON, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("failed to marshal host input: %w", err)
}
caller := standard.NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessagesStream(ctx, agentID, string(inputJSON), streamFn)
if err != nil {
return nil, fmt.Errorf("host agent (%s) call failed: %w", agentID, err)
@ -895,7 +899,7 @@ func (m *Manager) callHostAgentForScenarioStreamRaw(ctx *types.Context, robot *t
Scenario: scenario,
Messages: []agentcontext.Message{{Role: "user", Content: msg}},
Context: hostCtx,
}, chatID, onMessage)
}, chatID, robot, onMessage)
}
// callHostAgentStreamRaw calls the Host Agent with CUI raw message streaming.
@ -903,7 +907,7 @@ func (m *Manager) callHostAgentForScenarioStreamRaw(ctx *types.Context, robot *t
// so the frontend never sees raw decision JSON. If the final result is a decision,
// the buffered chunks are discarded and a clean reply is sent instead. If the
// result is a normal conversation turn, buffered chunks are flushed through.
func (m *Manager) callHostAgentStreamRaw(ctx *types.Context, agentID string, input *types.HostInput, chatID string, onMessage agentcontext.OnMessageFunc) (*types.HostOutput, error) {
func (m *Manager) callHostAgentStreamRaw(ctx *types.Context, agentID string, input *types.HostInput, chatID string, robot *types.Robot, onMessage agentcontext.OnMessageFunc) (*types.HostOutput, error) {
inputJSON, err := json.Marshal(input)
if err != nil {
return nil, fmt.Errorf("failed to marshal host input: %w", err)
@ -956,6 +960,8 @@ func (m *Manager) callHostAgentStreamRaw(ctx *types.Context, agentID string, inp
}
caller := standard.NewConversationCaller(chatID)
caller.Connector = robot.LanguageModel
caller.Workspace = robot.Workspace
result, err := caller.CallWithMessagesStreamRaw(ctx, agentID, string(inputJSON), wrappedOnMessage)
if err != nil {
return nil, fmt.Errorf("host agent (%s) call failed: %w", agentID, err)

View file

@ -33,7 +33,7 @@ type RobotRecord struct {
ManagerID string `json:"manager_id"` // Direct manager user_id (who manages this robot)
// Communication
RobotEmail string `json:"robot_email"` // Robot email address
RobotEmail string `json:"robot_email"` // Deprecated: Robot email address
AuthorizedSenders interface{} `json:"authorized_senders,omitempty"` // Email whitelist (JSON array)
EmailFilterRules interface{} `json:"email_filter_rules,omitempty"` // Email filter rules (JSON array)
@ -42,6 +42,7 @@ type RobotRecord struct {
Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array)
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array)
LanguageModel string `json:"language_model,omitempty"` // Language model name
Workspace string `json:"workspace,omitempty"` // Workspace ID bound to this robot
// Limits
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
@ -117,6 +118,7 @@ var robotFields = []interface{}{
"agents",
"mcp_servers",
"language_model",
"workspace",
// Limits
"cost_limit",
@ -435,6 +437,9 @@ func (s *RobotStore) recordToMap(record *RobotRecord) map[string]interface{} {
if record.LanguageModel != "" {
data["language_model"] = record.LanguageModel
}
if record.Workspace != "" {
data["workspace"] = record.Workspace
}
// Limits
if record.CostLimit > 0 {
@ -547,6 +552,9 @@ func (s *RobotStore) mapToRecord(row map[string]interface{}) (*RobotRecord, erro
if v, ok := row["language_model"].(string); ok {
record.LanguageModel = v
}
if v, ok := row["workspace"].(string); ok {
record.Workspace = v
}
// Limits
if v := row["cost_limit"]; v != nil {
@ -596,6 +604,8 @@ func (r *RobotRecord) ToRobot() (*types.Robot, error) {
SystemPrompt: r.SystemPrompt,
AutonomousMode: r.AutonomousMode,
RobotEmail: r.RobotEmail,
LanguageModel: r.LanguageModel,
Workspace: r.Workspace,
}
// Parse robot_status
@ -678,6 +688,8 @@ func FromRobot(robot *types.Robot) *RobotRecord {
RobotStatus: string(robot.Status),
AutonomousMode: robot.AutonomousMode,
RobotEmail: robot.RobotEmail,
LanguageModel: robot.LanguageModel,
Workspace: robot.Workspace,
MemberType: "robot",
Status: "active",
}

View file

@ -21,8 +21,9 @@ type Robot struct {
SystemPrompt string `json:"system_prompt"`
Status RobotStatus `json:"robot_status"`
AutonomousMode bool `json:"autonomous_mode"`
RobotEmail string `json:"robot_email"` // Robot's email address for sending emails
RobotEmail string `json:"robot_email"` // Deprecated: Robot's email address for sending emails
LanguageModel string `json:"language_model"` // LLM connector override (from __yao.member.language_model)
Workspace string `json:"workspace"` // Workspace ID bound to this robot (nullable in DB)
// Manager info (from __yao.member)
ManagerID string `json:"manager_id"` // Direct manager user_id (who manages this robot)
@ -509,6 +510,7 @@ func NewRobotFromMap(m map[string]interface{}) (*Robot, error) {
ManagerID: getString(m, "manager_id"),
ManagerEmail: getString(m, "manager_email"),
LanguageModel: getString(m, "language_model"),
Workspace: getString(m, "workspace"),
}
// Parse robot_status

View file

@ -60,6 +60,8 @@ func ResolveNodeID(ctx *agentContext.Context, cfg *types.SandboxConfig, manager
if err == nil && wsNode != "" {
log.Trace("[sandbox/v2] ResolveNodeID: workspace %s -> node %s", workspaceID, wsNode)
computerID = wsNode
} else if err != nil {
log.Warn("[sandbox/v2] ResolveNodeID: workspace %s not found or deleted, falling back to auto-select: %v", workspaceID, err)
}
}
@ -132,6 +134,8 @@ func GetComputer(ctx *agentContext.Context, cfg *types.SandboxConfig, manager *i
log.Trace("[sandbox/v2] workspace %s bound to node %s overrides computer_id %s", workspaceID, wsNode, computerID)
}
computerID = wsNode
} else if err != nil {
log.Warn("[sandbox/v2] GetComputer: workspace %s not found or deleted, falling back: %v", workspaceID, err)
}
}

View file

@ -36,8 +36,8 @@ var migrateCmd = &cobra.Command{
exception.New(L("Migrate is not allowed on production mode."), 403).Throw()
}
// 加载数据模型
loadWarnings, err := engine.Load(config.Conf, engine.LoadOption{Action: "migrate"})
// 仅加载 Application、DB 连接和 Model含自动 migrate不启动完整 Engine
loadWarnings, err := engine.LoadForMigrate(config.Conf)
if err != nil {
fmt.Println(color.RedString(L("Fatal: %s"), err.Error()))
os.Exit(1)

File diff suppressed because one or more lines are too long

View file

@ -95,6 +95,27 @@ func loadStep(name string, loadFunc func() error, callback func(string, string))
return err
}
// LoadForMigrate loads only the minimal modules needed for schema migration:
// application config, database connection, and models (with auto-migrate).
func LoadForMigrate(cfg config.Config) (warnings []Warning, err error) {
defer func() { err = exception.Catch(recover()) }()
exception.Mode = cfg.Mode
if err = loadApp(cfg.AppSource); err != nil {
return append(warnings, Warning{Widget: "Load Application", Error: err}), err
}
if err = share.DBConnect(cfg.DB); err != nil {
return append(warnings, Warning{Widget: "DB", Error: err}), err
}
if err = model.Load(cfg); err != nil {
warnings = append(warnings, Warning{Widget: "Model", Error: err})
}
return warnings, err
}
// Load application engine
func Load(cfg config.Config, options LoadOption, progressCallback ...func(string, string)) (warnings []Warning, err error) {

View file

@ -41,6 +41,7 @@ type CreateRobotRequest struct {
Agents interface{} `json:"agents,omitempty"` // Accessible agents (JSON array)
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers (JSON array)
LanguageModel string `json:"language_model,omitempty"` // Language model name
Workspace string `json:"workspace,omitempty"` // Workspace ID bound to this robot
// Limits
CostLimit float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
@ -73,6 +74,7 @@ type UpdateRobotRequest struct {
Agents interface{} `json:"agents,omitempty"` // Accessible agents
MCPServers interface{} `json:"mcp_servers,omitempty"` // MCP servers
LanguageModel *string `json:"language_model,omitempty"` // Language model name
Workspace *string `json:"workspace,omitempty"` // Workspace ID (nil=no change, ""=unbind)
// Limits
CostLimit *float64 `json:"cost_limit,omitempty"` // Monthly cost limit USD
@ -115,6 +117,7 @@ type Response struct {
Agents interface{} `json:"agents,omitempty"`
MCPServers interface{} `json:"mcp_servers,omitempty"`
LanguageModel string `json:"language_model,omitempty"`
Workspace string `json:"workspace,omitempty"`
// Limits
CostLimit float64 `json:"cost_limit,omitempty"`
@ -186,6 +189,7 @@ func NewResponse(r *robotapi.RobotResponse) *Response {
Agents: r.Agents,
MCPServers: r.MCPServers,
LanguageModel: r.LanguageModel,
Workspace: r.Workspace,
CostLimit: r.CostLimit,
InvitedBy: r.InvitedBy,
JoinedAt: r.JoinedAt,
@ -215,6 +219,7 @@ func (r *CreateRobotRequest) ToAPICreateRequest() *robotapi.CreateRobotRequest {
Agents: r.Agents,
MCPServers: r.MCPServers,
LanguageModel: r.LanguageModel,
Workspace: r.Workspace,
CostLimit: r.CostLimit,
}
}
@ -238,6 +243,7 @@ func (r *UpdateRobotRequest) ToAPIUpdateRequest() *robotapi.UpdateRobotRequest {
Agents: r.Agents,
MCPServers: r.MCPServers,
LanguageModel: r.LanguageModel,
Workspace: r.Workspace,
CostLimit: r.CostLimit,
}
}

View file

@ -167,7 +167,7 @@ var (
"member_id", "team_id", "user_id", "member_type", "display_name", "bio", "avatar", "email", "role_id", "is_owner", "status",
"system_prompt", "manager_id", "robot_email", "authorized_senders", "email_filter_rules",
"robot_config", "agents", "mcp_servers",
"language_model", "cost_limit", "autonomous_mode", "last_robot_activity", "robot_status",
"language_model", "workspace", "cost_limit", "autonomous_mode", "last_robot_activity", "robot_status",
"invitation_id", "invited_by", "invited_at", "joined_at", "invitation_token",
"invitation_expires_at", "last_active_at",
"login_count", "notes", "metadata", "created_at", "updated_at",

View file

@ -362,7 +362,7 @@ func (u *DefaultUser) CreateRobotMember(ctx context.Context, teamID string, robo
robotFields := []string{
"role_id", "system_prompt", "manager_id", "robot_email", "authorized_senders", "email_filter_rules",
"robot_config", "agents", "mcp_servers",
"language_model", "cost_limit", "autonomous_mode", "robot_status",
"language_model", "workspace", "cost_limit", "autonomous_mode", "robot_status",
"notes", "metadata",
"__yao_created_by", "__yao_updated_by", "__yao_team_id", "__yao_tenant_id",
}
@ -444,7 +444,7 @@ func (u *DefaultUser) UpdateRobotMember(ctx context.Context, memberID string, ro
robotFields := []string{
"role_id", "system_prompt", "manager_id", "robot_email", "authorized_senders", "email_filter_rules",
"robot_config", "agents", "mcp_servers",
"language_model", "cost_limit", "autonomous_mode", "robot_status",
"language_model", "workspace", "cost_limit", "autonomous_mode", "robot_status",
"notes", "metadata", "status",
"__yao_updated_by", "__yao_team_id", "__yao_tenant_id",
}

View file

@ -1152,6 +1152,7 @@ func TestMemberCreateRobot(t *testing.T) {
"mcp_tools": []string{"filesystem", "database"},
"autonomous_mode": "enabled",
"cost_limit": 100.50,
"workspace": "ws-test-create",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
@ -1374,6 +1375,9 @@ func TestMemberCreateRobot(t *testing.T) {
if tc.body["prompt"] != nil {
assert.Equal(t, tc.body["prompt"], member["system_prompt"], "Should have correct system_prompt")
}
if tc.body["workspace"] != nil {
assert.Equal(t, "ws-test-create", member["workspace"], "Should have correct workspace")
}
}
}
}
@ -1595,6 +1599,7 @@ func TestMemberUpdateRobot(t *testing.T) {
"llm": "gpt-3.5-turbo",
"autonomous_mode": "disabled",
"cost_limit": 50.0,
"workspace": "ws-initial",
}
robotBodyBytes, _ := json.Marshal(robotBody)
robotReq, _ := http.NewRequest("POST", serverURL+baseURL+"/user/teams/"+teamID+"/members/robots", bytes.NewBuffer(robotBodyBytes))
@ -1655,6 +1660,7 @@ func TestMemberUpdateRobot(t *testing.T) {
"cost_limit": 100.0,
"status": "active",
"robot_status": "working",
"workspace": "ws-updated",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
@ -1679,6 +1685,7 @@ func TestMemberUpdateRobot(t *testing.T) {
assert.Equal(t, fmt.Sprintf("https://example.com/avatars/full-%s.png", testUUID), member["avatar"])
assert.Equal(t, "Updated system prompt", member["system_prompt"])
assert.Equal(t, "gpt-4", member["language_model"])
assert.Equal(t, "ws-updated", member["workspace"], "Should have correct workspace")
}
}
},
@ -1973,6 +1980,35 @@ func TestMemberUpdateRobot(t *testing.T) {
}
},
},
{
"update workspace to unbind",
func() (string, string) { return createTestRobot("15") },
map[string]interface{}{
"workspace": "",
},
map[string]string{
"Authorization": "Bearer " + tokenInfo.AccessToken,
},
200,
"should unbind workspace by setting to empty string",
func(t *testing.T, memberID string) {
getMemberURL := serverURL + baseURL + "/user/teams/" + teamID + "/members/" + memberID
getReq, _ := http.NewRequest("GET", getMemberURL, nil)
getReq.Header.Set("Authorization", "Bearer "+tokenInfo.AccessToken)
client := &http.Client{}
getResp, err := client.Do(getReq)
assert.NoError(t, err)
if getResp != nil {
defer getResp.Body.Close()
if getResp.StatusCode == 200 {
var member map[string]interface{}
body, _ := io.ReadAll(getResp.Body)
json.Unmarshal(body, &member)
assert.Empty(t, member["workspace"], "Workspace should be empty after unbinding")
}
}
},
},
}
for _, tc := range testCases {

View file

@ -315,6 +315,9 @@ func GinMemberCreateRobot(c *gin.Context) {
if req.LanguageModel != "" {
baseData["language_model"] = req.LanguageModel
}
if req.Workspace != "" {
baseData["workspace"] = req.Workspace
}
if len(req.Agents) > 0 {
baseData["agents"] = req.Agents
}
@ -431,6 +434,9 @@ func GinMemberUpdateRobot(c *gin.Context) {
if req.LanguageModel != "" {
updateData["language_model"] = req.LanguageModel
}
if req.Workspace != nil {
updateData["workspace"] = *req.Workspace
}
if req.Status != "" {
updateData["status"] = req.Status
}
@ -1591,6 +1597,7 @@ func mapToMemberDetailResponse(data maps.MapStr) MemberDetailResponse {
SystemPrompt: utils.ToString(data["system_prompt"]),
ManagerID: utils.ToString(data["manager_id"]),
LanguageModel: utils.ToString(data["language_model"]),
Workspace: utils.ToString(data["workspace"]),
CostLimit: utils.ToFloat64(data["cost_limit"]),
AutonomousMode: data["autonomous_mode"], // Keep original type (bool or string)
LastRobotActivity: utils.ToTimeString(data["last_robot_activity"]),

View file

@ -457,6 +457,7 @@ type MemberDetailResponse struct {
Agents []string `json:"agents,omitempty"`
MCPServers []string `json:"mcp_servers,omitempty"`
LanguageModel string `json:"language_model,omitempty"`
Workspace string `json:"workspace,omitempty"`
CostLimit float64 `json:"cost_limit,omitempty"`
AutonomousMode interface{} `json:"autonomous_mode,omitempty"` // Can be bool or string
LastRobotActivity string `json:"last_robot_activity,omitempty"`
@ -480,6 +481,7 @@ type CreateRobotMemberRequest struct {
ManagerID string `json:"report_to,omitempty"` // Direct manager user ID
SystemPrompt string `json:"prompt" binding:"required"` // Identity & role prompt
LanguageModel string `json:"llm,omitempty"` // Language model (e.g., "gpt-4")
Workspace string `json:"workspace,omitempty"` // Workspace ID bound to this robot
Agents []string `json:"agents,omitempty"` // Accessible agents
MCPServers []string `json:"mcp_tools,omitempty"` // MCP servers/tools
AutonomousMode string `json:"autonomous_mode,omitempty"` // "enabled" or "disabled"
@ -499,6 +501,7 @@ type UpdateRobotMemberRequest struct {
ManagerID string `json:"report_to,omitempty"` // Direct manager user ID
SystemPrompt string `json:"prompt,omitempty"` // Identity & role prompt
LanguageModel string `json:"llm,omitempty"` // Language model (e.g., "gpt-4")
Workspace *string `json:"workspace"` // Workspace ID (nil=no change, ""=unbind)
Agents []string `json:"agents,omitempty"` // Accessible agents
MCPServers []string `json:"mcp_tools,omitempty"` // MCP servers/tools
AutonomousMode string `json:"autonomous_mode,omitempty"` // "enabled" or "disabled"

View file

@ -214,6 +214,15 @@
"length": 100,
"nullable": true
},
{
"name": "workspace",
"type": "string",
"label": "Workspace",
"comment": "Workspace ID bound to this robot member (nullable = not bound)",
"length": 255,
"nullable": true,
"index": true
},
{
"name": "cost_limit",
"type": "decimal",