From 4c34c926ab99b6371ec58a9ca77af91e56aaf13e Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 14 May 2025 12:55:31 -0700 Subject: [PATCH 1/4] lfg we have agent creation api --- acp/config/localdev/kustomization.yaml | 2 +- acp/internal/server/server.go | 462 ++++++++++++++++++++++ acp/internal/server/server_test.go | 522 +++++++++++++++++++++++++ 3 files changed, 985 insertions(+), 1 deletion(-) diff --git a/acp/config/localdev/kustomization.yaml b/acp/config/localdev/kustomization.yaml index 551b350..5ef6e67 100644 --- a/acp/config/localdev/kustomization.yaml +++ b/acp/config/localdev/kustomization.yaml @@ -26,4 +26,4 @@ patches: images: - name: controller newName: controller - newTag: "202505121745" + newTag: "202505141236" diff --git a/acp/internal/server/server.go b/acp/internal/server/server.go index eb6eb3e..8499ae1 100644 --- a/acp/internal/server/server.go +++ b/acp/internal/server/server.go @@ -4,6 +4,7 @@ import ( "bytes" "context" "encoding/json" + "fmt" "net/http" "strings" "time" @@ -14,6 +15,7 @@ import ( "github.com/humanlayer/agentcontrolplane/acp/internal/validation" "github.com/pkg/errors" apierrors "k8s.io/apimachinery/pkg/api/errors" + corev1 "k8s.io/api/core/v1" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -29,6 +31,34 @@ type CreateTaskRequest struct { ResponseUrl string `json:"responseUrl,omitempty"` // Alternative casing for responseURL (deprecated) } +// CreateAgentRequest defines the structure of the request body for creating an agent +type CreateAgentRequest struct { + Namespace string `json:"namespace,omitempty"` // Optional, defaults to "default" + Name string `json:"name"` // Required + LLM string `json:"llm"` // Required + SystemPrompt string `json:"systemPrompt"` // Required + MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` // Optional +} + +// MCPServerConfig defines the configuration for an MCP server +type MCPServerConfig struct { + Transport string `json:"transport"` // Required: "stdio" or "http" + Command string `json:"command,omitempty"` // Required for stdio transport + Args []string `json:"args,omitempty"` // Required for stdio transport + URL string `json:"url,omitempty"` // Required for http transport + Env map[string]string `json:"env,omitempty"` // Optional environment variables + Secrets map[string]string `json:"secrets,omitempty"` // Optional secrets +} + +// AgentResponse defines the structure of the response body for agent endpoints +type AgentResponse struct { + Namespace string `json:"namespace"` + Name string `json:"name"` + LLM string `json:"llm"` + SystemPrompt string `json:"systemPrompt"` + MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` +} + // APIServer represents the REST API server type APIServer struct { client client.Client @@ -67,6 +97,278 @@ func (s *APIServer) registerRoutes() { tasks.GET("", s.listTasks) tasks.GET("/:id", s.getTask) tasks.POST("", s.createTask) + + // Agent endpoints + agents := v1.Group("/agents") + agents.GET("", s.listAgents) + agents.GET("/:name", s.getAgent) + agents.POST("", s.createAgent) +} + +// processMCPServers creates MCP servers and their secrets based on the given configuration +func (s *APIServer) processMCPServers(ctx context.Context, agentName, namespace string, mcpConfigs map[string]MCPServerConfig) ([]acp.LocalObjectReference, error) { + logger := log.FromContext(ctx) + mcpServerRefs := []acp.LocalObjectReference{} + + for key, config := range mcpConfigs { + // Validate MCP server configuration + if err := validateMCPConfig(config); err != nil { + return nil, fmt.Errorf("invalid MCP server configuration for '%s': %s", key, err.Error()) + } + + // Generate names for MCP server and its secret + mcpName := fmt.Sprintf("%s-%s", agentName, key) + secretName := fmt.Sprintf("%s-%s-secrets", agentName, key) + + // Check if MCP server already exists + exists, err := s.resourceExists(ctx, &acp.MCPServer{}, namespace, mcpName) + if err != nil { + logger.Error(err, "Failed to check MCP server existence", "name", mcpName) + return nil, fmt.Errorf("failed to check MCP server existence: %w", err) + } + if exists { + return nil, fmt.Errorf("MCP server '%s' already exists", mcpName) + } + + // Check if secret already exists + if len(config.Secrets) > 0 { + exists, err := s.resourceExists(ctx, &corev1.Secret{}, namespace, secretName) + if err != nil { + logger.Error(err, "Failed to check secret existence", "name", secretName) + return nil, fmt.Errorf("failed to check secret existence: %w", err) + } + if exists { + return nil, fmt.Errorf("secret '%s' already exists", secretName) + } + } + + // Create secret if needed + if len(config.Secrets) > 0 { + secret := createSecret(secretName, namespace, config.Secrets) + if err := s.client.Create(ctx, secret); err != nil { + logger.Error(err, "Failed to create secret", "name", secretName) + return nil, fmt.Errorf("failed to create secret: %w", err) + } + } + + // Create MCP server + mcpServer := createMCPServer(mcpName, namespace, config, secretName) + if err := s.client.Create(ctx, mcpServer); err != nil { + logger.Error(err, "Failed to create MCP server", "name", mcpName) + return nil, fmt.Errorf("failed to create MCP server: %w", err) + } + + // Add reference to the list + mcpServerRefs = append(mcpServerRefs, acp.LocalObjectReference{Name: mcpName}) + } + + return mcpServerRefs, nil +} + +// createAgent handles the creation of a new agent and associated MCP servers +func (s *APIServer) createAgent(c *gin.Context) { + ctx := c.Request.Context() + logger := log.FromContext(ctx) + + // Read the raw data for validation + var rawData []byte + if data, err := c.GetRawData(); err == nil { + rawData = data + } else { + c.JSON(http.StatusBadRequest, gin.H{"error": "Failed to read request body: " + err.Error()}) + return + } + + // Parse request + var req CreateAgentRequest + if err := json.Unmarshal(rawData, &req); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request body: " + err.Error()}) + return + } + + // Validate for unknown fields + decoder := json.NewDecoder(bytes.NewReader(rawData)) + decoder.DisallowUnknownFields() + if err := decoder.Decode(&req); err != nil { + if strings.Contains(err.Error(), "unknown field") { + c.JSON(http.StatusBadRequest, gin.H{"error": "Unknown field in request: " + err.Error()}) + return + } + c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid JSON format: " + err.Error()}) + return + } + + // Validate required fields + if req.Name == "" || req.LLM == "" || req.SystemPrompt == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "name, llm, and systemPrompt are required"}) + return + } + + // Default namespace to "default" if not provided + namespace := defaultIfEmpty(req.Namespace, "default") + + // Check if agent already exists + exists, err := s.resourceExists(ctx, &acp.Agent{}, namespace, req.Name) + if err != nil { + logger.Error(err, "Failed to check agent existence") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check agent existence: " + err.Error()}) + return + } + if exists { + c.JSON(http.StatusConflict, gin.H{"error": "Agent already exists"}) + return + } + + // Verify LLM exists + var llm acp.LLM + if err := s.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: req.LLM}, &llm); err != nil { + if apierrors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "LLM not found"}) + return + } + logger.Error(err, "Failed to check LLM existence") + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to check LLM existence: " + err.Error()}) + return + } + + // Process MCP servers if provided + var mcpServerRefs []acp.LocalObjectReference + if len(req.MCPServers) > 0 { + mcpServerRefs, err = s.processMCPServers(ctx, req.Name, namespace, req.MCPServers) + if err != nil { + // Convert various MCP server errors to appropriate HTTP status codes + if strings.Contains(err.Error(), "invalid MCP server configuration") { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } else if strings.Contains(err.Error(), "already exists") { + c.JSON(http.StatusConflict, gin.H{"error": err.Error()}) + return + } else { + c.JSON(http.StatusInternalServerError, gin.H{"error": err.Error()}) + return + } + } + } + + // Create the agent + agent := &acp.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: req.Name, + Namespace: namespace, + }, + Spec: acp.AgentSpec{ + LLMRef: acp.LocalObjectReference{Name: req.LLM}, + System: req.SystemPrompt, + MCPServers: mcpServerRefs, + }, + } + + if err := s.client.Create(ctx, agent); err != nil { + logger.Error(err, "Failed to create agent", "name", req.Name) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to create agent: " + err.Error()}) + return + } + + // Return success response + c.JSON(http.StatusCreated, AgentResponse{ + Namespace: namespace, + Name: req.Name, + LLM: req.LLM, + SystemPrompt: req.SystemPrompt, + MCPServers: req.MCPServers, + }) +} + +// listAgents handles the GET /agents endpoint to list all agents in a namespace +func (s *APIServer) listAgents(c *gin.Context) { + ctx := c.Request.Context() + logger := log.FromContext(ctx) + + // Get namespace from query parameter (required) + namespace := c.Query("namespace") + if namespace == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "namespace query parameter is required"}) + return + } + + // List all agents in the namespace + var agentList acp.AgentList + if err := s.client.List(ctx, &agentList, client.InNamespace(namespace)); err != nil { + logger.Error(err, "Failed to list agents", "namespace", namespace) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to list agents: " + err.Error()}) + return + } + + // Transform to response format + response := []AgentResponse{} + for _, agent := range agentList.Items { + // Fetch MCP server details for each agent + mcpServers, err := s.fetchMCPServers(ctx, namespace, agent.Spec.MCPServers) + if err != nil { + logger.Error(err, "Failed to fetch MCP servers for agent", "agent", agent.Name) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch MCP servers: " + err.Error()}) + return + } + + response = append(response, AgentResponse{ + Namespace: namespace, + Name: agent.Name, + LLM: agent.Spec.LLMRef.Name, + SystemPrompt: agent.Spec.System, + MCPServers: mcpServers, + }) + } + + c.JSON(http.StatusOK, response) +} + +// getAgent handles the GET /agents/:name endpoint to get a specific agent by name +func (s *APIServer) getAgent(c *gin.Context) { + ctx := c.Request.Context() + logger := log.FromContext(ctx) + + // Get namespace from query parameter (required) + namespace := c.Query("namespace") + if namespace == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "namespace query parameter is required"}) + return + } + + // Get agent name from path parameter + name := c.Param("name") + if name == "" { + c.JSON(http.StatusBadRequest, gin.H{"error": "agent name is required"}) + return + } + + // Get the agent + var agent acp.Agent + if err := s.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, &agent); err != nil { + if apierrors.IsNotFound(err) { + c.JSON(http.StatusNotFound, gin.H{"error": "Agent not found"}) + return + } + logger.Error(err, "Failed to get agent", "name", name, "namespace", namespace) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to get agent: " + err.Error()}) + return + } + + // Fetch MCP server details + mcpServers, err := s.fetchMCPServers(ctx, namespace, agent.Spec.MCPServers) + if err != nil { + logger.Error(err, "Failed to fetch MCP servers for agent", "agent", agent.Name) + c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to fetch MCP servers: " + err.Error()}) + return + } + + // Return the response + c.JSON(http.StatusOK, AgentResponse{ + Namespace: namespace, + Name: agent.Name, + LLM: agent.Spec.LLMRef.Name, + SystemPrompt: agent.Spec.System, + MCPServers: mcpServers, + }) } // Start begins listening for requests in a goroutine @@ -156,6 +458,166 @@ func (s *APIServer) getTask(c *gin.Context) { c.JSON(http.StatusOK, task) } +// Helper functions for Agent API +// resourceExists checks if a resource with the given name exists in the specified namespace +func (s *APIServer) resourceExists(ctx context.Context, obj client.Object, namespace, name string) (bool, error) { + err := s.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: name}, obj) + if err != nil { + if apierrors.IsNotFound(err) { + return false, nil + } + return false, err + } + return true, nil +} + +// validateMCPConfig validates an MCP server configuration +// Defaults to "stdio" transport if not specified +func validateMCPConfig(config MCPServerConfig) error { + // Default to stdio transport if not specified + transport := config.Transport + if transport == "" { + transport = "stdio" + } + + // Validate the transport type + if transport != "stdio" && transport != "http" { + return fmt.Errorf("invalid transport: %s", transport) + } + + // Validate transport-specific requirements + if transport == "stdio" && (config.Command == "" || len(config.Args) == 0) { + return fmt.Errorf("command and args required for stdio transport") + } + if transport == "http" && config.URL == "" { + return fmt.Errorf("url required for http transport") + } + + return nil +} + +// createSecret creates a Kubernetes Secret from a map of secret values +func createSecret(name, namespace string, secrets map[string]string) *corev1.Secret { + data := make(map[string][]byte) + for k, v := range secrets { + data[k] = []byte(v) + } + return &corev1.Secret{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Data: data, + } +} + +// createMCPServer creates an MCPServer from a configuration +// Defaults to "stdio" transport if not specified +func createMCPServer(name, namespace string, config MCPServerConfig, secretName string) *acp.MCPServer { + env := []acp.EnvVar{} + + // Add regular environment variables + for k, v := range config.Env { + env = append(env, acp.EnvVar{ + Name: k, + Value: v, + }) + } + + // Add secret references + for k := range config.Secrets { + env = append(env, acp.EnvVar{ + Name: k, + ValueFrom: &acp.EnvVarSource{ + SecretKeyRef: &acp.SecretKeyRef{ + Name: secretName, + Key: k, + }, + }, + }) + } + + // Default to stdio transport if not specified + transport := config.Transport + if transport == "" { + transport = "stdio" + } + + return &acp.MCPServer{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: acp.MCPServerSpec{ + Transport: transport, + Command: config.Command, + Args: config.Args, + URL: config.URL, + Env: env, + }, + } +} + +// fetchMCPServers retrieves MCP servers and their configurations +func (s *APIServer) fetchMCPServers(ctx context.Context, namespace string, refs []acp.LocalObjectReference) (map[string]MCPServerConfig, error) { + result := make(map[string]MCPServerConfig) + + for _, ref := range refs { + var mcpServer acp.MCPServer + if err := s.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref.Name}, &mcpServer); err != nil { + return nil, err + } + + // Extract key from MCP server name (assuming it follows the pattern: {agent-name}-{key}) + parts := strings.Split(ref.Name, "-") + key := parts[len(parts)-1] + + // Initialize config + config := MCPServerConfig{ + Transport: mcpServer.Spec.Transport, + Command: mcpServer.Spec.Command, + Args: mcpServer.Spec.Args, + URL: mcpServer.Spec.URL, + Env: map[string]string{}, + Secrets: map[string]string{}, + } + + // Process environment variables and secrets + for _, envVar := range mcpServer.Spec.Env { + if envVar.Value != "" { + // Regular environment variable + config.Env[envVar.Name] = envVar.Value + } else if envVar.ValueFrom != nil && envVar.ValueFrom.SecretKeyRef != nil { + // Secret reference + secretRef := envVar.ValueFrom.SecretKeyRef + var secret corev1.Secret + if err := s.client.Get(ctx, client.ObjectKey{ + Namespace: namespace, + Name: secretRef.Name, + }, &secret); err != nil { + return nil, err + } + + if val, ok := secret.Data[secretRef.Key]; ok { + config.Secrets[envVar.Name] = string(val) + } + } + } + + result[key] = config + } + + return result, nil +} + +// defaultIfEmpty returns the default value if the input is empty +func defaultIfEmpty(val, defaultVal string) string { + if val == "" { + return defaultVal + } + return val +} + // createTask handles the creation of a new task func (s *APIServer) createTask(c *gin.Context) { ctx := c.Request.Context() diff --git a/acp/internal/server/server_test.go b/acp/internal/server/server_test.go index 4b09d37..3cd8d20 100644 --- a/acp/internal/server/server_test.go +++ b/acp/internal/server/server_test.go @@ -51,6 +51,22 @@ var _ = Describe("API Server", func() { router = server.router recorder = httptest.NewRecorder() }) + + // Helper function to create an LLM for tests + createTestLLM := func(name, namespace string) *acp.LLM { + llm := &acp.LLM{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: acp.LLMSpec{ + Provider: "test-provider", + Model: "test-model", + }, + } + Expect(k8sClient.Create(ctx, llm)).To(Succeed()) + return llm + } Describe("POST /v1/tasks", func() { It("should create a new task with valid input", func() { @@ -342,3 +358,509 @@ func (e *errorK8sClient) Create(ctx context.Context, obj client.Object, opts ... func (e *errorK8sClient) Get(ctx context.Context, key client.ObjectKey, obj client.Object, opts ...client.GetOption) error { return e.Client.Get(ctx, key, obj, opts...) } + +// Tests for Agent API endpoints +var _ = Describe("Agent API", func() { + var ( + ctx context.Context + k8sClient client.Client + server *APIServer + router *gin.Engine + recorder *httptest.ResponseRecorder + ) + + BeforeEach(func() { + ctx = context.Background() + // Create a scheme with our API types registered + scheme := runtime.NewScheme() + Expect(corev1.AddToScheme(scheme)).To(Succeed()) + Expect(acp.AddToScheme(scheme)).To(Succeed()) + + // Create a fake client + k8sClient = fake.NewClientBuilder().WithScheme(scheme).Build() + + // Create the API server with the client + server = NewAPIServer(k8sClient, ":8080") + router = server.router + recorder = httptest.NewRecorder() + }) + + // Helper function to create an LLM for tests + createTestLLM := func(name, namespace string) *acp.LLM { + llm := &acp.LLM{ + ObjectMeta: metav1.ObjectMeta{ + Name: name, + Namespace: namespace, + }, + Spec: acp.LLMSpec{ + Provider: "test-provider", + Model: "test-model", + }, + } + Expect(k8sClient.Create(ctx, llm)).To(Succeed()) + return llm + } + + Describe("POST /v1/agents", func() { + It("should create a new agent with valid input", func() { + // Create an LLM first + createTestLLM("test-llm", "default") + + // Create the request body + reqBody := CreateAgentRequest{ + Name: "test-agent", + LLM: "test-llm", + SystemPrompt: "You are a test agent", + } + jsonBody, err := json.Marshal(reqBody) + Expect(err).NotTo(HaveOccurred()) + + // Create a test request + req := httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + // Serve the request + router.ServeHTTP(recorder, req) + + // Verify the response + Expect(recorder.Code).To(Equal(http.StatusCreated)) + + // Parse the response + var response AgentResponse + err = json.Unmarshal(recorder.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + + // Verify the agent was created with expected values + Expect(response.Name).To(Equal("test-agent")) + Expect(response.LLM).To(Equal("test-llm")) + Expect(response.SystemPrompt).To(Equal("You are a test agent")) + Expect(response.Namespace).To(Equal("default")) + + // Verify agent is in the Kubernetes store + var storedAgent acp.Agent + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "test-agent", + Namespace: "default", + }, &storedAgent)).To(Succeed()) + }) + + It("should create an agent with MCP servers", func() { + // Create an LLM first + createTestLLM("test-llm-2", "default") + + // Create the request body with MCP servers + reqBody := CreateAgentRequest{ + Name: "test-agent-mcp", + LLM: "test-llm-2", + SystemPrompt: "You are a test agent with MCP servers", + MCPServers: map[string]MCPServerConfig{ + "stdio": { + Transport: "stdio", + Command: "python", + Args: []string{"-m", "test_script.py"}, + Env: map[string]string{"TEST_ENV": "value"}, + Secrets: map[string]string{"API_KEY": "test-key"}, + }, + "http": { + Transport: "http", + URL: "http://localhost:8000", + Env: map[string]string{"SERVER_URL": "value"}, + }, + }, + } + jsonBody, err := json.Marshal(reqBody) + Expect(err).NotTo(HaveOccurred()) + + // Create a test request + req := httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + + // Serve the request + router.ServeHTTP(recorder, req) + + // Verify the response + Expect(recorder.Code).To(Equal(http.StatusCreated)) + + // Parse the response + var response AgentResponse + err = json.Unmarshal(recorder.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + + // Verify the agent was created with expected values + Expect(response.Name).To(Equal("test-agent-mcp")) + Expect(response.MCPServers).To(HaveLen(2)) + Expect(response.MCPServers).To(HaveKey("stdio")) + Expect(response.MCPServers).To(HaveKey("http")) + + // Verify MCP servers are in the Kubernetes store + var stdioMCP acp.MCPServer + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "test-agent-mcp-stdio", + Namespace: "default", + }, &stdioMCP)).To(Succeed()) + + var httpMCP acp.MCPServer + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "test-agent-mcp-http", + Namespace: "default", + }, &httpMCP)).To(Succeed()) + + // Verify secret was created + var secret corev1.Secret + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "test-agent-mcp-stdio-secrets", + Namespace: "default", + }, &secret)).To(Succeed()) + Expect(string(secret.Data["API_KEY"])).To(Equal("test-key")) + }) + + It("should validate required fields", func() { + // Missing name + reqBody := CreateAgentRequest{ + LLM: "test-llm", + SystemPrompt: "Test prompt", + } + jsonBody, err := json.Marshal(reqBody) + Expect(err).NotTo(HaveOccurred()) + + req := httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusBadRequest)) + var errorResponse map[string]string + err = json.Unmarshal(recorder.Body.Bytes(), &errorResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(errorResponse["error"]).To(Equal("name, llm, and systemPrompt are required")) + + // Missing LLM + reqBody = CreateAgentRequest{ + Name: "test-agent", + SystemPrompt: "Test prompt", + } + jsonBody, err = json.Marshal(reqBody) + Expect(err).NotTo(HaveOccurred()) + + req = httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusBadRequest)) + err = json.Unmarshal(recorder.Body.Bytes(), &errorResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(errorResponse["error"]).To(Equal("name, llm, and systemPrompt are required")) + }) + + It("should return 404 if the LLM does not exist", func() { + reqBody := CreateAgentRequest{ + Name: "test-agent", + LLM: "non-existent-llm", + SystemPrompt: "Test prompt", + } + jsonBody, err := json.Marshal(reqBody) + Expect(err).NotTo(HaveOccurred()) + + req := httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusNotFound)) + var errorResponse map[string]string + err = json.Unmarshal(recorder.Body.Bytes(), &errorResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(errorResponse["error"]).To(Equal("LLM not found")) + }) + + It("should validate MCP server configurations", func() { + // Create an LLM first + createTestLLM("test-llm-4", "default") + + // Test with invalid transport type + reqBody := CreateAgentRequest{ + Name: "test-agent-invalid-mcp", + LLM: "test-llm-4", + SystemPrompt: "Test agent", + MCPServers: map[string]MCPServerConfig{ + "invalid": { + Transport: "invalid-transport", // Not "stdio" or "http" + Command: "python", + Args: []string{"-m", "script.py"}, + }, + }, + } + jsonBody, err := json.Marshal(reqBody) + Expect(err).NotTo(HaveOccurred()) + + req := httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusBadRequest)) + var errorResponse map[string]string + err = json.Unmarshal(recorder.Body.Bytes(), &errorResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(errorResponse["error"]).To(ContainSubstring("invalid transport")) + + // Test without transport (should default to stdio) + reqBody = CreateAgentRequest{ + Name: "test-agent-default-transport", + LLM: "test-llm-4", + SystemPrompt: "Test agent", + MCPServers: map[string]MCPServerConfig{ + "default": { + // No transport specified (should default to stdio) + Command: "python", + Args: []string{"-m", "script.py"}, + }, + }, + } + jsonBody, err = json.Marshal(reqBody) + Expect(err).NotTo(HaveOccurred()) + + req = httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + // Should succeed with default stdio transport + Expect(recorder.Code).To(Equal(http.StatusCreated)) + + // Verify MCP server is in Kubernetes with stdio transport + var mcpServer acp.MCPServer + Expect(k8sClient.Get(ctx, types.NamespacedName{ + Name: "test-agent-default-transport-default", + Namespace: "default", + }, &mcpServer)).To(Succeed()) + Expect(mcpServer.Spec.Transport).To(Equal("stdio")) + + // Test missing command for stdio transport + reqBody = CreateAgentRequest{ + Name: "test-agent-missing-command", + LLM: "test-llm-4", + SystemPrompt: "Test agent", + MCPServers: map[string]MCPServerConfig{ + "stdio": { + Transport: "stdio", + // Missing command and args + }, + }, + } + jsonBody, err = json.Marshal(reqBody) + Expect(err).NotTo(HaveOccurred()) + + req = httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusBadRequest)) + err = json.Unmarshal(recorder.Body.Bytes(), &errorResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(errorResponse["error"]).To(ContainSubstring("command and args required")) + + // Test missing URL for http transport + reqBody = CreateAgentRequest{ + Name: "test-agent-missing-url", + LLM: "test-llm-4", + SystemPrompt: "Test agent", + MCPServers: map[string]MCPServerConfig{ + "http": { + Transport: "http", + // Missing URL + }, + }, + } + jsonBody, err = json.Marshal(reqBody) + Expect(err).NotTo(HaveOccurred()) + + req = httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusBadRequest)) + err = json.Unmarshal(recorder.Body.Bytes(), &errorResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(errorResponse["error"]).To(ContainSubstring("url required")) + }) + + It("should return 409 if the agent already exists", func() { + // Create an LLM + createTestLLM("test-llm-3", "default") + + // Create an agent + agent := &acp.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "existing-agent", + Namespace: "default", + }, + Spec: acp.AgentSpec{ + LLMRef: acp.LocalObjectReference{Name: "test-llm-3"}, + System: "Existing agent", + }, + } + Expect(k8sClient.Create(ctx, agent)).To(Succeed()) + + // Try to create the same agent again + reqBody := CreateAgentRequest{ + Name: "existing-agent", + LLM: "test-llm-3", + SystemPrompt: "Test prompt", + } + jsonBody, err := json.Marshal(reqBody) + Expect(err).NotTo(HaveOccurred()) + + req := httptest.NewRequest(http.MethodPost, "/v1/agents", bytes.NewBuffer(jsonBody)) + req.Header.Set("Content-Type", "application/json") + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusConflict)) + var errorResponse map[string]string + err = json.Unmarshal(recorder.Body.Bytes(), &errorResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(errorResponse["error"]).To(Equal("Agent already exists")) + }) + }) + + Describe("GET /v1/agents", func() { + It("should return a list of agents", func() { + // Create namespace + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "test-namespace"}, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + + // Create an LLM + createTestLLM("test-llm-5", "test-namespace") + + // Create a few agents + agent1 := &acp.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-agent-1", + Namespace: "test-namespace", + }, + Spec: acp.AgentSpec{ + LLMRef: acp.LocalObjectReference{Name: "test-llm-5"}, + System: "Agent 1", + }, + } + Expect(k8sClient.Create(ctx, agent1)).To(Succeed()) + + agent2 := &acp.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "test-agent-2", + Namespace: "test-namespace", + }, + Spec: acp.AgentSpec{ + LLMRef: acp.LocalObjectReference{Name: "test-llm-5"}, + System: "Agent 2", + }, + } + Expect(k8sClient.Create(ctx, agent2)).To(Succeed()) + + // Make the request + req := httptest.NewRequest(http.MethodGet, "/v1/agents?namespace=test-namespace", nil) + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + // Verify the response + Expect(recorder.Code).To(Equal(http.StatusOK)) + + // Parse the response + var response []AgentResponse + err := json.Unmarshal(recorder.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + + // Verify the response contains the agents + Expect(response).To(HaveLen(2)) + agentNames := []string{response[0].Name, response[1].Name} + Expect(agentNames).To(ContainElements("test-agent-1", "test-agent-2")) + }) + + It("should require a namespace parameter", func() { + req := httptest.NewRequest(http.MethodGet, "/v1/agents", nil) + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusBadRequest)) + var errorResponse map[string]string + err := json.Unmarshal(recorder.Body.Bytes(), &errorResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(errorResponse["error"]).To(Equal("namespace query parameter is required")) + }) + }) + + Describe("GET /v1/agents/:name", func() { + It("should return a specific agent", func() { + // Create namespace + namespace := &corev1.Namespace{ + ObjectMeta: metav1.ObjectMeta{Name: "get-namespace"}, + } + Expect(k8sClient.Create(ctx, namespace)).To(Succeed()) + + // Create an LLM + createTestLLM("get-llm", "get-namespace") + + // Create an agent + agent := &acp.Agent{ + ObjectMeta: metav1.ObjectMeta{ + Name: "get-agent", + Namespace: "get-namespace", + }, + Spec: acp.AgentSpec{ + LLMRef: acp.LocalObjectReference{Name: "get-llm"}, + System: "Get Agent", + }, + } + Expect(k8sClient.Create(ctx, agent)).To(Succeed()) + + // Make the request + req := httptest.NewRequest(http.MethodGet, "/v1/agents/get-agent?namespace=get-namespace", nil) + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + // Verify the response + Expect(recorder.Code).To(Equal(http.StatusOK)) + + // Parse the response + var response AgentResponse + err := json.Unmarshal(recorder.Body.Bytes(), &response) + Expect(err).NotTo(HaveOccurred()) + + // Verify the agent details + Expect(response.Name).To(Equal("get-agent")) + Expect(response.Namespace).To(Equal("get-namespace")) + Expect(response.LLM).To(Equal("get-llm")) + Expect(response.SystemPrompt).To(Equal("Get Agent")) + }) + + It("should return 404 for non-existent agent", func() { + req := httptest.NewRequest(http.MethodGet, "/v1/agents/non-existent-agent?namespace=default", nil) + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusNotFound)) + var errorResponse map[string]string + err := json.Unmarshal(recorder.Body.Bytes(), &errorResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(errorResponse["error"]).To(Equal("Agent not found")) + }) + + It("should require a namespace parameter", func() { + req := httptest.NewRequest(http.MethodGet, "/v1/agents/some-agent", nil) + recorder = httptest.NewRecorder() + router.ServeHTTP(recorder, req) + + Expect(recorder.Code).To(Equal(http.StatusBadRequest)) + var errorResponse map[string]string + err := json.Unmarshal(recorder.Body.Bytes(), &errorResponse) + Expect(err).NotTo(HaveOccurred()) + Expect(errorResponse["error"]).To(Equal("namespace query parameter is required")) + }) + }) +}) From 5acff5826a69a019850e20d3060d15fbc62a068d Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 14 May 2025 13:03:21 -0700 Subject: [PATCH 2/4] formatting --- acp/internal/server/server.go | 62 +++++++++++++++++------------------ 1 file changed, 31 insertions(+), 31 deletions(-) diff --git a/acp/internal/server/server.go b/acp/internal/server/server.go index 8499ae1..c705869 100644 --- a/acp/internal/server/server.go +++ b/acp/internal/server/server.go @@ -14,8 +14,8 @@ import ( acp "github.com/humanlayer/agentcontrolplane/acp/api/v1alpha1" "github.com/humanlayer/agentcontrolplane/acp/internal/validation" "github.com/pkg/errors" - apierrors "k8s.io/apimachinery/pkg/api/errors" corev1 "k8s.io/api/core/v1" + apierrors "k8s.io/apimachinery/pkg/api/errors" metav1 "k8s.io/apimachinery/pkg/apis/meta/v1" "sigs.k8s.io/controller-runtime/pkg/client" "sigs.k8s.io/controller-runtime/pkg/log" @@ -33,30 +33,30 @@ type CreateTaskRequest struct { // CreateAgentRequest defines the structure of the request body for creating an agent type CreateAgentRequest struct { - Namespace string `json:"namespace,omitempty"` // Optional, defaults to "default" - Name string `json:"name"` // Required - LLM string `json:"llm"` // Required - SystemPrompt string `json:"systemPrompt"` // Required - MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` // Optional + Namespace string `json:"namespace,omitempty"` // Optional, defaults to "default" + Name string `json:"name"` // Required + LLM string `json:"llm"` // Required + SystemPrompt string `json:"systemPrompt"` // Required + MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` // Optional } // MCPServerConfig defines the configuration for an MCP server type MCPServerConfig struct { - Transport string `json:"transport"` // Required: "stdio" or "http" - Command string `json:"command,omitempty"` // Required for stdio transport - Args []string `json:"args,omitempty"` // Required for stdio transport - URL string `json:"url,omitempty"` // Required for http transport - Env map[string]string `json:"env,omitempty"` // Optional environment variables - Secrets map[string]string `json:"secrets,omitempty"` // Optional secrets + Transport string `json:"transport"` // Required: "stdio" or "http" + Command string `json:"command,omitempty"` // Required for stdio transport + Args []string `json:"args,omitempty"` // Required for stdio transport + URL string `json:"url,omitempty"` // Required for http transport + Env map[string]string `json:"env,omitempty"` // Optional environment variables + Secrets map[string]string `json:"secrets,omitempty"` // Optional secrets } // AgentResponse defines the structure of the response body for agent endpoints type AgentResponse struct { - Namespace string `json:"namespace"` - Name string `json:"name"` - LLM string `json:"llm"` - SystemPrompt string `json:"systemPrompt"` - MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` + Namespace string `json:"namespace"` + Name string `json:"name"` + LLM string `json:"llm"` + SystemPrompt string `json:"systemPrompt"` + MCPServers map[string]MCPServerConfig `json:"mcpServers,omitempty"` } // APIServer represents the REST API server @@ -479,12 +479,12 @@ func validateMCPConfig(config MCPServerConfig) error { if transport == "" { transport = "stdio" } - + // Validate the transport type if transport != "stdio" && transport != "http" { return fmt.Errorf("invalid transport: %s", transport) } - + // Validate transport-specific requirements if transport == "stdio" && (config.Command == "" || len(config.Args) == 0) { return fmt.Errorf("command and args required for stdio transport") @@ -492,7 +492,7 @@ func validateMCPConfig(config MCPServerConfig) error { if transport == "http" && config.URL == "" { return fmt.Errorf("url required for http transport") } - + return nil } @@ -515,7 +515,7 @@ func createSecret(name, namespace string, secrets map[string]string) *corev1.Sec // Defaults to "stdio" transport if not specified func createMCPServer(name, namespace string, config MCPServerConfig, secretName string) *acp.MCPServer { env := []acp.EnvVar{} - + // Add regular environment variables for k, v := range config.Env { env = append(env, acp.EnvVar{ @@ -523,7 +523,7 @@ func createMCPServer(name, namespace string, config MCPServerConfig, secretName Value: v, }) } - + // Add secret references for k := range config.Secrets { env = append(env, acp.EnvVar{ @@ -536,13 +536,13 @@ func createMCPServer(name, namespace string, config MCPServerConfig, secretName }, }) } - + // Default to stdio transport if not specified transport := config.Transport if transport == "" { transport = "stdio" } - + return &acp.MCPServer{ ObjectMeta: metav1.ObjectMeta{ Name: name, @@ -561,17 +561,17 @@ func createMCPServer(name, namespace string, config MCPServerConfig, secretName // fetchMCPServers retrieves MCP servers and their configurations func (s *APIServer) fetchMCPServers(ctx context.Context, namespace string, refs []acp.LocalObjectReference) (map[string]MCPServerConfig, error) { result := make(map[string]MCPServerConfig) - + for _, ref := range refs { var mcpServer acp.MCPServer if err := s.client.Get(ctx, client.ObjectKey{Namespace: namespace, Name: ref.Name}, &mcpServer); err != nil { return nil, err } - + // Extract key from MCP server name (assuming it follows the pattern: {agent-name}-{key}) parts := strings.Split(ref.Name, "-") key := parts[len(parts)-1] - + // Initialize config config := MCPServerConfig{ Transport: mcpServer.Spec.Transport, @@ -581,7 +581,7 @@ func (s *APIServer) fetchMCPServers(ctx context.Context, namespace string, refs Env: map[string]string{}, Secrets: map[string]string{}, } - + // Process environment variables and secrets for _, envVar := range mcpServer.Spec.Env { if envVar.Value != "" { @@ -597,16 +597,16 @@ func (s *APIServer) fetchMCPServers(ctx context.Context, namespace string, refs }, &secret); err != nil { return nil, err } - + if val, ok := secret.Data[secretRef.Key]; ok { config.Secrets[envVar.Name] = string(val) } } } - + result[key] = config } - + return result, nil } From 79cc9665704087c62df161685fc74ad1f0118e03 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 14 May 2025 13:03:42 -0700 Subject: [PATCH 3/4] fix model name mismatch type in test --- acp/internal/server/server_test.go | 24 ++++++++++++++---------- 1 file changed, 14 insertions(+), 10 deletions(-) diff --git a/acp/internal/server/server_test.go b/acp/internal/server/server_test.go index 3cd8d20..9a838b3 100644 --- a/acp/internal/server/server_test.go +++ b/acp/internal/server/server_test.go @@ -51,7 +51,7 @@ var _ = Describe("API Server", func() { router = server.router recorder = httptest.NewRecorder() }) - + // Helper function to create an LLM for tests createTestLLM := func(name, namespace string) *acp.LLM { llm := &acp.LLM{ @@ -61,7 +61,9 @@ var _ = Describe("API Server", func() { }, Spec: acp.LLMSpec{ Provider: "test-provider", - Model: "test-model", + Parameters: acp.BaseConfig{ + Model: "test-model", + }, }, } Expect(k8sClient.Create(ctx, llm)).To(Succeed()) @@ -394,7 +396,9 @@ var _ = Describe("Agent API", func() { }, Spec: acp.LLMSpec{ Provider: "test-provider", - Model: "test-model", + Parameters: acp.BaseConfig{ + Model: "test-model", + }, }, } Expect(k8sClient.Create(ctx, llm)).To(Succeed()) @@ -577,7 +581,7 @@ var _ = Describe("Agent API", func() { It("should validate MCP server configurations", func() { // Create an LLM first createTestLLM("test-llm-4", "default") - + // Test with invalid transport type reqBody := CreateAgentRequest{ Name: "test-agent-invalid-mcp", @@ -585,7 +589,7 @@ var _ = Describe("Agent API", func() { SystemPrompt: "Test agent", MCPServers: map[string]MCPServerConfig{ "invalid": { - Transport: "invalid-transport", // Not "stdio" or "http" + Transport: "invalid-transport", // Not "stdio" or "http" Command: "python", Args: []string{"-m", "script.py"}, }, @@ -604,7 +608,7 @@ var _ = Describe("Agent API", func() { err = json.Unmarshal(recorder.Body.Bytes(), &errorResponse) Expect(err).NotTo(HaveOccurred()) Expect(errorResponse["error"]).To(ContainSubstring("invalid transport")) - + // Test without transport (should default to stdio) reqBody = CreateAgentRequest{ Name: "test-agent-default-transport", @@ -628,7 +632,7 @@ var _ = Describe("Agent API", func() { // Should succeed with default stdio transport Expect(recorder.Code).To(Equal(http.StatusCreated)) - + // Verify MCP server is in Kubernetes with stdio transport var mcpServer acp.MCPServer Expect(k8sClient.Get(ctx, types.NamespacedName{ @@ -636,7 +640,7 @@ var _ = Describe("Agent API", func() { Namespace: "default", }, &mcpServer)).To(Succeed()) Expect(mcpServer.Spec.Transport).To(Equal("stdio")) - + // Test missing command for stdio transport reqBody = CreateAgentRequest{ Name: "test-agent-missing-command", @@ -661,7 +665,7 @@ var _ = Describe("Agent API", func() { err = json.Unmarshal(recorder.Body.Bytes(), &errorResponse) Expect(err).NotTo(HaveOccurred()) Expect(errorResponse["error"]).To(ContainSubstring("command and args required")) - + // Test missing URL for http transport reqBody = CreateAgentRequest{ Name: "test-agent-missing-url", @@ -687,7 +691,7 @@ var _ = Describe("Agent API", func() { Expect(err).NotTo(HaveOccurred()) Expect(errorResponse["error"]).To(ContainSubstring("url required")) }) - + It("should return 409 if the agent already exists", func() { // Create an LLM createTestLLM("test-llm-3", "default") From c03899a0e8bbfb36338d8e93b884d0869a992847 Mon Sep 17 00:00:00 2001 From: Allison Durham Date: Wed, 14 May 2025 13:09:47 -0700 Subject: [PATCH 4/4] fix tests to be happy --- .../controller/task/send_response_url_test.go | 24 ++++++++++++++++--- acp/internal/server/server_test.go | 18 -------------- 2 files changed, 21 insertions(+), 21 deletions(-) diff --git a/acp/internal/controller/task/send_response_url_test.go b/acp/internal/controller/task/send_response_url_test.go index b7acfae..4a2cfa7 100644 --- a/acp/internal/controller/task/send_response_url_test.go +++ b/acp/internal/controller/task/send_response_url_test.go @@ -76,7 +76,13 @@ var _ = Describe("ResponseURL Functionality", func() { // Test sending result testMsg := "This is the final task result" - err := reconciler.sendFinalResultToResponseURL(ctx, server.URL, testMsg) + testTask := &acp.Task{ + Spec: acp.TaskSpec{ + ResponseURL: server.URL, + AgentRef: acp.LocalObjectReference{Name: "test-agent"}, + }, + } + err := reconciler.sendFinalResultToResponseURL(ctx, testTask, testMsg) Expect(err).NotTo(HaveOccurred()) // Wait for the request to be processed with a timeout @@ -106,7 +112,13 @@ var _ = Describe("ResponseURL Functionality", func() { reconciler, ctx := initTestReconciler() // Test sending result - err := reconciler.sendFinalResultToResponseURL(ctx, server.URL, "test message") + testTask := &acp.Task{ + Spec: acp.TaskSpec{ + ResponseURL: server.URL, + AgentRef: acp.LocalObjectReference{Name: "test-agent"}, + }, + } + err := reconciler.sendFinalResultToResponseURL(ctx, testTask, "test message") // Should return an error due to non-200 response Expect(err).To(HaveOccurred()) @@ -118,7 +130,13 @@ var _ = Describe("ResponseURL Functionality", func() { reconciler, ctx := initTestReconciler() // Use an invalid URL to cause a connection error - err := reconciler.sendFinalResultToResponseURL(ctx, "http://localhost:1", "test message") + testTask := &acp.Task{ + Spec: acp.TaskSpec{ + ResponseURL: "http://localhost:1", + AgentRef: acp.LocalObjectReference{Name: "test-agent"}, + }, + } + err := reconciler.sendFinalResultToResponseURL(ctx, testTask, "test message") // Should return an error due to connection failure Expect(err).To(HaveOccurred()) diff --git a/acp/internal/server/server_test.go b/acp/internal/server/server_test.go index 9a838b3..c34fefc 100644 --- a/acp/internal/server/server_test.go +++ b/acp/internal/server/server_test.go @@ -52,24 +52,6 @@ var _ = Describe("API Server", func() { recorder = httptest.NewRecorder() }) - // Helper function to create an LLM for tests - createTestLLM := func(name, namespace string) *acp.LLM { - llm := &acp.LLM{ - ObjectMeta: metav1.ObjectMeta{ - Name: name, - Namespace: namespace, - }, - Spec: acp.LLMSpec{ - Provider: "test-provider", - Parameters: acp.BaseConfig{ - Model: "test-model", - }, - }, - } - Expect(k8sClient.Create(ctx, llm)).To(Succeed()) - return llm - } - Describe("POST /v1/tasks", func() { It("should create a new task with valid input", func() { // Create an agent first