From 84cc3fa80bcd9d5a78518f53f86f01e0353a34ba Mon Sep 17 00:00:00 2001 From: trangevi Date: Fri, 24 Oct 2025 15:21:53 -0700 Subject: [PATCH 01/12] Update yaml objects to match maml changes. Update other files to adapt to changes. Signed-off-by: trangevi --- .../internal/cmd/init.go | 33 +- .../internal/pkg/agents/agent_yaml/map.go | 177 +++--- .../internal/pkg/agents/agent_yaml/parse.go | 87 ++- .../{parse_test.go => parse_test.go.old} | 0 ...test.go => sample_integration_test.go.old} | 0 .../internal/pkg/agents/agent_yaml/yaml.go | 520 +++++++++--------- .../pkg/agents/agent_yaml/yaml.go.old | 389 +++++++++++++ .../pkg/agents/agent_yaml/yaml_test.go | 20 +- .../pkg/agents/registry_api/helpers.go | 66 ++- .../internal/project/parser.go | 5 +- .../internal/project/service_target_agent.go | 42 +- 11 files changed, 900 insertions(+), 439 deletions(-) rename cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/{parse_test.go => parse_test.go.old} (100%) rename cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/{sample_integration_test.go => sample_integration_test.go.old} (100%) create mode 100644 cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go.old diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go index c75eaab8049..6b90441f46f 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go @@ -701,7 +701,8 @@ func (a *InitAction) downloadAgentYaml( return nil, "", fmt.Errorf("marshaling agent manifest to YAML after parameter processing: %w", err) } - agentId := agentManifest.Agent.Name + agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + agentId := agentDef.Name // Use targetDir if provided or set to local file pointer, otherwise default to "src/{agentId}" if targetDir == "" { @@ -719,7 +720,7 @@ func (a *InitAction) downloadAgentYaml( return nil, "", fmt.Errorf("saving file to %s: %w", filePath, err) } - if isGitHubUrl && agentManifest.Agent.Kind == agent_yaml.AgentKindHosted { + if isGitHubUrl && agentDef.Kind == agent_yaml.AgentKindHosted { // For hosted agents, download the entire parent directory fmt.Println("Downloading full directory for hosted agent") err := downloadParentDirectory(ctx, urlInfo, targetDir, ghCli, console) @@ -735,7 +736,8 @@ func (a *InitAction) downloadAgentYaml( func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentManifest *agent_yaml.AgentManifest) error { var host string - switch agentManifest.Agent.Kind { + agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + switch agentDef.Kind { case "container": host = "containerapp" default: @@ -743,7 +745,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa } serviceConfig := &azdext.ServiceConfig{ - Name: agentManifest.Agent.Name, + Name: agentDef.Name, RelativePath: targetDir, Host: host, Language: "python", @@ -755,7 +757,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa return fmt.Errorf("adding agent service to project: %w", err) } - fmt.Printf("Added service '%s' to azure.yaml\n", agentManifest.Agent.Name) + fmt.Printf("Added service '%s' to azure.yaml\n", agentDef.Name) return nil } @@ -1222,7 +1224,8 @@ func downloadDirectoryContents( // } func (a *InitAction) updateEnvironment(ctx context.Context, agentManifest *agent_yaml.AgentManifest) error { - fmt.Printf("Updating environment variables for agent kind: %s\n", agentManifest.Agent.Kind) + agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + fmt.Printf("Updating environment variables for agent kind: %s\n", agentDef.Kind) // Get current environment envResponse, err := a.azdClient.Environment().GetCurrent(ctx, &azdext.EmptyRequest{}) @@ -1237,25 +1240,25 @@ func (a *InitAction) updateEnvironment(ctx context.Context, agentManifest *agent envName := envResponse.Environment.Name // Set environment variables based on agent kind - switch agentManifest.Agent.Kind { - case "hosted": + switch agentDef.Kind { + case agent_yaml.AgentKindPrompt: + agentDef := agentManifest.Template.(agent_yaml.PromptAgent) + if err := a.setEnvVar(ctx, envName, "AZURE_AI_FOUNDRY_MODEL_NAME", agentDef.Model.Id); err != nil { + return err + } + case agent_yaml.AgentKindHosted: // Set environment variables for hosted agents if err := a.setEnvVar(ctx, envName, "ENABLE_HOSTED_AGENTS", "true"); err != nil { return err } - case "container": + case agent_yaml.AgentKindYamlContainerApp: // Set environment variables for foundry agents if err := a.setEnvVar(ctx, envName, "ENABLE_CONTAINER_AGENTS", "true"); err != nil { return err } } - // Model information should be set regardless of agent kind - if err := a.setEnvVar(ctx, envName, "AZURE_AI_FOUNDRY_MODEL_NAME", agentManifest.Agent.Model.Id); err != nil { - return err - } - - fmt.Printf("Successfully updated environment variables for agent kind: %s\n", agentManifest.Agent.Kind) + fmt.Printf("Successfully updated environment variables for agent kind: %s\n", agentDef.Kind) return nil } diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/map.go index 1936962f41c..26764f6fb05 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -92,74 +92,80 @@ func WithEnvironmentVariables(envVars map[string]string) AgentBuildOption { } } -// BuildAgentDefinitionFromManifest constructs an AgentDefinition from the given AgentManifest -// with optional build-time configuration. It returns both the agent definition and build config. -func BuildAgentDefinitionFromManifest(agentManifest AgentManifest, options ...AgentBuildOption) (AgentDefinition, *AgentBuildConfig, error) { - // Apply options +func constructBuildConfig(options ...AgentBuildOption) *AgentBuildConfig { config := &AgentBuildConfig{} for _, option := range options { option(config) } - - // Return the agent definition and build config separately - // The build config will be used later when creating the API request - return agentManifest.Agent, config, nil + return config } // CreateAgentAPIRequestFromManifest creates a CreateAgentRequest from AgentManifest with strong typing func CreateAgentAPIRequestFromManifest(agentManifest AgentManifest, options ...AgentBuildOption) (*agent_api.CreateAgentRequest, error) { - agentDef, buildConfig, err := BuildAgentDefinitionFromManifest(agentManifest, options...) - if err != nil { - return nil, err - } + buildConfig := constructBuildConfig(options...) // Route to appropriate handler based on agent kind + agentDef := agentManifest.Template.(AgentDefinition) switch agentDef.Kind { case AgentKindPrompt: - return CreatePromptAgentAPIRequest(agentDef, buildConfig) + promptDef := agentManifest.Template.(PromptAgent) + return CreatePromptAgentAPIRequest(promptDef, buildConfig) case AgentKindHosted: - return CreateHostedAgentAPIRequest(agentDef, buildConfig) + hostedDef := agentManifest.Template.(HostedContainerAgent) + return CreateHostedAgentAPIRequest(hostedDef, buildConfig) default: return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: prompt, hosted", agentDef.Kind) } } // CreatePromptAgentAPIRequest creates a CreateAgentRequest for prompt-based agents -func CreatePromptAgentAPIRequest(agentDefinition AgentDefinition, buildConfig *AgentBuildConfig) (*agent_api.CreateAgentRequest, error) { - // TODO QUESTION: Should I expect a PromptAgent type instead of AgentDefinition? - // The AgentDefinition has all the fields but PromptAgent might have additional prompt-specific fields - +func CreatePromptAgentAPIRequest(promptAgent PromptAgent, buildConfig *AgentBuildConfig) (*agent_api.CreateAgentRequest, error) { + // Extract model information from the prompt agent + var modelId string + var instructions *string + var temperature *float32 + var topP *float32 + + // Get model ID + if promptAgent.Model.Id != "" { + modelId = promptAgent.Model.Id + } else { + return nil, fmt.Errorf("model.id is required for prompt agents") + } + + // Get instructions + if promptAgent.Instructions != nil { + instructions = promptAgent.Instructions + } + + // Extract temperature and topP from model options if available + if promptAgent.Model.Options != nil { + if promptAgent.Model.Options.Temperature != nil { + tempFloat32 := float32(*promptAgent.Model.Options.Temperature) + temperature = &tempFloat32 + } + if promptAgent.Model.Options.TopP != nil { + tpFloat32 := float32(*promptAgent.Model.Options.TopP) + topP = &tpFloat32 + } + } + promptDef := agent_api.PromptAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ - Kind: agent_api.AgentKindPrompt, // This sets Kind to "prompt" - }, - Model: agentDefinition.Model.Id, // TODO QUESTION: Is Model.Id the right field to use? - Instructions: &agentDefinition.Instructions, - - // TODO QUESTION: How should I map Model.Options to these fields? - // The agent_yaml.Model has ModelOptions with a Kind field, but how do I get: - // - Temperature (float32) - from Model.Options or somewhere else? - // - TopP (float32) - from Model.Options or somewhere else? - // - // Example: if agentDefinition.Model.Options has structured data: - // Temperature: extractFloat32FromOptions(agentDefinition.Model.Options, "temperature"), - // TopP: extractFloat32FromOptions(agentDefinition.Model.Options, "top_p"), - - // TODO QUESTION: How should I map Tools from agent_yaml to agent_api? - // agent_yaml.Tool vs agent_api.Tool - are they compatible or do I need conversion? - // Tools: convertYamlToolsToApiTools(agentDefinition.Tools), - - // TODO QUESTION: What about these advanced fields? - // - Reasoning (*agent_api.Reasoning) - where does this come from in YAML? - // - Text (*agent_api.ResponseTextFormatConfiguration) - related to output format? - // - StructuredInputs (map[string]agent_api.StructuredInputDefinition) - from InputSchema? - // - // Possible mappings: - // Text: mapOutputSchemaToTextFormat(agentDefinition.OutputSchema), - // StructuredInputs: mapInputSchemaToStructuredInputs(agentDefinition.InputSchema), + Kind: agent_api.AgentKindPrompt, + }, + Model: modelId, + Instructions: instructions, + Temperature: temperature, + TopP: topP, + + // TODO: Handle additional fields like Tools, Reasoning, etc. + // Tools: convertYamlToolsToApiTools(promptAgent.Tools), + // Text: mapOutputSchemaToTextFormat(promptAgent.OutputSchema), + // StructuredInputs: mapInputSchemaToStructuredInputs(promptAgent.InputSchema), } - return createAgentAPIRequest(agentDefinition, promptDef) + return createAgentAPIRequest(promptAgent.AgentDefinition, promptDef) } // Helper functions for type conversion (TODO: Implement based on answers to questions above) @@ -179,25 +185,22 @@ func convertYamlToolsToApiTools(yamlTools []Tool) []agent_api.Tool { return nil // Placeholder } -// mapInputSchemaToStructuredInputs converts InputSchema to StructuredInputs -func mapInputSchemaToStructuredInputs(inputSchema InputSchema) map[string]agent_api.StructuredInputDefinition { - // TODO QUESTION: How does InputSchema map to StructuredInputDefinition? - // InputSchema might have parameters that become structured inputs +// mapInputSchemaToStructuredInputs converts PropertySchema to StructuredInputs +func mapInputSchemaToStructuredInputs(inputSchema *PropertySchema) map[string]agent_api.StructuredInputDefinition { + // TODO QUESTION: How does PropertySchema map to StructuredInputDefinition? + // PropertySchema might have parameters that become structured inputs return nil // Placeholder } -// mapOutputSchemaToTextFormat converts OutputSchema to text response format -func mapOutputSchemaToTextFormat(outputSchema OutputSchema) *agent_api.ResponseTextFormatConfiguration { - // TODO QUESTION: How does OutputSchema influence text formatting? - // OutputSchema might specify response structure that affects text config +// mapOutputSchemaToTextFormat converts PropertySchema to text response format +func mapOutputSchemaToTextFormat(outputSchema *PropertySchema) *agent_api.ResponseTextFormatConfiguration { + // TODO QUESTION: How does PropertySchema influence text formatting? + // PropertySchema might specify response structure that affects text config return nil // Placeholder } // CreateHostedAgentAPIRequest creates a CreateAgentRequest for hosted agents -func CreateHostedAgentAPIRequest(agentDefinition AgentDefinition, buildConfig *AgentBuildConfig) (*agent_api.CreateAgentRequest, error) { - // TODO QUESTION: Should I expect a ContainerAgent type instead of AgentDefinition? - // ContainerAgent has additional fields like Protocol and Options that might be relevant - +func CreateHostedAgentAPIRequest(hostedAgent HostedContainerAgent, buildConfig *AgentBuildConfig) (*agent_api.CreateAgentRequest, error) { // Check if we have an image URL set via the build config imageURL := "" cpu := "1" // Default CPU @@ -218,36 +221,49 @@ func CreateHostedAgentAPIRequest(agentDefinition AgentDefinition, buildConfig *A envVars = buildConfig.EnvironmentVariables } } - + + // Try to get image URL from the hosted agent container definition if not provided in build config + if imageURL == "" && hostedAgent.Container.Image != nil && *hostedAgent.Container.Image != "" { + imageURL = *hostedAgent.Container.Image + } + if imageURL == "" { - return nil, fmt.Errorf("image URL is required for hosted agents - use WithImageURL build option") + return nil, fmt.Errorf("image URL is required for hosted agents - use WithImageURL build option or specify in container.image") } - // TODO QUESTION: Should protocol versions come from YAML definition or be configurable via build options? - // ContainerAgent.Protocol might specify this, or should it be in build config? - - // Set default protocol versions - protocolVersions := []agent_api.ProtocolVersionRecord{ - {Protocol: agent_api.AgentProtocolResponses, Version: "v1"}, + // Map protocol versions from the hosted agent definition + protocolVersions := make([]agent_api.ProtocolVersionRecord, 0) + if len(hostedAgent.Protocols) > 0 { + for _, protocol := range hostedAgent.Protocols { + protocolVersions = append(protocolVersions, agent_api.ProtocolVersionRecord{ + Protocol: agent_api.AgentProtocol(protocol.Protocol), + Version: protocol.Version, + }) + } + } else { + // Set default protocol versions if none specified + protocolVersions = []agent_api.ProtocolVersionRecord{ + {Protocol: agent_api.AgentProtocolResponses, Version: "v1"}, + } } hostedDef := agent_api.HostedAgentDefinition{ AgentDefinition: agent_api.AgentDefinition{ - Kind: agent_api.AgentKindHosted, // This sets Kind to "hosted" - }, + Kind: agent_api.AgentKindHosted, + }, ContainerProtocolVersions: protocolVersions, CPU: cpu, Memory: memory, EnvironmentVariables: envVars, } - - // Set the image from build configuration + + // Set the image from build configuration or container definition imageHostedDef := agent_api.ImageBasedHostedAgentDefinition{ HostedAgentDefinition: hostedDef, Image: imageURL, } - return createAgentAPIRequest(agentDefinition, imageHostedDef) + return createAgentAPIRequest(hostedAgent.AgentDefinition, imageHostedDef) } // createAgentAPIRequest is a helper function to create the final request with common fields @@ -256,7 +272,7 @@ func createAgentAPIRequest(agentDefinition AgentDefinition, agentDef interface{} metadata := make(map[string]string) if agentDefinition.Metadata != nil { // Handle authors specially - convert slice to comma-separated string - if authors, exists := agentDefinition.Metadata["authors"]; exists { + if authors, exists := (*agentDefinition.Metadata)["authors"]; exists { if authorsSlice, ok := authors.([]interface{}); ok { var authorsStr []string for _, author := range authorsSlice { @@ -268,7 +284,7 @@ func createAgentAPIRequest(agentDefinition AgentDefinition, agentDef interface{} } } // Copy other metadata as strings - for key, value := range agentDefinition.Metadata { + for key, value := range *agentDefinition.Metadata { if key != "authors" { if strValue, ok := value.(string); ok { metadata[key] = strValue @@ -291,8 +307,8 @@ func createAgentAPIRequest(agentDefinition AgentDefinition, agentDef interface{} }, } - if agentDefinition.Description != "" { - request.Description = &agentDefinition.Description + if agentDefinition.Description != nil && *agentDefinition.Description != "" { + request.Description = agentDefinition.Description } if len(metadata) > 0 { @@ -301,16 +317,3 @@ func createAgentAPIRequest(agentDefinition AgentDefinition, agentDef interface{} return request, nil } - -// Legacy function for backward compatibility - delegates to the new structured approach -func CreateAgentAPIRequestFromAgentDefinition(agentDefinition AgentDefinition, buildConfig *AgentBuildConfig) (*agent_api.CreateAgentRequest, error) { - // Route to appropriate handler based on agent kind - switch agentDefinition.Kind { - case AgentKindPrompt: - return CreatePromptAgentAPIRequest(agentDefinition, buildConfig) - case AgentKindHosted: - return CreateHostedAgentAPIRequest(agentDefinition, buildConfig) - default: - return nil, fmt.Errorf("unsupported agent kind: %s. Supported kinds are: prompt, hosted", agentDefinition.Kind) - } -} \ No newline at end of file diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse.go index 72adafcb611..d722ff7e4dd 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -29,22 +29,77 @@ func LoadAndValidateAgentManifest(yamlContent []byte) (*AgentManifest, error) { func ValidateAgentManifest(manifest *AgentManifest) error { var errors []string - // Validate Agent Definition - only the essential fields - if manifest.Agent.Name == "" { - errors = append(errors, "agent.name is required") - } - if manifest.Agent.Kind == "" { - errors = append(errors, "agent.kind is required") - } else if !IsValidAgentKind(manifest.Agent.Kind) { - validKinds := ValidAgentKinds() - validKindStrings := make([]string, len(validKinds)) - for i, kind := range validKinds { - validKindStrings[i] = string(kind) + // First, extract the kind from the template to determine the agent type + templateMap, ok := manifest.Template.(map[string]interface{}) + if !ok { + errors = append(errors, "template must be a valid object") + } else { + kindValue, hasKind := templateMap["kind"] + if !hasKind { + errors = append(errors, "template.kind is required") + } else { + kind, kindOk := kindValue.(string) + if !kindOk { + errors = append(errors, "template.kind must be a string") + } else { + // Validate the kind is supported + if !IsValidAgentKind(AgentKind(kind)) { + validKinds := ValidAgentKinds() + validKindStrings := make([]string, len(validKinds)) + for i, validKind := range validKinds { + validKindStrings[i] = string(validKind) + } + errors = append(errors, fmt.Sprintf("template.kind must be one of: %v, got '%s'", validKindStrings, kind)) + } else { + // Convert template to YAML bytes and unmarshal to specific type based on kind + templateBytes, err := yaml.Marshal(manifest.Template) + if err != nil { + errors = append(errors, "failed to process template structure") + } else { + switch AgentKind(kind) { + case AgentKindPrompt: + var agent PromptAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if agent.Name == "" { + errors = append(errors, "template.name is required") + } + if agent.Model.Id == "" { + errors = append(errors, "template.model.id is required") + } + } + case AgentKindHosted: + var agent HostedContainerAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if agent.Name == "" { + errors = append(errors, "template.name is required") + } + if len(agent.Models) == 0 { + errors = append(errors, "template.models is required and must not be empty") + } + } + case AgentKindContainerApp, AgentKindYamlContainerApp: + var agent ContainerAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if agent.Name == "" { + errors = append(errors, "template.name is required") + } + if len(agent.Models) == 0 { + errors = append(errors, "template.models is required and must not be empty") + } + } + case AgentKindWorkflow: + var agent WorkflowAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if agent.Name == "" { + errors = append(errors, "template.name is required") + } + // WorkflowAgent doesn't have models, so no model validation needed + } + } + } + } + } } - errors = append(errors, fmt.Sprintf("agent.kind must be one of: %v, got '%s'", validKindStrings, manifest.Agent.Kind)) - } - if manifest.Agent.Model.Id == "" { - errors = append(errors, "agent.model.id is required") } if len(errors) > 0 { @@ -56,4 +111,4 @@ func ValidateAgentManifest(manifest *AgentManifest) error { } return nil -} \ No newline at end of file +} diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go.old similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go rename to cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go.old diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/sample_integration_test.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/sample_integration_test.go.old similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/sample_integration_test.go rename to cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/sample_integration_test.go.old diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index 4c05a608809..d05fdb01a59 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -35,356 +35,362 @@ func ValidAgentKinds() []AgentKind { } } -// AgentDefinition represents The following is a specification for defining AI agents with structured metadata, inputs, outputs, tools, and templates. +// AgentDefinition is a specification for defining AI agents with structured metadata, inputs, outputs, tools, and templates. // It provides a way to create reusable and composable AI agents that can be executed with specific configurations. // The specification includes metadata about the agent, model configuration, input parameters, expected outputs, // available tools, and template configurations for prompt rendering. type AgentDefinition struct { - Kind AgentKind `json:"kind"` // Kind represented by the document - Name string `json:"name"` // Human-readable name of the agent - Description string `json:"description,omitempty"` // Description of the agent's capabilities and purpose - Instructions string `json:"instructions,omitempty"` // Give your agent clear directions on what to do and how to do it - Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata including authors, tags, and other arbitrary properties - Model Model `json:"model"` // Primary AI model configuration for the agent - InputSchema InputSchema `json:"inputSchema,omitempty"` // Input parameters that participate in template rendering - OutputSchema OutputSchema `json:"outputSchema,omitempty"` // Expected output format and structure from the agent - Tools []Tool `json:"tools,omitempty"` // Tools available to the agent for extended functionality -} - -// PromptAgent represents Prompt based agent definition. Used to create agents that can be executed directly. + Kind AgentKind `json:"kind"` + Name string `json:"name"` + DisplayName *string `json:"displayName,omitempty"` + Description *string `json:"description,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + InputSchema *PropertySchema `json:"inputSchema,omitempty"` + OutputSchema *PropertySchema `json:"outputSchema,omitempty"` + Tools *[]Tool `json:"tools,omitempty"` +} + +// PromptAgent is a prompt based agent definition used to create agents that can be executed directly. // These agents can leverage tools, input parameters, and templates to generate responses. // They are designed to be straightforward and easy to use for various applications. type PromptAgent struct { - AgentDefinition - Kind AgentKind `json:"kind"` // Type of agent, e.g., 'prompt' - Template Template `json:"template,omitempty"` // Template configuration for prompt rendering - Instructions string `json:"instructions,omitempty"` // Give your agent clear directions on what to do and how to do it. Include specific tasks, their order, and any special instructions like tone or engagement style. (can use this for a pure yaml declaration or as content in the markdown format) - AdditionalInstructions string `json:"additionalInstructions,omitempty"` // Additional instructions or context for the agent, can be used to provide extra guidance (can use this for a pure yaml declaration) -} - -// ContainerAgent represents The following represents a containerized agent that can be deployed and hosted. + AgentDefinition // Embedded parent struct + Kind AgentKind `json:"kind"` + Model Model `json:"model"` + Template *Template `json:"template,omitempty"` + Instructions *string `json:"instructions,omitempty"` + AdditionalInstructions *string `json:"additionalInstructions,omitempty"` +} + +// HostedContainerAgent represents a container based agent hosted by the provider/publisher. +// The intent is to represent a container application that the user wants to run +// in a hosted environment that the provider manages. +type HostedContainerAgent struct { + AgentDefinition // Embedded parent struct + Kind AgentKind `json:"kind"` + Protocols []ProtocolVersionRecord `json:"protocols"` + Models []Model `json:"models"` + Container HostedContainerDefinition `json:"container"` +} + +// ContainerAgent represents a containerized agent that can be deployed and hosted. // It includes details about the container image, registry information, and environment variables. // This model allows for the definition of agents that can run in isolated environments, // making them suitable for deployment in various cloud or on-premises scenarios. -// // The containerized agent can communicate using specified protocols and can be scaled -// based on the provided configuration. -// -// This kind of agent represents the users intent to bring their own container specific -// app hosting platform that they manage. +// based on the provided configuration. This kind of agent represents the users intent +// to bring their own container specific app hosting platform that they manage. type ContainerAgent struct { - AgentDefinition - Kind AgentKind `json:"kind"` // Type of agent, e.g., 'container' - Protocol string `json:"protocol"` // Protocol used by the containerized agent - Options map[string]interface{} `json:"options,omitempty"` // Container definition including image, registry, and scaling information -} - -// AgentManifest represents The following represents a manifest that can be used to create agents dynamically. -// It includes a list of models that the publisher of the manifest has tested and -// has confidence will work with an instantiated prompt agent. -// The manifest also includes parameters that can be used to configure the agent's behavior. + AgentDefinition // Embedded parent struct + Kind AgentKind `json:"kind"` + Protocols []ProtocolVersionRecord `json:"protocols"` + Models []Model `json:"models"` + Resource string `json:"resource"` + IngressSuffix string `json:"ingressSuffix"` + Options *map[string]interface{} `json:"options,omitempty"` +} + +// WorkflowAgent is a workflow agent that can orchestrate multiple steps and actions. +// This agent type is designed to handle complex workflows that may involve +// multiple tools, models, and decision points. The workflow agent can be configured +// with a series of steps that define the flow of execution, including conditional +// logic and parallel processing. This allows for the creation of sophisticated +// AI-driven processes that can adapt to various scenarios and requirements. +// Note: The detailed structure of the workflow steps and actions is not defined here +// and would need to be implemented based on specific use cases and requirements. +type WorkflowAgent struct { + AgentDefinition // Embedded parent struct + Kind AgentKind `json:"kind"` + Trigger *map[string]interface{} `json:"trigger,omitempty"` +} + +// AgentManifest represents a manifest that can be used to create agents dynamically. +// It includes parameters that can be used to configure the agent's behavior. // These parameters include values that can be used as publisher parameters that can // be used to describe additional variables that have been tested and are known to work. -// // Variables described here are then used to project into a prompt agent that can be executed. // Once parameters are provided, these can be referenced in the manifest using the following notation: -// -// `${param:MyParameter}` -// -// This allows for dynamic configuration of the agent based on the provided parameters. -// (This notation is used elsewhere, but only the `param` scope is supported here) +// `{{myParameter}}` This allows for dynamic configuration of the agent based on the provided parameters. type AgentManifest struct { - Agent AgentDefinition `json:"agent"` // The agent that this manifest is based on - // Models []Model `json:"models"` // Additional models that are known to work with this prompt - Parameters []Parameter `json:"parameters"` // Parameters for configuring the agent's behavior and execution + Name string `json:"name"` + DisplayName string `json:"displayName"` + Description *string `json:"description,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty"` + Template any `json:"template"` // can be PromptAgent, HostedContainerAgent, ContainerAgent, or WorkflowAgent + Parameters []Parameter `json:"parameters"` } -// Binding represents Represents a binding between an input property and a tool parameter. +// Binding represents a binding between an input property and a tool parameter. type Binding struct { - Name string `json:"name"` // Name of the binding - Input string `json:"input"` // The input property that will be bound to the tool parameter argument + Name string `json:"name"` + Input string `json:"input"` } -// BingSearchConfiguration represents Configuration options for the Bing search tool. -type BingSearchConfiguration struct { - Name string `json:"name"` // The name of the Bing search tool instance, used to identify the specific instance in the system - Market string `json:"market,omitempty"` // The market where the results come from. - SetLang string `json:"setLang,omitempty"` // The language to use for user interface strings when calling Bing API. - Count int64 `json:"count,omitempty"` // The number of search results to return in the bing api response - Freshness string `json:"freshness,omitempty"` // Filter search results by a specific time range. Accepted values: https://learn.microsoft.com/bing/search-apis/bing-web-search/reference/query-parameters +// BingSearchOption provides configuration options for the Bing search tool. +type BingSearchOption struct { + Name string `json:"name"` + Market *string `json:"market,omitempty"` + SetLang *string `json:"setLang,omitempty"` + Count *int `json:"count,omitempty"` + Freshness *string `json:"freshness,omitempty"` } -// Connection represents Connection configuration for AI agents. +// Connection configuration for AI agents. // `provider`, `kind`, and `endpoint` are required properties here, // but this section can accept additional via options. type Connection struct { - Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) - Authority string `json:"authority"` // The authority level for the connection, indicating under whose authority the connection is made (e.g., 'user', 'agent', 'system') - UsageDescription string `json:"usageDescription,omitempty"` // The usage description for the connection, providing context on how this connection will be used + Kind string `json:"kind"` + Authority string `json:"authority"` + UsageDescription *string `json:"usageDescription,omitempty"` } -// GenericConnection represents Generic connection configuration for AI services. -type GenericConnection struct { - Connection - Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) - Options map[string]interface{} `json:"options,omitempty"` // Additional options for the connection +// ReferenceConnection provides connection configuration for AI services using named connections. +type ReferenceConnection struct { + Connection // Embedded parent struct + Kind string `json:"kind"` + Name string `json:"name"` } -// ReferenceConnection represents Connection configuration for AI services using named connections. -type ReferenceConnection struct { - Connection - Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) - Name string `json:"name"` // The name of the connection +// TokenCredentialConnection provides connection configuration for AI services using token credentials. +type TokenCredentialConnection struct { + Connection // Embedded parent struct + Kind string `json:"kind"` + Endpoint string `json:"endpoint"` } -// KeyConnection represents Connection configuration for AI services using API keys. -type KeyConnection struct { - Connection - Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) - Endpoint string `json:"endpoint"` // The endpoint URL for the AI service - Key string `json:"key"` // The API key for authenticating with the AI service +// ApiKeyConnection provides connection configuration for AI services using API keys. +type ApiKeyConnection struct { + Connection // Embedded parent struct + Kind string `json:"kind"` + Endpoint string `json:"endpoint"` + ApiKey string `json:"apiKey"` } -// OAuthConnection represents Connection configuration for AI services using OAuth authentication. -type OAuthConnection struct { - Connection - Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) - Endpoint string `json:"endpoint"` // The endpoint URL for the AI service - ClientId string `json:"clientId"` // The OAuth client ID for authenticating with the AI service - ClientSecret string `json:"clientSecret"` // The OAuth client secret for authenticating with the AI service - TokenUrl string `json:"tokenUrl"` // The OAuth token URL for obtaining access tokens - Scopes []interface{} `json:"scopes"` // The scopes required for the OAuth token +// EnvironmentVariable represents an environment variable configuration. +type EnvironmentVariable struct { + Name string `json:"name"` + Value string `json:"value"` } -// Format represents Template format definition +// Format represents the Format from _Format.py type Format struct { - Kind string `json:"kind"` // Template rendering engine used for slot filling prompts (e.g., mustache, jinja2) - Strict bool `json:"strict,omitempty"` // Whether the template can emit structural text for parsing output - Options map[string]interface{} `json:"options,omitempty"` // Options for the template engine + Kind string `json:"kind"` + Strict *bool `json:"strict,omitempty"` + Options *map[string]interface{} `json:"options,omitempty"` } -// HostedContainerDefinition represents Definition for a containerized AI agent hosted by the provider. -// This includes the container registry information and scaling configuration. +// HostedContainerDefinition represents the HostedContainerDefinition from _HostedContainerDefinition.py type HostedContainerDefinition struct { - Scale Scale `json:"scale"` // Instance scaling configuration - Context interface{} `json:"context"` // Container context for building the container image -} - -// Input represents Represents a single input property for a prompt. -// * This model defines the structure of input properties that can be used in prompts, -// including their type, description, whether they are required, and other attributes. -// * It allows for the definition of dynamic inputs that can be filled with data -// and processed to generate prompts for AI models. -type Input struct { - Name string `json:"name"` // Name of the input property - Kind string `json:"kind"` // The data type of the input property - Description string `json:"description,omitempty"` // A short description of the input property - Required bool `json:"required,omitempty"` // Whether the input property is required - Strict bool `json:"strict,omitempty"` // Whether the input property can emit structural text when parsing output - Default interface{} `json:"default,omitempty"` // The default value of the input - this represents the default value if none is provided - Sample interface{} `json:"sample,omitempty"` // A sample value of the input for examples and tooling -} - -// ArrayInput represents Represents an array output property. -// This extends the base Output model to represent an array of items. -type ArrayInput struct { - Input - Kind string `json:"kind"` - Items Input `json:"items"` // The type of items contained in the array -} - -// ObjectInput represents Represents an object output property. -// This extends the base Output model to represent a structured object. -type ObjectInput struct { - Input - Kind string `json:"kind"` - Properties []interface{} `json:"properties"` // The properties contained in the object -} - -// InputSchema represents Definition for the input schema of a prompt. -// This includes the properties and example records. -type InputSchema struct { - Examples []interface{} `json:"examples,omitempty"` // Example records for the input schema - Strict bool `json:"strict,omitempty"` // Whether the input schema is strict - if true, only the defined properties are allowed - Properties []Input `json:"properties"` // The input properties for the schema + Scale Scale `json:"scale"` + Image *string `json:"image,omitempty"` + Context map[string]interface{} `json:"context"` + EnvironmentVariables *[]EnvironmentVariable `json:"environmentVariables,omitempty"` } -// Model represents Model for defining the structure and behavior of AI agents. +// McpServerApprovalMode represents the McpServerApprovalMode from _McpServerApprovalMode.py +type McpServerApprovalMode struct { + Mode string `json:"mode"` + AlwaysRequireApprovalTools []string `json:"alwaysRequireApprovalTools"` + NeverRequireApprovalTools []string `json:"neverRequireApprovalTools"` +} + +// Model defines the structure and behavior of AI agents. // This model includes properties for specifying the model's provider, connection details, and various options. // It allows for flexible configuration of AI models to suit different use cases and requirements. type Model struct { - Id string `json:"id"` // The unique identifier of the model - can be used as the single property shorthand - Publisher string `json:"publisher,omitempty"` // The publisher of the model (e.g., 'openai', 'azure', 'anthropic') - Connection Connection `json:"connection,omitempty"` // The connection configuration for the model - Options ModelOptions `json:"options,omitempty"` // Additional options for the model + Id string `json:"id"` + Provider *string `json:"provider,omitempty"` + ApiType string `json:"apiType"` + Deployment *string `json:"deployment,omitempty"` + Version *string `json:"version,omitempty"` + Connection *Connection `json:"connection,omitempty"` + Options *ModelOptions `json:"options,omitempty"` } -// ModelOptions represents Options for configuring the behavior of the AI model. -// `kind` is a required property here, but this section can accept additional via options. +// ModelOptions represents the ModelOptions from _ModelOptions.py type ModelOptions struct { - Kind string `json:"kind"` + FrequencyPenalty *float64 `json:"frequencyPenalty,omitempty"` + MaxOutputTokens *int `json:"maxOutputTokens,omitempty"` + PresencePenalty *float64 `json:"presencePenalty,omitempty"` + Seed *int `json:"seed,omitempty"` + Temperature *float64 `json:"temperature,omitempty"` + TopK *int `json:"topK,omitempty"` + TopP *float64 `json:"topP,omitempty"` + StopSequences *[]string `json:"stopSequences,omitempty"` + AllowMultipleToolCalls *bool `json:"allowMultipleToolCalls,omitempty"` + AdditionalProperties *map[string]interface{} `json:"additionalProperties,omitempty"` +} + +// Parameter represents the Parameter from _Parameter.py +type Parameter struct { + Name string `json:"name"` + Description *string `json:"description,omitempty"` + Required *bool `json:"required,omitempty"` + Schema ParameterSchema `json:"schema"` } -// Output represents Represents the output properties of an AI agent. -// Each output property can be a simple kind, an array, or an object. -type Output struct { - Name string `json:"name"` // Name of the output property - Kind string `json:"kind"` // The data kind of the output property - Description string `json:"description,omitempty"` // A short description of the output property - Required bool `json:"required,omitempty"` // Whether the output property is required +// ParameterSchema represents the ParameterSchema from _ParameterSchema.py +type ParameterSchema struct { + Type string `json:"type"` + Default *interface{} `json:"default,omitempty"` + Enum *[]interface{} `json:"enum,omitempty"` + Extensions *map[string]interface{} `json:"extensions,omitempty"` } -// ArrayOutput represents Represents an array output property. -// This extends the base Output model to represent an array of items. -type ArrayOutput struct { - Output - Kind string `json:"kind"` - Items Output `json:"items"` // The type of items contained in the array +// StringParameterSchema represents a string parameter schema. +type StringParameterSchema struct { + ParameterSchema // Embedded parent struct + Type string `json:"type"` + MinLength *int `json:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty"` + Pattern *string `json:"pattern,omitempty"` } -// ObjectOutput represents Represents an object output property. -// This extends the base Output model to represent a structured object. -type ObjectOutput struct { - Output - Kind string `json:"kind"` - Properties []interface{} `json:"properties"` // The properties contained in the object +// DigitParameterSchema represents a digit parameter schema. +type DigitParameterSchema struct { + ParameterSchema // Embedded parent struct + Type string `json:"type"` + Minimum *int `json:"minimum,omitempty"` + Maximum *int `json:"maximum,omitempty"` + ExclusiveMinimum *bool `json:"exclusiveMinimum,omitempty"` + ExclusiveMaximum *bool `json:"exclusiveMaximum,omitempty"` + MultipleOf *float64 `json:"multipleOf,omitempty"` } -// OutputSchema represents Definition for the output schema of an AI agent. -// This includes the properties and example records. -type OutputSchema struct { - Examples []interface{} `json:"examples,omitempty"` // Example records for the output schema - Properties []Output `json:"properties"` // The output properties for the schema +// Parser represents the Parser from _Parser.py +type Parser struct { + Kind string `json:"kind"` + Options *map[string]interface{} `json:"options,omitempty"` } -// Parameter represents Represents a parameter for a tool. -type Parameter struct { - Name string `json:"name"` // Name of the parameter - Kind string `json:"kind"` // The data type of the parameter - Description string `json:"description,omitempty"` // A short description of the property - Required bool `json:"required,omitempty"` // Whether the tool parameter is required - Default interface{} `json:"default,omitempty"` // The default value of the parameter - this represents the default value if none is provided - Value interface{} `json:"value,omitempty"` // Parameter value used for initializing manifest examples and tooling - Enum []interface{} `json:"enum,omitempty"` // Allowed enumeration values for the parameter +// Property represents the Property from _Property.py +type Property struct { + Name string `json:"name"` + Kind string `json:"kind"` + Description *string `json:"description,omitempty"` + Required *bool `json:"required,omitempty"` + Strict *bool `json:"strict,omitempty"` + Default *interface{} `json:"default,omitempty"` + Example *interface{} `json:"example,omitempty"` + EnumValues *[]interface{} `json:"enumValues,omitempty"` } -// ObjectParameter represents Represents an object parameter for a tool. -type ObjectParameter struct { - Parameter - Kind string `json:"kind"` - Properties []Parameter `json:"properties"` // The properties of the object parameter +// ArrayProperty represents an array property. +// This extends the base Property model to represent an array of items. +type ArrayProperty struct { + Property // Embedded parent struct + Kind string `json:"kind"` + Items Property `json:"items"` } -// ArrayParameter represents Represents an array parameter for a tool. -type ArrayParameter struct { - Parameter - Kind string `json:"kind"` - Items interface{} `json:"items"` // The kind of items contained in the array +// ObjectProperty represents an object property. +// This extends the base Property model to represent a structured object. +type ObjectProperty struct { + Property // Embedded parent struct + Kind string `json:"kind"` + Properties []Property `json:"properties"` } -// Parser represents Template parser definition -type Parser struct { - Kind string `json:"kind"` // Parser used to process the rendered template into API-compatible format - Options map[string]interface{} `json:"options,omitempty"` // Options for the parser +// PropertySchema defines the property schema of a model. +// This includes the properties and example records. +type PropertySchema struct { + Examples *[]map[string]interface{} `json:"examples,omitempty"` + Strict *bool `json:"strict,omitempty"` + Properties []Property `json:"properties"` } -// Scale represents Configuration for scaling container instances. +// ProtocolVersionRecord represents the ProtocolVersionRecord from _ProtocolVersionRecord.py +type ProtocolVersionRecord struct { + Protocol string `json:"protocol"` + Version string `json:"version"` +} + +// Scale represents the Scale from _Scale.py type Scale struct { - MinReplicas int32 `json:"minReplicas,omitempty"` // Minimum number of container instances to run - MaxReplicas int32 `json:"maxReplicas,omitempty"` // Maximum number of container instances to run - Cpu float32 `json:"cpu"` // CPU allocation per instance (in cores) - Memory float32 `json:"memory"` // Memory allocation per instance (in GB) -} - -// Template represents Template model for defining prompt templates. -// -// This model specifies the rendering engine used for slot filling prompts, -// the parser used to process the rendered template into API-compatible format, -// and additional options for the template engine. -// -// It allows for the creation of reusable templates that can be filled with dynamic data -// and processed to generate prompts for AI models. + MinReplicas *int `json:"minReplicas,omitempty"` + MaxReplicas *int `json:"maxReplicas,omitempty"` + Cpu float64 `json:"cpu"` + Memory float64 `json:"memory"` +} + +// Template represents the Template from _Template.py type Template struct { - Format Format `json:"format"` // Template rendering engine used for slot filling prompts (e.g., mustache, jinja2) - Parser Parser `json:"parser"` // Parser used to process the rendered template into API-compatible format + Format Format `json:"format"` + Parser Parser `json:"parser"` } -// Tool represents Represents a tool that can be used in prompts. +// Tool represents a tool that can be used in prompts. type Tool struct { - Name string `json:"name"` // Name of the tool. If a function tool, this is the function name, otherwise it is the type - Kind string `json:"kind"` // The kind identifier for the tool - Description string `json:"description,omitempty"` // A short description of the tool for metadata purposes - Bindings []Binding `json:"bindings,omitempty"` // Tool argument bindings to input properties + Name string `json:"name"` + Kind string `json:"kind"` + Description *string `json:"description,omitempty"` + Bindings *[]Binding `json:"bindings,omitempty"` } -// FunctionTool represents Represents a local function tool. +// FunctionTool represents a local function tool. type FunctionTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for function tools - Parameters []Parameter `json:"parameters"` // Parameters accepted by the function tool + Tool // Embedded parent struct + Kind string `json:"kind"` + Parameters PropertySchema `json:"parameters"` + Strict *bool `json:"strict,omitempty"` } -// ServerTool represents Represents a generic server tool that runs on a server -// This tool kind is designed for operations that require server-side execution -// It may include features such as authentication, data storage, and long-running processes -// This tool kind is ideal for tasks that involve complex computations or access to secure resources -// Server tools can be used to offload heavy processing from client applications +// ServerTool represents a generic server tool that runs on a server. +// This tool kind is designed for operations that require server-side execution. +// It may include features such as authentication, data storage, and long-running processes. +// This tool kind is ideal for tasks that involve complex computations or access to secure resources. +// Server tools can be used to offload heavy processing from client applications. type ServerTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for server tools. This is a wildcard and can represent any server tool type not explicitly defined. - Connection interface{} `json:"connection"` // Connection configuration for the server tool - Options map[string]interface{} `json:"options"` // Configuration options for the server tool + Tool // Embedded parent struct + Kind string `json:"kind"` + Connection Connection `json:"connection"` + Options map[string]interface{} `json:"options"` } -// BingSearchTool represents The Bing search tool. +// BingSearchTool represents the Bing search tool. type BingSearchTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for Bing search tools - Connection interface{} `json:"connection"` // The connection configuration for the Bing search tool - Configurations []BingSearchConfiguration `json:"configurations"` // The configuration options for the Bing search tool + Tool // Embedded parent struct + Kind string `json:"kind"` + Connection Connection `json:"connection"` + Options []BingSearchOption `json:"options"` } -// FileSearchTool represents A tool for searching files. +// FileSearchTool is a tool for searching files. // This tool allows an AI agent to search for files based on a query. type FileSearchTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for file search tools - Connection interface{} `json:"connection"` // The connection configuration for the file search tool - MaxNumResults int32 `json:"maxNumResults,omitempty"` // The maximum number of search results to return. - Ranker string `json:"ranker"` // File search ranker. - ScoreThreshold float32 `json:"scoreThreshold"` // Ranker search threshold. - VectorStoreIds []interface{} `json:"vectorStoreIds"` // The IDs of the vector stores to search within. + Tool // Embedded parent struct + Kind string `json:"kind"` + Connection Connection `json:"connection"` + VectorStoreIds []string `json:"vectorStoreIds"` + MaxNumResults *int `json:"maxNumResults,omitempty"` + Ranker string `json:"ranker"` + ScoreThreshold float64 `json:"scoreThreshold"` + Filters *map[string]interface{} `json:"filters,omitempty"` } -// McpTool represents The MCP Server tool. +// McpTool represents the MCP Server tool. type McpTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for MCP tools - Connection interface{} `json:"connection"` // The connection configuration for the MCP tool - Name string `json:"name"` // The name of the MCP tool - Url string `json:"url"` // The URL of the MCP server - Allowed []interface{} `json:"allowed"` // List of allowed operations or resources for the MCP tool -} - -// ModelTool represents The MCP Server tool. -type ModelTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for a model connection as a tool - Model interface{} `json:"model"` // The connection configuration for the model tool + Tool // Embedded parent struct + Kind string `json:"kind"` + Connection Connection `json:"connection"` + Name string `json:"name"` + Url string `json:"url"` + ApprovalMode McpServerApprovalMode `json:"approvalMode"` + AllowedTools []string `json:"allowedTools"` } -// OpenApiTool represents +// OpenApiTool represents an OpenAPI tool. type OpenApiTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for OpenAPI tools - Connection interface{} `json:"connection"` // The connection configuration for the OpenAPI tool - Specification string `json:"specification"` // The URL or relative path to the OpenAPI specification document (JSON or YAML format) + Tool // Embedded parent struct + Kind string `json:"kind"` + Connection Connection `json:"connection"` + Specification string `json:"specification"` } -// CodeInterpreterTool represents A tool for interpreting and executing code. +// CodeInterpreterTool is a tool for interpreting and executing code. // This tool allows an AI agent to run code snippets and analyze data files. type CodeInterpreterTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for code interpreter tools - FileIds []interface{} `json:"fileIds"` // The IDs of the files to be used by the code interpreter tool. + Tool // Embedded parent struct + Kind string `json:"kind"` + FileIds []string `json:"fileIds"` } diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go.old b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go.old new file mode 100644 index 00000000000..6af25922e5b --- /dev/null +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go.old @@ -0,0 +1,389 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package agent_yaml + +// AgentKind represents the type of agent +type AgentKind string + +const ( + AgentKindPrompt AgentKind = "prompt" + AgentKindHosted AgentKind = "hosted" + AgentKindContainerApp AgentKind = "container_app" + // Same as AgentKindContainerApp but this is the expected way to refer to container based agents in yaml files + AgentKindYamlContainerApp AgentKind = "container" + AgentKindWorkflow AgentKind = "workflow" +) + +// IsValidAgentKind checks if the provided AgentKind is valid +func IsValidAgentKind(kind AgentKind) bool { + switch kind { + case AgentKindPrompt, AgentKindHosted, AgentKindContainerApp, AgentKindWorkflow, AgentKindYamlContainerApp: + return true + default: + return false + } +} + +// ValidAgentKinds returns a slice of all valid AgentKind values +func ValidAgentKinds() []AgentKind { + return []AgentKind{ + AgentKindPrompt, + AgentKindHosted, + AgentKindContainerApp, + AgentKindWorkflow, + } +} + +// AgentDefinition represents The following is a specification for defining AI agents with structured metadata, inputs, outputs, tools, and templates. +// It provides a way to create reusable and composable AI agents that can be executed with specific configurations. +// The specification includes metadata about the agent, model configuration, input parameters, expected outputs, +// available tools, and template configurations for prompt rendering. +type AgentDefinition struct { + Kind AgentKind `json:"kind"` // Kind represented by the document + Name string `json:"name"` // Human-readable name of the agent + Description string `json:"description,omitempty"` // Description of the agent's capabilities and purpose + Instructions string `json:"instructions,omitempty"` // Give your agent clear directions on what to do and how to do it + Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata including authors, tags, and other arbitrary properties + Model Model `json:"model"` // Primary AI model configuration for the agent + InputSchema InputSchema `json:"inputSchema,omitempty"` // Input parameters that participate in template rendering + OutputSchema OutputSchema `json:"outputSchema,omitempty"` // Expected output format and structure from the agent + Tools []Tool `json:"tools,omitempty"` // Tools available to the agent for extended functionality +} + +// PromptAgent represents Prompt based agent definition. Used to create agents that can be executed directly. +// These agents can leverage tools, input parameters, and templates to generate responses. +// They are designed to be straightforward and easy to use for various applications. +type PromptAgent struct { + AgentDefinition + Kind AgentKind `json:"kind"` // Type of agent, e.g., 'prompt' + Template Template `json:"template,omitempty"` // Template configuration for prompt rendering + Instructions string `json:"instructions,omitempty"` // Give your agent clear directions on what to do and how to do it. Include specific tasks, their order, and any special instructions like tone or engagement style. (can use this for a pure yaml declaration or as content in the markdown format) + AdditionalInstructions string `json:"additionalInstructions,omitempty"` // Additional instructions or context for the agent, can be used to provide extra guidance (can use this for a pure yaml declaration) +} + +// ContainerAgent represents The following represents a containerized agent that can be deployed and hosted. +// It includes details about the container image, registry information, and environment variables. +// This model allows for the definition of agents that can run in isolated environments, +// making them suitable for deployment in various cloud or on-premises scenarios. +// +// The containerized agent can communicate using specified protocols and can be scaled +// based on the provided configuration. +// +// This kind of agent represents the users intent to bring their own container specific +// app hosting platform that they manage. +type ContainerAgent struct { + AgentDefinition + Kind AgentKind `json:"kind"` // Type of agent, e.g., 'container' + Protocol string `json:"protocol"` // Protocol used by the containerized agent + Options map[string]interface{} `json:"options,omitempty"` // Container definition including image, registry, and scaling information +} + +// AgentManifest represents The following represents a manifest that can be used to create agents dynamically. +// It includes a list of models that the publisher of the manifest has tested and +// has confidence will work with an instantiated prompt agent. +// The manifest also includes parameters that can be used to configure the agent's behavior. +// These parameters include values that can be used as publisher parameters that can +// be used to describe additional variables that have been tested and are known to work. +// +// Variables described here are then used to project into a prompt agent that can be executed. +// Once parameters are provided, these can be referenced in the manifest using the following notation: +// +// `${param:MyParameter}` +// +// This allows for dynamic configuration of the agent based on the provided parameters. +// (This notation is used elsewhere, but only the `param` scope is supported here) +type AgentManifest struct { + Agent AgentDefinition `json:"agent"` // The agent that this manifest is based on + Parameters map[string]Parameter `json:"parameters"` // Parameters for configuring the agent's behavior and execution +} + +// Binding represents Represents a binding between an input property and a tool parameter. +type Binding struct { + Name string `json:"name"` // Name of the binding + Input string `json:"input"` // The input property that will be bound to the tool parameter argument +} + +// BingSearchConfiguration represents Configuration options for the Bing search tool. +type BingSearchConfiguration struct { + Name string `json:"name"` // The name of the Bing search tool instance, used to identify the specific instance in the system + Market string `json:"market,omitempty"` // The market where the results come from. + SetLang string `json:"setLang,omitempty"` // The language to use for user interface strings when calling Bing API. + Count int64 `json:"count,omitempty"` // The number of search results to return in the bing api response + Freshness string `json:"freshness,omitempty"` // Filter search results by a specific time range. Accepted values: https://learn.microsoft.com/bing/search-apis/bing-web-search/reference/query-parameters +} + +// Connection represents Connection configuration for AI agents. +// `provider`, `kind`, and `endpoint` are required properties here, +// but this section can accept additional via options. +type Connection struct { + Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) + Authority string `json:"authority"` // The authority level for the connection, indicating under whose authority the connection is made (e.g., 'user', 'agent', 'system') + UsageDescription string `json:"usageDescription,omitempty"` // The usage description for the connection, providing context on how this connection will be used +} + +// GenericConnection represents Generic connection configuration for AI services. +type GenericConnection struct { + Connection + Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) + Options map[string]interface{} `json:"options,omitempty"` // Additional options for the connection +} + +// ReferenceConnection represents Connection configuration for AI services using named connections. +type ReferenceConnection struct { + Connection + Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) + Name string `json:"name"` // The name of the connection +} + +// KeyConnection represents Connection configuration for AI services using API keys. +type KeyConnection struct { + Connection + Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) + Endpoint string `json:"endpoint"` // The endpoint URL for the AI service + Key string `json:"key"` // The API key for authenticating with the AI service +} + +// OAuthConnection represents Connection configuration for AI services using OAuth authentication. +type OAuthConnection struct { + Connection + Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) + Endpoint string `json:"endpoint"` // The endpoint URL for the AI service + ClientId string `json:"clientId"` // The OAuth client ID for authenticating with the AI service + ClientSecret string `json:"clientSecret"` // The OAuth client secret for authenticating with the AI service + TokenUrl string `json:"tokenUrl"` // The OAuth token URL for obtaining access tokens + Scopes []interface{} `json:"scopes"` // The scopes required for the OAuth token +} + +// Format represents Template format definition +type Format struct { + Kind string `json:"kind"` // Template rendering engine used for slot filling prompts (e.g., mustache, jinja2) + Strict bool `json:"strict,omitempty"` // Whether the template can emit structural text for parsing output + Options map[string]interface{} `json:"options,omitempty"` // Options for the template engine +} + +// HostedContainerDefinition represents Definition for a containerized AI agent hosted by the provider. +// This includes the container registry information and scaling configuration. +type HostedContainerDefinition struct { + Scale Scale `json:"scale"` // Instance scaling configuration + Context interface{} `json:"context"` // Container context for building the container image +} + +// Input represents Represents a single input property for a prompt. +// * This model defines the structure of input properties that can be used in prompts, +// including their type, description, whether they are required, and other attributes. +// * It allows for the definition of dynamic inputs that can be filled with data +// and processed to generate prompts for AI models. +type Input struct { + Name string `json:"name"` // Name of the input property + Kind string `json:"kind"` // The data type of the input property + Description string `json:"description,omitempty"` // A short description of the input property + Required bool `json:"required,omitempty"` // Whether the input property is required + Strict bool `json:"strict,omitempty"` // Whether the input property can emit structural text when parsing output + Default interface{} `json:"default,omitempty"` // The default value of the input - this represents the default value if none is provided + Sample interface{} `json:"sample,omitempty"` // A sample value of the input for examples and tooling +} + +// ArrayInput represents Represents an array output property. +// This extends the base Output model to represent an array of items. +type ArrayInput struct { + Input + Kind string `json:"kind"` + Items Input `json:"items"` // The type of items contained in the array +} + +// ObjectInput represents Represents an object output property. +// This extends the base Output model to represent a structured object. +type ObjectInput struct { + Input + Kind string `json:"kind"` + Properties []interface{} `json:"properties"` // The properties contained in the object +} + +// InputSchema represents Definition for the input schema of a prompt. +// This includes the properties and example records. +type InputSchema struct { + Examples []interface{} `json:"examples,omitempty"` // Example records for the input schema + Strict bool `json:"strict,omitempty"` // Whether the input schema is strict - if true, only the defined properties are allowed + Properties []Input `json:"properties"` // The input properties for the schema +} + +// Model represents Model for defining the structure and behavior of AI agents. +// This model includes properties for specifying the model's provider, connection details, and various options. +// It allows for flexible configuration of AI models to suit different use cases and requirements. +type Model struct { + Id string `json:"id"` // The unique identifier of the model - can be used as the single property shorthand + Publisher string `json:"publisher,omitempty"` // The publisher of the model (e.g., 'openai', 'azure', 'anthropic') + Connection Connection `json:"connection,omitempty"` // The connection configuration for the model + Options ModelOptions `json:"options,omitempty"` // Additional options for the model +} + +// ModelOptions represents Options for configuring the behavior of the AI model. +// `kind` is a required property here, but this section can accept additional via options. +type ModelOptions struct { + Kind string `json:"kind"` +} + +// Output represents Represents the output properties of an AI agent. +// Each output property can be a simple kind, an array, or an object. +type Output struct { + Name string `json:"name"` // Name of the output property + Kind string `json:"kind"` // The data kind of the output property + Description string `json:"description,omitempty"` // A short description of the output property + Required bool `json:"required,omitempty"` // Whether the output property is required +} + +// ArrayOutput represents Represents an array output property. +// This extends the base Output model to represent an array of items. +type ArrayOutput struct { + Output + Kind string `json:"kind"` + Items Output `json:"items"` // The type of items contained in the array +} + +// ObjectOutput represents Represents an object output property. +// This extends the base Output model to represent a structured object. +type ObjectOutput struct { + Output + Kind string `json:"kind"` + Properties []interface{} `json:"properties"` // The properties contained in the object +} + +// OutputSchema represents Definition for the output schema of an AI agent. +// This includes the properties and example records. +type OutputSchema struct { + Examples []interface{} `json:"examples,omitempty"` // Example records for the output schema + Properties []Output `json:"properties"` // The output properties for the schema +} + +// Parameter represents Represents a parameter for a tool. +type Parameter struct { + Name string `json:"name"` // Name of the parameter + Kind string `json:"kind"` // The data type of the parameter + Description string `json:"description,omitempty"` // A short description of the property + Required bool `json:"required,omitempty"` // Whether the tool parameter is required + Default interface{} `json:"default,omitempty"` // The default value of the parameter - this represents the default value if none is provided + Value interface{} `json:"value,omitempty"` // Parameter value used for initializing manifest examples and tooling + Enum []interface{} `json:"enum,omitempty"` // Allowed enumeration values for the parameter +} + +// ObjectParameter represents Represents an object parameter for a tool. +type ObjectParameter struct { + Parameter + Kind string `json:"kind"` + Properties []Parameter `json:"properties"` // The properties of the object parameter +} + +// ArrayParameter represents Represents an array parameter for a tool. +type ArrayParameter struct { + Parameter + Kind string `json:"kind"` + Items interface{} `json:"items"` // The kind of items contained in the array +} + +// Parser represents Template parser definition +type Parser struct { + Kind string `json:"kind"` // Parser used to process the rendered template into API-compatible format + Options map[string]interface{} `json:"options,omitempty"` // Options for the parser +} + +// Scale represents Configuration for scaling container instances. +type Scale struct { + MinReplicas int32 `json:"minReplicas,omitempty"` // Minimum number of container instances to run + MaxReplicas int32 `json:"maxReplicas,omitempty"` // Maximum number of container instances to run + Cpu float32 `json:"cpu"` // CPU allocation per instance (in cores) + Memory float32 `json:"memory"` // Memory allocation per instance (in GB) +} + +// Template represents Template model for defining prompt templates. +// +// This model specifies the rendering engine used for slot filling prompts, +// the parser used to process the rendered template into API-compatible format, +// and additional options for the template engine. +// +// It allows for the creation of reusable templates that can be filled with dynamic data +// and processed to generate prompts for AI models. +type Template struct { + Format Format `json:"format"` // Template rendering engine used for slot filling prompts (e.g., mustache, jinja2) + Parser Parser `json:"parser"` // Parser used to process the rendered template into API-compatible format +} + +// Tool represents Represents a tool that can be used in prompts. +type Tool struct { + Name string `json:"name"` // Name of the tool. If a function tool, this is the function name, otherwise it is the type + Kind string `json:"kind"` // The kind identifier for the tool + Description string `json:"description,omitempty"` // A short description of the tool for metadata purposes + Bindings []Binding `json:"bindings,omitempty"` // Tool argument bindings to input properties +} + +// FunctionTool represents Represents a local function tool. +type FunctionTool struct { + Tool + Kind string `json:"kind"` // The kind identifier for function tools + Parameters []Parameter `json:"parameters"` // Parameters accepted by the function tool +} + +// ServerTool represents Represents a generic server tool that runs on a server +// This tool kind is designed for operations that require server-side execution +// It may include features such as authentication, data storage, and long-running processes +// This tool kind is ideal for tasks that involve complex computations or access to secure resources +// Server tools can be used to offload heavy processing from client applications +type ServerTool struct { + Tool + Kind string `json:"kind"` // The kind identifier for server tools. This is a wildcard and can represent any server tool type not explicitly defined. + Connection interface{} `json:"connection"` // Connection configuration for the server tool + Options map[string]interface{} `json:"options"` // Configuration options for the server tool +} + +// BingSearchTool represents The Bing search tool. +type BingSearchTool struct { + Tool + Kind string `json:"kind"` // The kind identifier for Bing search tools + Connection interface{} `json:"connection"` // The connection configuration for the Bing search tool + Configurations []BingSearchConfiguration `json:"configurations"` // The configuration options for the Bing search tool +} + +// FileSearchTool represents A tool for searching files. +// This tool allows an AI agent to search for files based on a query. +type FileSearchTool struct { + Tool + Kind string `json:"kind"` // The kind identifier for file search tools + Connection interface{} `json:"connection"` // The connection configuration for the file search tool + MaxNumResults int32 `json:"maxNumResults,omitempty"` // The maximum number of search results to return. + Ranker string `json:"ranker"` // File search ranker. + ScoreThreshold float32 `json:"scoreThreshold"` // Ranker search threshold. + VectorStoreIds []interface{} `json:"vectorStoreIds"` // The IDs of the vector stores to search within. +} + +// McpTool represents The MCP Server tool. +type McpTool struct { + Tool + Kind string `json:"kind"` // The kind identifier for MCP tools + Connection interface{} `json:"connection"` // The connection configuration for the MCP tool + Name string `json:"name"` // The name of the MCP tool + Url string `json:"url"` // The URL of the MCP server + Allowed []interface{} `json:"allowed"` // List of allowed operations or resources for the MCP tool +} + +// ModelTool represents The MCP Server tool. +type ModelTool struct { + Tool + Kind string `json:"kind"` // The kind identifier for a model connection as a tool + Model interface{} `json:"model"` // The connection configuration for the model tool +} + +// OpenApiTool represents +type OpenApiTool struct { + Tool + Kind string `json:"kind"` // The kind identifier for OpenAPI tools + Connection interface{} `json:"connection"` // The connection configuration for the OpenAPI tool + Specification string `json:"specification"` // The URL or relative path to the OpenAPI specification document (JSON or YAML format) +} + +// CodeInterpreterTool represents A tool for interpreting and executing code. +// This tool allows an AI agent to run code snippets and analyze data files. +type CodeInterpreterTool struct { + Tool + Kind string `json:"kind"` // The kind identifier for code interpreter tools + FileIds []interface{} `json:"fileIds"` // The IDs of the files to be used by the code interpreter tool. +} diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go index 31b19dc8fee..fba8c971244 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go @@ -8,18 +8,18 @@ import ( "testing" ) -// TestArrayInput_BasicSerialization tests basic JSON serialization -func TestArrayInput_BasicSerialization(t *testing.T) { - // Test that we can create and marshal a ArrayInput - obj := &ArrayInput{} - +// TestArrayProperty_BasicSerialization tests basic JSON serialization +func TestArrayProperty_BasicSerialization(t *testing.T) { + // Test that we can create and marshal a ArrayProperty + obj := &ArrayProperty{} + data, err := json.Marshal(obj) if err != nil { - t.Fatalf("Failed to marshal ArrayInput: %v", err) + t.Fatalf("Failed to marshal ArrayProperty: %v", err) } - - var obj2 ArrayInput + + var obj2 ArrayProperty if err := json.Unmarshal(data, &obj2); err != nil { - t.Fatalf("Failed to unmarshal ArrayInput: %v", err) + t.Fatalf("Failed to unmarshal ArrayProperty: %v", err) } -} \ No newline at end of file +} diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go index 2cb638b04c5..89a999b6e93 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go @@ -38,19 +38,17 @@ func ProcessRegistryManifest(ctx context.Context, manifest *Manifest, azdClient // Create the AgentManifest with the converted AgentDefinition result := &agent_yaml.AgentManifest{ - Agent: *agentDef, - Parameters: parameters, + Name: manifest.Name, + DisplayName: manifest.DisplayName, + Description: &manifest.Description, + Template: *agentDef, + Parameters: parameters, } return result, nil } func ConvertAgentDefinition(template agent_api.PromptAgentDefinition) (*agent_yaml.AgentDefinition, error) { - // Convert the model string to Model struct - model := agent_yaml.Model{ - Id: template.Model, - } - // Convert tools from agent_api.Tool to agent_yaml.Tool var tools []agent_yaml.Tool for _, apiTool := range template.Tools { @@ -61,20 +59,12 @@ func ConvertAgentDefinition(template agent_api.PromptAgentDefinition) (*agent_ya tools = append(tools, yamlTool) } - // Get instructions, defaulting to empty string if nil - instructions := "" - if template.Instructions != nil { - instructions = *template.Instructions - } - // Create the AgentDefinition agentDef := &agent_yaml.AgentDefinition{ - Kind: agent_yaml.AgentKindPrompt, // Set to prompt kind - Name: "", // Will be set later from manifest or user input - Description: "", // Will be set later from manifest or user input - Instructions: instructions, - Model: model, - Tools: tools, + Kind: agent_yaml.AgentKindPrompt, // Set to prompt kind + Name: "", // Will be set later from manifest or user input + Description: nil, // Will be set later from manifest or user input + Tools: &tools, // Metadata: make(map[string]interface{}), // TODO, Where does this come from? } @@ -92,29 +82,31 @@ func ConvertParameters(parameters map[string]OpenApiParameter) ([]agent_yaml.Par // Create a basic Parameter from the OpenApiParameter param := agent_yaml.Parameter{ Name: paramName, - Description: openApiParam.Description, - Required: openApiParam.Required, + Description: &openApiParam.Description, + Required: &openApiParam.Required, } // Extract type/kind from schema if available if openApiParam.Schema != nil { - param.Kind = openApiParam.Schema.Type - param.Default = openApiParam.Schema.Default + param.Schema = agent_yaml.ParameterSchema{ + Type: openApiParam.Schema.Type, + Default: &openApiParam.Schema.Default, + } // Convert enum values if present if len(openApiParam.Schema.Enum) > 0 { - param.Enum = openApiParam.Schema.Enum + param.Schema.Enum = &openApiParam.Schema.Enum } } // Use example as default if no schema default is provided - if param.Default == nil && openApiParam.Example != nil { - param.Default = openApiParam.Example + if param.Schema.Default == nil && openApiParam.Example != nil { + param.Schema.Default = &openApiParam.Example } // Fallback to string type if no type specified - if param.Kind == "" { - param.Kind = "string" + if param.Schema.Type == "" { + param.Schema.Type = "string" } result = append(result, param) @@ -155,18 +147,21 @@ func promptForYamlParameterValues(ctx context.Context, parameters []agent_yaml.P for _, param := range parameters { fmt.Printf("Parameter: %s\n", param.Name) - if param.Description != "" { - fmt.Printf(" Description: %s\n", param.Description) + if param.Description != nil && *param.Description != "" { + fmt.Printf(" Description: %s\n", *param.Description) } // Get default value - defaultValue := param.Default + var defaultValue interface{} + if param.Schema.Default != nil { + defaultValue = *param.Schema.Default + } // Get enum values if available var enumValues []string - if len(param.Enum) > 0 { - enumValues = make([]string, len(param.Enum)) - for i, val := range param.Enum { + if param.Schema.Enum != nil && len(*param.Schema.Enum) > 0 { + enumValues = make([]string, len(*param.Schema.Enum)) + for i, val := range *param.Schema.Enum { enumValues[i] = fmt.Sprintf("%v", val) } } @@ -186,12 +181,13 @@ func promptForYamlParameterValues(ctx context.Context, parameters []agent_yaml.P // Prompt for value var value interface{} var err error + isRequired := param.Required != nil && *param.Required if len(enumValues) > 0 { // Use selection for enum parameters value, err = promptForEnumValue(ctx, param.Name, enumValues, defaultValue, azdClient) } else { // Use text input for other parameters - value, err = promptForTextValue(ctx, param.Name, defaultValue, param.Required, azdClient) + value, err = promptForTextValue(ctx, param.Name, defaultValue, isRequired, azdClient) } if err != nil { diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/project/parser.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/project/parser.go index 7c6c7377545..28585fd3422 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/project/parser.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/project/parser.go @@ -59,12 +59,13 @@ func shouldRun(ctx context.Context, project *azdext.ProjectConfig) (bool, error) return false, fmt.Errorf("failed to read agent yaml file: %w", err) } - agent, err := agent_yaml.LoadAndValidateAgentManifest(content) + manifest, err := agent_yaml.LoadAndValidateAgentManifest(content) if err != nil { return false, fmt.Errorf("failed to validate agent yaml file: %w", err) } - return agent.Agent.Kind == agent_yaml.AgentKindYamlContainerApp, nil + agent := manifest.Template.(agent_yaml.AgentDefinition) + return agent.Kind == agent_yaml.AgentKindYamlContainerApp, nil } } } diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/project/service_target_agent.go index a63f4509e52..074b6a6639c 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/project/service_target_agent.go @@ -243,13 +243,14 @@ func (p *AgentServiceTargetProvider) Deploy( } // Determine agent type and delegate to appropriate deployment method - switch agent_api.AgentKind(agentManifest.Agent.Kind) { - case agent_api.AgentKindPrompt: + agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + switch agentDef.Kind { + case agent_yaml.AgentKindPrompt: return p.deployPromptAgent(ctx, agentManifest, azdEnv) - case agent_api.AgentKindHosted: + case agent_yaml.AgentKindHosted: return p.deployHostedAgent(ctx, serviceContext, progress, agentManifest, azdEnv) default: - return nil, fmt.Errorf("unsupported agent kind: %s", agentManifest.Agent.Kind) + return nil, fmt.Errorf("unsupported agent kind: %s", agentDef.Kind) } } @@ -265,7 +266,8 @@ func (p *AgentServiceTargetProvider) isContainerAgent() bool { return false } - return agentManifest.Agent.Kind == agent_yaml.AgentKind(agent_api.AgentKindHosted) + agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + return agentDef.Kind == agent_yaml.AgentKindHosted } // deployPromptAgent handles deployment of prompt-based agents @@ -285,11 +287,13 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( return nil, fmt.Errorf("failed to create Azure credential: %w", err) } + agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + fmt.Fprintf(os.Stderr, "Deploying Prompt Agent\n") fmt.Fprintf(os.Stderr, "======================\n") fmt.Fprintf(os.Stderr, "Loaded configuration from: %s\n", p.agentDefinitionPath) fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["AZURE_AI_PROJECT_ENDPOINT"]) - fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentManifest.Agent.Name) + fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentDef.Name) // Create agent request (no image URL needed for prompt agents) request, err := agent_yaml.CreateAgentAPIRequestFromManifest(*agentManifest) @@ -362,9 +366,11 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( return nil, fmt.Errorf("failed to create Azure credential: %w", err) } + agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + fmt.Fprintf(os.Stderr, "Loaded configuration from: %s\n", p.agentDefinitionPath) fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["AZURE_AI_PROJECT_ENDPOINT"]) - fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentManifest.Agent.Name) + fmt.Fprintf(os.Stderr, "Agent Name: %s\n", agentDef.Name) // Step 2: Create agent request with image URL request, err := agent_yaml.CreateAgentAPIRequestFromManifest(*agentManifest, agent_yaml.WithImageURL(fullImageURL)) @@ -461,18 +467,20 @@ func (p *AgentServiceTargetProvider) startAgentContainer( maxReplicas := int32(1) // Check if the agent definition has scale configuration - if containerAgent, ok := interface{}(agentManifest.Agent).(agent_yaml.ContainerAgent); ok { + if containerAgent, ok := interface{}(agentManifest.Template).(agent_yaml.ContainerAgent); ok { // For ContainerAgent, check if Options contains scale information - if options, exists := containerAgent.Options["scale"]; exists { - if scaleMap, ok := options.(map[string]interface{}); ok { - if minReplicasFloat, exists := scaleMap["minReplicas"]; exists { - if minReplicasVal, ok := minReplicasFloat.(float64); ok { - minReplicas = int32(minReplicasVal) + if containerAgent.Options != nil { + if options, exists := (*containerAgent.Options)["scale"]; exists { + if scaleMap, ok := options.(map[string]interface{}); ok { + if minReplicasFloat, exists := scaleMap["minReplicas"]; exists { + if minReplicasVal, ok := minReplicasFloat.(float64); ok { + minReplicas = int32(minReplicasVal) + } } - } - if maxReplicasFloat, exists := scaleMap["maxReplicas"]; exists { - if maxReplicasVal, ok := maxReplicasFloat.(float64); ok { - maxReplicas = int32(maxReplicasVal) + if maxReplicasFloat, exists := scaleMap["maxReplicas"]; exists { + if maxReplicasVal, ok := maxReplicasFloat.(float64); ok { + maxReplicas = int32(maxReplicasVal) + } } } } From d2dbe24af93b1680c58865825ab2945b1403096c Mon Sep 17 00:00:00 2001 From: trangevi Date: Sun, 26 Oct 2025 09:41:15 -0700 Subject: [PATCH 02/12] Some more fixes for typing Signed-off-by: trangevi --- .../internal/cmd/init.go | 69 +++- .../internal/pkg/agents/agent_yaml/parse.go | 174 +++++---- .../internal/pkg/agents/agent_yaml/yaml.go | 334 +++++++++--------- .../pkg/agents/registry_api/helpers.go | 39 +- .../internal/project/parser.go | 13 +- .../internal/project/service_target_agent.go | 100 +++++- 6 files changed, 461 insertions(+), 268 deletions(-) diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go index 6b90441f46f..a94841c59bb 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go @@ -701,8 +701,7 @@ func (a *InitAction) downloadAgentYaml( return nil, "", fmt.Errorf("marshaling agent manifest to YAML after parameter processing: %w", err) } - agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) - agentId := agentDef.Name + agentId := agentManifest.Name // Use targetDir if provided or set to local file pointer, otherwise default to "src/{agentId}" if targetDir == "" { @@ -720,12 +719,15 @@ func (a *InitAction) downloadAgentYaml( return nil, "", fmt.Errorf("saving file to %s: %w", filePath, err) } - if isGitHubUrl && agentDef.Kind == agent_yaml.AgentKindHosted { - // For hosted agents, download the entire parent directory - fmt.Println("Downloading full directory for hosted agent") - err := downloadParentDirectory(ctx, urlInfo, targetDir, ghCli, console) - if err != nil { - return nil, "", fmt.Errorf("downloading parent directory: %w", err) + if isGitHubUrl { + // Check if the template is a HostedContainerAgent + if _, isHostedContainer := agentManifest.Template.(agent_yaml.HostedContainerAgent); isHostedContainer { + // For hosted agents, download the entire parent directory + fmt.Println("Downloading full directory for hosted agent") + err := downloadParentDirectory(ctx, urlInfo, targetDir, ghCli, console) + if err != nil { + return nil, "", fmt.Errorf("downloading parent directory: %w", err) + } } } @@ -736,7 +738,31 @@ func (a *InitAction) downloadAgentYaml( func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentManifest *agent_yaml.AgentManifest) error { var host string - agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + + // Convert the template to bytes + templateBytes, err := json.Marshal(agentManifest.Template) + if err != nil { + return fmt.Errorf("failed to marshal agent template to JSON: %w", err) + } + + // Convert the bytes to a dictionary + var templateDict map[string]interface{} + if err := json.Unmarshal(templateBytes, &templateDict); err != nil { + return fmt.Errorf("failed to unmarshal agent template from JSON: %w", err) + } + + // Convert the dictionary to bytes + dictJsonBytes, err := json.Marshal(templateDict) + if err != nil { + return fmt.Errorf("failed to marshal templateDict to JSON: %w", err) + } + + // Convert the bytes to an Agent Definition + var agentDef agent_yaml.AgentDefinition + if err := json.Unmarshal(dictJsonBytes, &agentDef); err != nil { + return fmt.Errorf("failed to unmarshal JSON to AgentDefinition: %w", err) + } + switch agentDef.Kind { case "container": host = "containerapp" @@ -1224,7 +1250,30 @@ func downloadDirectoryContents( // } func (a *InitAction) updateEnvironment(ctx context.Context, agentManifest *agent_yaml.AgentManifest) error { - agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + // Convert the template to bytes + templateBytes, err := json.Marshal(agentManifest.Template) + if err != nil { + return fmt.Errorf("failed to marshal agent template to JSON: %w", err) + } + + // Convert the bytes to a dictionary + var templateDict map[string]interface{} + if err := json.Unmarshal(templateBytes, &templateDict); err != nil { + return fmt.Errorf("failed to unmarshal agent template from JSON: %w", err) + } + + // Convert the dictionary to bytes + dictJsonBytes, err := json.Marshal(templateDict) + if err != nil { + return fmt.Errorf("failed to marshal templateDict to JSON: %w", err) + } + + // Convert the bytes to an Agent Definition + var agentDef agent_yaml.AgentDefinition + if err := json.Unmarshal(dictJsonBytes, &agentDef); err != nil { + return fmt.Errorf("failed to unmarshal JSON to AgentDefinition: %w", err) + } + fmt.Printf("Updating environment variables for agent kind: %s\n", agentDef.Kind) // Get current environment diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse.go index bf3ecc1bc04..ae5de1915e5 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse.go @@ -11,11 +11,17 @@ import ( // LoadAndValidateAgentManifest parses YAML content and validates it as an AgentManifest // Returns the parsed manifest and any validation errors -func LoadAndValidateAgentManifest(yamlContent []byte) (*AgentManifest, error) { +func LoadAndValidateAgentManifest(manifestYamlContent []byte) (*AgentManifest, error) { + agentDef, err := ExtractAgentDefinition(manifestYamlContent) + if err != nil { + return nil, fmt.Errorf("YAML content does not conform to AgentManifest format: %w", err) + } + var manifest AgentManifest - if err := yaml.Unmarshal(yamlContent, &manifest); err != nil { + if err := yaml.Unmarshal(manifestYamlContent, &manifest); err != nil { return nil, fmt.Errorf("YAML content does not conform to AgentManifest format: %w", err) } + manifest.Template = agentDef if err := ValidateAgentManifest(&manifest); err != nil { return nil, err @@ -24,79 +30,119 @@ func LoadAndValidateAgentManifest(yamlContent []byte) (*AgentManifest, error) { return &manifest, nil } +// Returns a specific agent definition based on the "kind" field in the template +func ExtractAgentDefinition(manifestYamlContent []byte) (any, error) { + var genericManifest map[string]interface{} + if err := yaml.Unmarshal(manifestYamlContent, &genericManifest); err != nil { + return nil, fmt.Errorf("YAML content is not valid: %w", err) + } + + template := genericManifest["template"].(map[string]interface{}) + templateBytes, _ := yaml.Marshal(template) + + var agentDef AgentDefinition + if err := yaml.Unmarshal(templateBytes, &agentDef); err != nil { + return nil, fmt.Errorf("failed to unmarshal to AgentDefinition: %v\n", err) + } + + switch agentDef.Kind { + case AgentKindPrompt: + var agent PromptAgent + if err := yaml.Unmarshal(templateBytes, &agent); err != nil { + return nil, fmt.Errorf("failed to unmarshal to PromptAgent: %v\n", err) + } + + agent.AgentDefinition = agentDef + return agent, nil + case AgentKindHosted: + var agent HostedContainerAgent + if err := yaml.Unmarshal(templateBytes, &agent); err != nil { + return nil, fmt.Errorf("failed to unmarshal to HostedContainerAgent: %v\n", err) + } + + agent.AgentDefinition = agentDef + return agent, nil + case AgentKindContainerApp, AgentKindYamlContainerApp: + var agent ContainerAgent + if err := yaml.Unmarshal(templateBytes, &agent); err != nil { + return nil, fmt.Errorf("failed to unmarshal to ContainerAgent: %v\n", err) + } + + agent.AgentDefinition = agentDef + return agent, nil + } + + return nil, fmt.Errorf("unrecognized agent kind: %s", agentDef.Kind) +} + // ValidateAgentManifest performs basic validation of an AgentManifest // Returns an error if the manifest is invalid, nil if valid func ValidateAgentManifest(manifest *AgentManifest) error { var errors []string // First, extract the kind from the template to determine the agent type - templateMap, ok := manifest.Template.(map[string]interface{}) - if !ok { - errors = append(errors, "template must be a valid object") + templateBytes, _ := yaml.Marshal(manifest.Template) + + var agentDef AgentDefinition + if err := yaml.Unmarshal(templateBytes, &agentDef); err != nil { + errors = append(errors, "failed to parse template to determine agent kind") } else { - kindValue, hasKind := templateMap["kind"] - if !hasKind { - errors = append(errors, "template.kind is required") + // Validate the kind is supported + if !IsValidAgentKind(agentDef.Kind) { + validKinds := ValidAgentKinds() + validKindStrings := make([]string, len(validKinds)) + for i, validKind := range validKinds { + validKindStrings[i] = string(validKind) + } + errors = append(errors, fmt.Sprintf("template.kind must be one of: %v, got '%s'", validKindStrings, agentDef.Kind)) } else { - kind, kindOk := kindValue.(string) - if !kindOk { - errors = append(errors, "template.kind must be a string") - } else { - // Validate the kind is supported - if !IsValidAgentKind(AgentKind(kind)) { - validKinds := ValidAgentKinds() - validKindStrings := make([]string, len(validKinds)) - for i, validKind := range validKinds { - validKindStrings[i] = string(validKind) + switch AgentKind(agentDef.Kind) { + case AgentKindPrompt: + var agent PromptAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if agent.Name == "" { + errors = append(errors, "template.name is required") + } + if agent.Model.Id == "" { + errors = append(errors, "template.model.id is required") } - errors = append(errors, fmt.Sprintf("template.kind must be one of: %v, got '%s'", validKindStrings, kind)) } else { - // Convert template to YAML bytes and unmarshal to specific type based on kind - templateBytes, err := yaml.Marshal(manifest.Template) - if err != nil { - errors = append(errors, "failed to process template structure") - } else { - switch AgentKind(kind) { - case AgentKindPrompt: - var agent PromptAgent - if err := yaml.Unmarshal(templateBytes, &agent); err == nil { - if agent.Name == "" { - errors = append(errors, "template.name is required") - } - if agent.Model.Id == "" { - errors = append(errors, "template.model.id is required") - } - } - case AgentKindHosted: - var agent HostedContainerAgent - if err := yaml.Unmarshal(templateBytes, &agent); err == nil { - if agent.Name == "" { - errors = append(errors, "template.name is required") - } - if len(agent.Models) == 0 { - errors = append(errors, "template.models is required and must not be empty") - } - } - case AgentKindContainerApp, AgentKindYamlContainerApp: - var agent ContainerAgent - if err := yaml.Unmarshal(templateBytes, &agent); err == nil { - if agent.Name == "" { - errors = append(errors, "template.name is required") - } - if len(agent.Models) == 0 { - errors = append(errors, "template.models is required and must not be empty") - } - } - case AgentKindWorkflow: - var agent WorkflowAgent - if err := yaml.Unmarshal(templateBytes, &agent); err == nil { - if agent.Name == "" { - errors = append(errors, "template.name is required") - } - // WorkflowAgent doesn't have models, so no model validation needed - } - } + errors = append(errors, fmt.Sprintf("Failed to unmarshal to PromptAgent: %v\n", err)) + } + case AgentKindHosted: + var agent HostedContainerAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if agent.Name == "" { + errors = append(errors, "template.name is required") } + // TODO: Do we need this? + // if len(agent.Models) == 0 { + // errors = append(errors, "template.models is required and must not be empty") + // } + } else { + errors = append(errors, fmt.Sprintf("Failed to unmarshal to HostedContainerAgent: %v\n", err)) + } + case AgentKindContainerApp, AgentKindYamlContainerApp: + var agent ContainerAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if agent.Name == "" { + errors = append(errors, "template.name is required") + } + if len(agent.Models) == 0 { + errors = append(errors, "template.models is required and must not be empty") + } + } else { + errors = append(errors, fmt.Sprintf("Failed to unmarshal to ContainerAgent: %v\n", err)) + } + case AgentKindWorkflow: + var agent WorkflowAgent + if err := yaml.Unmarshal(templateBytes, &agent); err == nil { + if agent.Name == "" { + errors = append(errors, "template.name is required") + } + // WorkflowAgent doesn't have models, so no model validation needed + } else { + errors = append(errors, fmt.Sprintf("Failed to unmarshal to WorkflowAgent: %v\n", err)) } } } diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go index d05fdb01a59..d7f66003f82 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go @@ -40,37 +40,35 @@ func ValidAgentKinds() []AgentKind { // The specification includes metadata about the agent, model configuration, input parameters, expected outputs, // available tools, and template configurations for prompt rendering. type AgentDefinition struct { - Kind AgentKind `json:"kind"` - Name string `json:"name"` - DisplayName *string `json:"displayName,omitempty"` - Description *string `json:"description,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - InputSchema *PropertySchema `json:"inputSchema,omitempty"` - OutputSchema *PropertySchema `json:"outputSchema,omitempty"` - Tools *[]Tool `json:"tools,omitempty"` + Kind AgentKind `json:"kind" yaml:"kind"` + Name string `json:"name" yaml:"name"` + DisplayName *string `json:"displayName,omitempty" yaml:"displayName,omitempty"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + InputSchema *PropertySchema `json:"inputSchema,omitempty" yaml:"inputSchema,omitempty"` + OutputSchema *PropertySchema `json:"outputSchema,omitempty" yaml:"outputSchema,omitempty"` + Tools *[]Tool `json:"tools,omitempty" yaml:"tools,omitempty"` } // PromptAgent is a prompt based agent definition used to create agents that can be executed directly. // These agents can leverage tools, input parameters, and templates to generate responses. // They are designed to be straightforward and easy to use for various applications. type PromptAgent struct { - AgentDefinition // Embedded parent struct - Kind AgentKind `json:"kind"` - Model Model `json:"model"` - Template *Template `json:"template,omitempty"` - Instructions *string `json:"instructions,omitempty"` - AdditionalInstructions *string `json:"additionalInstructions,omitempty"` + AgentDefinition `json:",inline" yaml:",inline"` + Model Model `json:"model" yaml:"model"` + Template *Template `json:"template,omitempty" yaml:"template,omitempty"` + Instructions *string `json:"instructions,omitempty" yaml:"instructions,omitempty"` + AdditionalInstructions *string `json:"additionalInstructions,omitempty" yaml:"additionalInstructions,omitempty"` } // HostedContainerAgent represents a container based agent hosted by the provider/publisher. // The intent is to represent a container application that the user wants to run // in a hosted environment that the provider manages. type HostedContainerAgent struct { - AgentDefinition // Embedded parent struct - Kind AgentKind `json:"kind"` - Protocols []ProtocolVersionRecord `json:"protocols"` - Models []Model `json:"models"` - Container HostedContainerDefinition `json:"container"` + AgentDefinition `json:",inline" yaml:",inline"` + Protocols []ProtocolVersionRecord `json:"protocols" yaml:"protocols"` + Models []Model `json:"models" yaml:"models"` + Container HostedContainerDefinition `json:"container" yaml:"container"` } // ContainerAgent represents a containerized agent that can be deployed and hosted. @@ -81,13 +79,12 @@ type HostedContainerAgent struct { // based on the provided configuration. This kind of agent represents the users intent // to bring their own container specific app hosting platform that they manage. type ContainerAgent struct { - AgentDefinition // Embedded parent struct - Kind AgentKind `json:"kind"` - Protocols []ProtocolVersionRecord `json:"protocols"` - Models []Model `json:"models"` - Resource string `json:"resource"` - IngressSuffix string `json:"ingressSuffix"` - Options *map[string]interface{} `json:"options,omitempty"` + AgentDefinition `json:",inline" yaml:",inline"` + Protocols []ProtocolVersionRecord `json:"protocols" yaml:"protocols"` + Models []Model `json:"models" yaml:"models"` + Resource string `json:"resource" yaml:"resource"` + IngressSuffix string `json:"ingressSuffix" yaml:"ingressSuffix"` + Options *map[string]interface{} `json:"options,omitempty" yaml:"options,omitempty"` } // WorkflowAgent is a workflow agent that can orchestrate multiple steps and actions. @@ -99,9 +96,8 @@ type ContainerAgent struct { // Note: The detailed structure of the workflow steps and actions is not defined here // and would need to be implemented based on specific use cases and requirements. type WorkflowAgent struct { - AgentDefinition // Embedded parent struct - Kind AgentKind `json:"kind"` - Trigger *map[string]interface{} `json:"trigger,omitempty"` + AgentDefinition `json:",inline" yaml:",inline"` + Trigger *map[string]interface{} `json:"trigger,omitempty" yaml:"trigger,omitempty"` } // AgentManifest represents a manifest that can be used to create agents dynamically. @@ -112,227 +108,227 @@ type WorkflowAgent struct { // Once parameters are provided, these can be referenced in the manifest using the following notation: // `{{myParameter}}` This allows for dynamic configuration of the agent based on the provided parameters. type AgentManifest struct { - Name string `json:"name"` - DisplayName string `json:"displayName"` - Description *string `json:"description,omitempty"` - Metadata *map[string]interface{} `json:"metadata,omitempty"` - Template any `json:"template"` // can be PromptAgent, HostedContainerAgent, ContainerAgent, or WorkflowAgent - Parameters []Parameter `json:"parameters"` + Name string `json:"name" yaml:"name"` + DisplayName string `json:"displayName" yaml:"displayName"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Metadata *map[string]interface{} `json:"metadata,omitempty" yaml:"metadata,omitempty"` + Template any `json:"template" yaml:"template"` // can be PromptAgent, HostedContainerAgent, ContainerAgent, or WorkflowAgent + Parameters *map[string]Parameter `json:"parameters,omitempty" yaml:"parameters,omitempty"` } // Binding represents a binding between an input property and a tool parameter. type Binding struct { - Name string `json:"name"` - Input string `json:"input"` + Name string `json:"name" yaml:"name"` + Input string `json:"input" yaml:"input"` } // BingSearchOption provides configuration options for the Bing search tool. type BingSearchOption struct { - Name string `json:"name"` - Market *string `json:"market,omitempty"` - SetLang *string `json:"setLang,omitempty"` - Count *int `json:"count,omitempty"` - Freshness *string `json:"freshness,omitempty"` + Name string `json:"name" yaml:"name"` + Market *string `json:"market,omitempty" yaml:"market,omitempty"` + SetLang *string `json:"setLang,omitempty" yaml:"setLang,omitempty"` + Count *int `json:"count,omitempty" yaml:"count,omitempty"` + Freshness *string `json:"freshness,omitempty" yaml:"freshness,omitempty"` } // Connection configuration for AI agents. // `provider`, `kind`, and `endpoint` are required properties here, // but this section can accept additional via options. type Connection struct { - Kind string `json:"kind"` - Authority string `json:"authority"` - UsageDescription *string `json:"usageDescription,omitempty"` + Kind string `json:"kind" yaml:"kind"` + Authority string `json:"authority" yaml:"authority"` + UsageDescription *string `json:"usageDescription,omitempty" yaml:"usageDescription,omitempty"` } // ReferenceConnection provides connection configuration for AI services using named connections. type ReferenceConnection struct { - Connection // Embedded parent struct - Kind string `json:"kind"` - Name string `json:"name"` + Connection `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Name string `json:"name" yaml:"name"` } // TokenCredentialConnection provides connection configuration for AI services using token credentials. type TokenCredentialConnection struct { - Connection // Embedded parent struct - Kind string `json:"kind"` - Endpoint string `json:"endpoint"` + Connection `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Endpoint string `json:"endpoint" yaml:"endpoint"` } // ApiKeyConnection provides connection configuration for AI services using API keys. type ApiKeyConnection struct { - Connection // Embedded parent struct - Kind string `json:"kind"` - Endpoint string `json:"endpoint"` - ApiKey string `json:"apiKey"` + Connection `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Endpoint string `json:"endpoint" yaml:"endpoint"` + ApiKey string `json:"apiKey" yaml:"apiKey"` } // EnvironmentVariable represents an environment variable configuration. type EnvironmentVariable struct { - Name string `json:"name"` - Value string `json:"value"` + Name string `json:"name" yaml:"name"` + Value string `json:"value" yaml:"value"` } // Format represents the Format from _Format.py type Format struct { - Kind string `json:"kind"` - Strict *bool `json:"strict,omitempty"` - Options *map[string]interface{} `json:"options,omitempty"` + Kind string `json:"kind" yaml:"kind"` + Strict *bool `json:"strict,omitempty" yaml:"strict,omitempty"` + Options *map[string]interface{} `json:"options,omitempty" yaml:"options,omitempty"` } // HostedContainerDefinition represents the HostedContainerDefinition from _HostedContainerDefinition.py type HostedContainerDefinition struct { - Scale Scale `json:"scale"` - Image *string `json:"image,omitempty"` - Context map[string]interface{} `json:"context"` - EnvironmentVariables *[]EnvironmentVariable `json:"environmentVariables,omitempty"` + Scale Scale `json:"scale" yaml:"scale"` + Image *string `json:"image,omitempty" yaml:"image,omitempty"` + Context map[string]interface{} `json:"context" yaml:"context"` + EnvironmentVariables *[]EnvironmentVariable `json:"environmentVariables,omitempty" yaml:"environmentVariables,omitempty"` } // McpServerApprovalMode represents the McpServerApprovalMode from _McpServerApprovalMode.py type McpServerApprovalMode struct { - Mode string `json:"mode"` - AlwaysRequireApprovalTools []string `json:"alwaysRequireApprovalTools"` - NeverRequireApprovalTools []string `json:"neverRequireApprovalTools"` + Mode string `json:"mode" yaml:"mode"` + AlwaysRequireApprovalTools []string `json:"alwaysRequireApprovalTools" yaml:"alwaysRequireApprovalTools"` + NeverRequireApprovalTools []string `json:"neverRequireApprovalTools" yaml:"neverRequireApprovalTools"` } // Model defines the structure and behavior of AI agents. // This model includes properties for specifying the model's provider, connection details, and various options. // It allows for flexible configuration of AI models to suit different use cases and requirements. type Model struct { - Id string `json:"id"` - Provider *string `json:"provider,omitempty"` - ApiType string `json:"apiType"` - Deployment *string `json:"deployment,omitempty"` - Version *string `json:"version,omitempty"` - Connection *Connection `json:"connection,omitempty"` - Options *ModelOptions `json:"options,omitempty"` + Id string `json:"id" yaml:"id"` + Provider *string `json:"provider,omitempty" yaml:"provider,omitempty"` + ApiType string `json:"apiType" yaml:"apiType"` + Deployment *string `json:"deployment,omitempty" yaml:"deployment,omitempty"` + Version *string `json:"version,omitempty" yaml:"version,omitempty"` + Connection *Connection `json:"connection,omitempty" yaml:"connection,omitempty"` + Options *ModelOptions `json:"options,omitempty" yaml:"options,omitempty"` } // ModelOptions represents the ModelOptions from _ModelOptions.py type ModelOptions struct { - FrequencyPenalty *float64 `json:"frequencyPenalty,omitempty"` - MaxOutputTokens *int `json:"maxOutputTokens,omitempty"` - PresencePenalty *float64 `json:"presencePenalty,omitempty"` - Seed *int `json:"seed,omitempty"` - Temperature *float64 `json:"temperature,omitempty"` - TopK *int `json:"topK,omitempty"` - TopP *float64 `json:"topP,omitempty"` - StopSequences *[]string `json:"stopSequences,omitempty"` - AllowMultipleToolCalls *bool `json:"allowMultipleToolCalls,omitempty"` - AdditionalProperties *map[string]interface{} `json:"additionalProperties,omitempty"` + FrequencyPenalty *float64 `json:"frequencyPenalty,omitempty" yaml:"frequencyPenalty,omitempty"` + MaxOutputTokens *int `json:"maxOutputTokens,omitempty" yaml:"maxOutputTokens,omitempty"` + PresencePenalty *float64 `json:"presencePenalty,omitempty" yaml:"presencePenalty,omitempty"` + Seed *int `json:"seed,omitempty" yaml:"seed,omitempty"` + Temperature *float64 `json:"temperature,omitempty" yaml:"temperature,omitempty"` + TopK *int `json:"topK,omitempty" yaml:"topK,omitempty"` + TopP *float64 `json:"topP,omitempty" yaml:"topP,omitempty"` + StopSequences *[]string `json:"stopSequences,omitempty" yaml:"stopSequences,omitempty"` + AllowMultipleToolCalls *bool `json:"allowMultipleToolCalls,omitempty" yaml:"allowMultipleToolCalls,omitempty"` + AdditionalProperties *map[string]interface{} `json:"additionalProperties,omitempty" yaml:"additionalProperties,omitempty"` } // Parameter represents the Parameter from _Parameter.py type Parameter struct { - Name string `json:"name"` - Description *string `json:"description,omitempty"` - Required *bool `json:"required,omitempty"` - Schema ParameterSchema `json:"schema"` + Name string `json:"name" yaml:"name"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Required *bool `json:"required,omitempty" yaml:"required,omitempty"` + Schema ParameterSchema `json:"schema" yaml:"schema"` } // ParameterSchema represents the ParameterSchema from _ParameterSchema.py type ParameterSchema struct { - Type string `json:"type"` - Default *interface{} `json:"default,omitempty"` - Enum *[]interface{} `json:"enum,omitempty"` - Extensions *map[string]interface{} `json:"extensions,omitempty"` + Type string `json:"type" yaml:"type"` + Default *interface{} `json:"default,omitempty" yaml:"default,omitempty"` + Enum *[]interface{} `json:"enum,omitempty" yaml:"enum,omitempty"` + Extensions *map[string]interface{} `json:"extensions,omitempty" yaml:"extensions,omitempty"` } // StringParameterSchema represents a string parameter schema. type StringParameterSchema struct { - ParameterSchema // Embedded parent struct - Type string `json:"type"` - MinLength *int `json:"minLength,omitempty"` - MaxLength *int `json:"maxLength,omitempty"` - Pattern *string `json:"pattern,omitempty"` + ParameterSchema `yaml:",inline"` // Embedded parent struct + Type string `json:"type" yaml:"type"` + MinLength *int `json:"minLength,omitempty" yaml:"minLength,omitempty"` + MaxLength *int `json:"maxLength,omitempty" yaml:"maxLength,omitempty"` + Pattern *string `json:"pattern,omitempty" yaml:"pattern,omitempty"` } // DigitParameterSchema represents a digit parameter schema. type DigitParameterSchema struct { - ParameterSchema // Embedded parent struct - Type string `json:"type"` - Minimum *int `json:"minimum,omitempty"` - Maximum *int `json:"maximum,omitempty"` - ExclusiveMinimum *bool `json:"exclusiveMinimum,omitempty"` - ExclusiveMaximum *bool `json:"exclusiveMaximum,omitempty"` - MultipleOf *float64 `json:"multipleOf,omitempty"` + ParameterSchema `yaml:",inline"` // Embedded parent struct + Type string `json:"type" yaml:"type"` + Minimum *int `json:"minimum,omitempty" yaml:"minimum,omitempty"` + Maximum *int `json:"maximum,omitempty" yaml:"maximum,omitempty"` + ExclusiveMinimum *bool `json:"exclusiveMinimum,omitempty" yaml:"exclusiveMinimum,omitempty"` + ExclusiveMaximum *bool `json:"exclusiveMaximum,omitempty" yaml:"exclusiveMaximum,omitempty"` + MultipleOf *float64 `json:"multipleOf,omitempty" yaml:"multipleOf,omitempty"` } // Parser represents the Parser from _Parser.py type Parser struct { - Kind string `json:"kind"` - Options *map[string]interface{} `json:"options,omitempty"` + Kind string `json:"kind" yaml:"kind"` + Options *map[string]interface{} `json:"options,omitempty" yaml:"options,omitempty"` } // Property represents the Property from _Property.py type Property struct { - Name string `json:"name"` - Kind string `json:"kind"` - Description *string `json:"description,omitempty"` - Required *bool `json:"required,omitempty"` - Strict *bool `json:"strict,omitempty"` - Default *interface{} `json:"default,omitempty"` - Example *interface{} `json:"example,omitempty"` - EnumValues *[]interface{} `json:"enumValues,omitempty"` + Name string `json:"name" yaml:"name"` + Kind string `json:"kind" yaml:"kind"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Required *bool `json:"required,omitempty" yaml:"required,omitempty"` + Strict *bool `json:"strict,omitempty" yaml:"strict,omitempty"` + Default *interface{} `json:"default,omitempty" yaml:"default,omitempty"` + Example *interface{} `json:"example,omitempty" yaml:"example,omitempty"` + EnumValues *[]interface{} `json:"enumValues,omitempty" yaml:"enumValues,omitempty"` } // ArrayProperty represents an array property. // This extends the base Property model to represent an array of items. type ArrayProperty struct { - Property // Embedded parent struct - Kind string `json:"kind"` - Items Property `json:"items"` + Property `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Items Property `json:"items" yaml:"items"` } // ObjectProperty represents an object property. // This extends the base Property model to represent a structured object. type ObjectProperty struct { - Property // Embedded parent struct - Kind string `json:"kind"` - Properties []Property `json:"properties"` + Property `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Properties []Property `json:"properties" yaml:"properties"` } // PropertySchema defines the property schema of a model. // This includes the properties and example records. type PropertySchema struct { - Examples *[]map[string]interface{} `json:"examples,omitempty"` - Strict *bool `json:"strict,omitempty"` - Properties []Property `json:"properties"` + Examples *[]map[string]interface{} `json:"examples,omitempty" yaml:"examples,omitempty"` + Strict *bool `json:"strict,omitempty" yaml:"strict,omitempty"` + Properties []Property `json:"properties" yaml:"properties"` } // ProtocolVersionRecord represents the ProtocolVersionRecord from _ProtocolVersionRecord.py type ProtocolVersionRecord struct { - Protocol string `json:"protocol"` - Version string `json:"version"` + Protocol string `json:"protocol" yaml:"protocol"` + Version string `json:"version" yaml:"version"` } // Scale represents the Scale from _Scale.py type Scale struct { - MinReplicas *int `json:"minReplicas,omitempty"` - MaxReplicas *int `json:"maxReplicas,omitempty"` - Cpu float64 `json:"cpu"` - Memory float64 `json:"memory"` + MinReplicas *int `json:"minReplicas,omitempty" yaml:"minReplicas,omitempty"` + MaxReplicas *int `json:"maxReplicas,omitempty" yaml:"maxReplicas,omitempty"` + Cpu float64 `json:"cpu" yaml:"cpu"` + Memory float64 `json:"memory" yaml:"memory"` } // Template represents the Template from _Template.py type Template struct { - Format Format `json:"format"` - Parser Parser `json:"parser"` + Format Format `json:"format" yaml:"format"` + Parser Parser `json:"parser" yaml:"parser"` } // Tool represents a tool that can be used in prompts. type Tool struct { - Name string `json:"name"` - Kind string `json:"kind"` - Description *string `json:"description,omitempty"` - Bindings *[]Binding `json:"bindings,omitempty"` + Name string `json:"name" yaml:"name"` + Kind string `json:"kind" yaml:"kind"` + Description *string `json:"description,omitempty" yaml:"description,omitempty"` + Bindings *[]Binding `json:"bindings,omitempty" yaml:"bindings,omitempty"` } // FunctionTool represents a local function tool. type FunctionTool struct { - Tool // Embedded parent struct - Kind string `json:"kind"` - Parameters PropertySchema `json:"parameters"` - Strict *bool `json:"strict,omitempty"` + Tool `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Parameters PropertySchema `json:"parameters" yaml:"parameters"` + Strict *bool `json:"strict,omitempty" yaml:"strict,omitempty"` } // ServerTool represents a generic server tool that runs on a server. @@ -341,56 +337,56 @@ type FunctionTool struct { // This tool kind is ideal for tasks that involve complex computations or access to secure resources. // Server tools can be used to offload heavy processing from client applications. type ServerTool struct { - Tool // Embedded parent struct - Kind string `json:"kind"` - Connection Connection `json:"connection"` - Options map[string]interface{} `json:"options"` + Tool `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Connection Connection `json:"connection" yaml:"connection"` + Options map[string]interface{} `json:"options" yaml:"options"` } // BingSearchTool represents the Bing search tool. type BingSearchTool struct { - Tool // Embedded parent struct - Kind string `json:"kind"` - Connection Connection `json:"connection"` - Options []BingSearchOption `json:"options"` + Tool `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Connection Connection `json:"connection" yaml:"connection"` + Options []BingSearchOption `json:"options" yaml:"options"` } // FileSearchTool is a tool for searching files. // This tool allows an AI agent to search for files based on a query. type FileSearchTool struct { - Tool // Embedded parent struct - Kind string `json:"kind"` - Connection Connection `json:"connection"` - VectorStoreIds []string `json:"vectorStoreIds"` - MaxNumResults *int `json:"maxNumResults,omitempty"` - Ranker string `json:"ranker"` - ScoreThreshold float64 `json:"scoreThreshold"` - Filters *map[string]interface{} `json:"filters,omitempty"` + Tool `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Connection Connection `json:"connection" yaml:"connection"` + VectorStoreIds []string `json:"vectorStoreIds" yaml:"vectorStoreIds"` + MaxNumResults *int `json:"maxNumResults,omitempty" yaml:"maxNumResults,omitempty"` + Ranker string `json:"ranker" yaml:"ranker"` + ScoreThreshold float64 `json:"scoreThreshold" yaml:"scoreThreshold"` + Filters *map[string]interface{} `json:"filters,omitempty" yaml:"filters,omitempty"` } // McpTool represents the MCP Server tool. type McpTool struct { - Tool // Embedded parent struct - Kind string `json:"kind"` - Connection Connection `json:"connection"` - Name string `json:"name"` - Url string `json:"url"` - ApprovalMode McpServerApprovalMode `json:"approvalMode"` - AllowedTools []string `json:"allowedTools"` + Tool `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Connection Connection `json:"connection" yaml:"connection"` + Name string `json:"name" yaml:"name"` + Url string `json:"url" yaml:"url"` + ApprovalMode McpServerApprovalMode `json:"approvalMode" yaml:"approvalMode"` + AllowedTools []string `json:"allowedTools" yaml:"allowedTools"` } // OpenApiTool represents an OpenAPI tool. type OpenApiTool struct { - Tool // Embedded parent struct - Kind string `json:"kind"` - Connection Connection `json:"connection"` - Specification string `json:"specification"` + Tool `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + Connection Connection `json:"connection" yaml:"connection"` + Specification string `json:"specification" yaml:"specification"` } // CodeInterpreterTool is a tool for interpreting and executing code. // This tool allows an AI agent to run code snippets and analyze data files. type CodeInterpreterTool struct { - Tool // Embedded parent struct - Kind string `json:"kind"` - FileIds []string `json:"fileIds"` + Tool `yaml:",inline"` // Embedded parent struct + Kind string `json:"kind" yaml:"kind"` + FileIds []string `json:"fileIds" yaml:"fileIds"` } diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go index 89a999b6e93..3070d591f76 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go @@ -71,12 +71,12 @@ func ConvertAgentDefinition(template agent_api.PromptAgentDefinition) (*agent_ya return agentDef, nil } -func ConvertParameters(parameters map[string]OpenApiParameter) ([]agent_yaml.Parameter, error) { +func ConvertParameters(parameters map[string]OpenApiParameter) (*map[string]agent_yaml.Parameter, error) { if len(parameters) == 0 { - return []agent_yaml.Parameter{}, nil + return nil, nil } - result := make([]agent_yaml.Parameter, 0, len(parameters)) + result := make(map[string]agent_yaml.Parameter, len(parameters)) for paramName, openApiParam := range parameters { // Create a basic Parameter from the OpenApiParameter @@ -109,16 +109,16 @@ func ConvertParameters(parameters map[string]OpenApiParameter) ([]agent_yaml.Par param.Schema.Type = "string" } - result = append(result, param) + result[paramName] = param } - return result, nil + return &result, nil } // ProcessManifestParameters prompts the user for parameter values and injects them into the template func ProcessManifestParameters(ctx context.Context, manifest *agent_yaml.AgentManifest, azdClient *azdext.AzdClient) (*agent_yaml.AgentManifest, error) { // If no parameters are defined, return the manifest as-is - if len(manifest.Parameters) == 0 { + if manifest.Parameters == nil || len(*manifest.Parameters) == 0 { fmt.Println("The manifest does not contain parameters that need to be configured.") return manifest, nil } @@ -127,7 +127,7 @@ func ProcessManifestParameters(ctx context.Context, manifest *agent_yaml.AgentMa fmt.Println() // Collect parameter values from user - paramValues, err := promptForYamlParameterValues(ctx, manifest.Parameters, azdClient) + paramValues, err := promptForYamlParameterValues(ctx, *manifest.Parameters, azdClient) if err != nil { return nil, fmt.Errorf("failed to collect parameter values: %w", err) } @@ -142,11 +142,11 @@ func ProcessManifestParameters(ctx context.Context, manifest *agent_yaml.AgentMa } // promptForYamlParameterValues prompts the user for values for each YAML parameter -func promptForYamlParameterValues(ctx context.Context, parameters []agent_yaml.Parameter, azdClient *azdext.AzdClient) (ParameterValues, error) { +func promptForYamlParameterValues(ctx context.Context, parameters map[string]agent_yaml.Parameter, azdClient *azdext.AzdClient) (ParameterValues, error) { paramValues := make(ParameterValues) - for _, param := range parameters { - fmt.Printf("Parameter: %s\n", param.Name) + for paramName, param := range parameters { + fmt.Printf("Parameter: %s\n", paramName) if param.Description != nil && *param.Description != "" { fmt.Printf(" Description: %s\n", *param.Description) } @@ -184,17 +184,17 @@ func promptForYamlParameterValues(ctx context.Context, parameters []agent_yaml.P isRequired := param.Required != nil && *param.Required if len(enumValues) > 0 { // Use selection for enum parameters - value, err = promptForEnumValue(ctx, param.Name, enumValues, defaultValue, azdClient) + value, err = promptForEnumValue(ctx, paramName, enumValues, defaultValue, azdClient) } else { // Use text input for other parameters - value, err = promptForTextValue(ctx, param.Name, defaultValue, isRequired, azdClient) + value, err = promptForTextValue(ctx, paramName, defaultValue, isRequired, azdClient) } if err != nil { - return nil, fmt.Errorf("failed to get value for parameter %s: %w", param.Name, err) + return nil, fmt.Errorf("failed to get value for parameter %s: %w", paramName, err) } - paramValues[param.Name] = value + paramValues[paramName] = value } return paramValues, nil @@ -215,12 +215,12 @@ func injectParameterValuesIntoManifest(manifest *agent_yaml.AgentManifest, param } // Convert back to AgentManifest - var processedManifest agent_yaml.AgentManifest - if err := json.Unmarshal(processedBytes, &processedManifest); err != nil { - return nil, fmt.Errorf("failed to unmarshal processed manifest: %w", err) + processedManifest, err := agent_yaml.LoadAndValidateAgentManifest(processedBytes) + if err != nil { + return nil, fmt.Errorf("failed to reload processed manifest: %w", err) } - return &processedManifest, nil + return processedManifest, nil } // promptForEnumValue prompts the user to select from enumerated values @@ -308,6 +308,9 @@ func injectParameterValues(template json.RawMessage, paramValues ParameterValues placeholder := fmt.Sprintf("{{%s}}", paramName) valueStr := fmt.Sprintf("%v", paramValue) templateStr = strings.ReplaceAll(templateStr, placeholder, valueStr) + + placeholder = fmt.Sprintf("{{ %s }}", paramName) + templateStr = strings.ReplaceAll(templateStr, placeholder, valueStr) } // Check for any remaining unreplaced placeholders diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/project/parser.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/project/parser.go index f30eff10292..cb449410ec7 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/project/parser.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/project/parser.go @@ -26,6 +26,7 @@ import ( "github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/graphsdk" + "github.com/braydonk/yaml" "github.com/google/uuid" ) @@ -63,8 +64,16 @@ func shouldRun(ctx context.Context, project *azdext.ProjectConfig) (bool, error) return false, fmt.Errorf("failed to validate agent yaml file: %w", err) } - agent := manifest.Template.(agent_yaml.AgentDefinition) - return agent.Kind == agent_yaml.AgentKindYamlContainerApp, nil + agentDefBytes, err := yaml.Marshal(manifest.Template) + if err != nil { + return false, fmt.Errorf("failed to marshal agent definition when updating project: %w", err) + } + var agentDef agent_yaml.AgentDefinition + if err := yaml.Unmarshal(agentDefBytes, &agentDef); err != nil { + return false, fmt.Errorf("failed to unmarshal agent definition when updating project: %w", err) + } + + return agentDef.Kind == agent_yaml.AgentKindYamlContainerApp, nil } } } diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/project/service_target_agent.go index 96c1120af7c..030f5d7f287 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/project/service_target_agent.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/project/service_target_agent.go @@ -5,6 +5,7 @@ package project import ( "context" + "encoding/json" "errors" "fmt" "os" @@ -269,8 +270,30 @@ func (p *AgentServiceTargetProvider) Deploy( return nil, fmt.Errorf("failed to create Azure credential: %w", err) } - // Determine agent type and delegate to appropriate deployment method - agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + // Convert the template to bytes + templateBytes, err := json.Marshal(agentManifest.Template) + if err != nil { + return nil, fmt.Errorf("failed to marshal agent template to JSON: %w", err) + } + + // Convert the bytes to a dictionary + var templateDict map[string]interface{} + if err := json.Unmarshal(templateBytes, &templateDict); err != nil { + return nil, fmt.Errorf("failed to unmarshal agent template from JSON: %w", err) + } + + // Convert the dictionary to bytes + dictJsonBytes, err := json.Marshal(templateDict) + if err != nil { + return nil, fmt.Errorf("failed to marshal templateDict to JSON: %w", err) + } + + // Convert the bytes to an Agent Definition + var agentDef agent_yaml.AgentDefinition + if err := json.Unmarshal(dictJsonBytes, &agentDef); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON to AgentDefinition: %w", err) + } + switch agentDef.Kind { case agent_yaml.AgentKindPrompt: return p.deployPromptAgent(ctx, cred, agentManifest, azdEnv) @@ -293,7 +316,30 @@ func (p *AgentServiceTargetProvider) isContainerAgent() bool { return false } - agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + // Convert the template to bytes + templateBytes, err := json.Marshal(agentManifest.Template) + if err != nil { + return false + } + + // Convert the bytes to a dictionary + var templateDict map[string]interface{} + if err := json.Unmarshal(templateBytes, &templateDict); err != nil { + return false + } + + // Convert the dictionary to bytes + dictJsonBytes, err := json.Marshal(templateDict) + if err != nil { + return false + } + + // Convert the bytes to an Agent Definition + var agentDef agent_yaml.AgentDefinition + if err := json.Unmarshal(dictJsonBytes, &agentDef); err != nil { + return false + } + return agentDef.Kind == agent_yaml.AgentKindHosted } @@ -309,7 +355,29 @@ func (p *AgentServiceTargetProvider) deployPromptAgent( return nil, fmt.Errorf("AZURE_AI_PROJECT_ENDPOINT environment variable is required") } - agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + // Convert the template to bytes + templateBytes, err := json.Marshal(agentManifest.Template) + if err != nil { + return nil, fmt.Errorf("failed to marshal agent template to JSON: %w", err) + } + + // Convert the bytes to a dictionary + var templateDict map[string]interface{} + if err := json.Unmarshal(templateBytes, &templateDict); err != nil { + return nil, fmt.Errorf("failed to unmarshal agent template from JSON: %w", err) + } + + // Convert the dictionary to bytes + dictJsonBytes, err := json.Marshal(templateDict) + if err != nil { + return nil, fmt.Errorf("failed to marshal templateDict to JSON: %w", err) + } + + // Convert the bytes to an Agent Definition + var agentDef agent_yaml.AgentDefinition + if err := json.Unmarshal(dictJsonBytes, &agentDef); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON to AgentDefinition: %w", err) + } fmt.Fprintf(os.Stderr, "Deploying Prompt Agent\n") fmt.Fprintf(os.Stderr, "======================\n") @@ -383,7 +451,29 @@ func (p *AgentServiceTargetProvider) deployHostedAgent( return nil, errors.New("published container artifact not found") } - agentDef := agentManifest.Template.(agent_yaml.AgentDefinition) + // Convert the template to bytes + templateBytes, err := json.Marshal(agentManifest.Template) + if err != nil { + return nil, fmt.Errorf("failed to marshal agent template to JSON: %w", err) + } + + // Convert the bytes to a dictionary + var templateDict map[string]interface{} + if err := json.Unmarshal(templateBytes, &templateDict); err != nil { + return nil, fmt.Errorf("failed to unmarshal agent template from JSON: %w", err) + } + + // Convert the dictionary to bytes + dictJsonBytes, err := json.Marshal(templateDict) + if err != nil { + return nil, fmt.Errorf("failed to marshal templateDict to JSON: %w", err) + } + + // Convert the bytes to an Agent Definition + var agentDef agent_yaml.AgentDefinition + if err := json.Unmarshal(dictJsonBytes, &agentDef); err != nil { + return nil, fmt.Errorf("failed to unmarshal JSON to AgentDefinition: %w", err) + } fmt.Fprintf(os.Stderr, "Loaded configuration from: %s\n", p.agentDefinitionPath) fmt.Fprintf(os.Stderr, "Using endpoint: %s\n", azdEnv["AZURE_AI_PROJECT_ENDPOINT"]) From cc3a418fd7231df99dcad6c7cc4742bf1934141d Mon Sep 17 00:00:00 2001 From: trangevi Date: Mon, 27 Oct 2025 10:35:25 -0700 Subject: [PATCH 03/12] Missed one Signed-off-by: trangevi --- .../internal/pkg/agents/agent_yaml/map.go | 10 +++++++++- 1 file changed, 9 insertions(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/map.go index 26764f6fb05..2d44222b6b6 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/map.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/map.go @@ -8,6 +8,8 @@ import ( "strings" "azureaiagent/internal/pkg/agents/agent_api" + + "go.yaml.in/yaml/v3" ) /* @@ -104,8 +106,14 @@ func constructBuildConfig(options ...AgentBuildOption) *AgentBuildConfig { func CreateAgentAPIRequestFromManifest(agentManifest AgentManifest, options ...AgentBuildOption) (*agent_api.CreateAgentRequest, error) { buildConfig := constructBuildConfig(options...) + templateBytes, _ := yaml.Marshal(agentManifest.Template) + + var agentDef AgentDefinition + if err := yaml.Unmarshal(templateBytes, &agentDef); err != nil { + return nil, fmt.Errorf("failed to parse template to determine agent kind while creating api request") + } + // Route to appropriate handler based on agent kind - agentDef := agentManifest.Template.(AgentDefinition) switch agentDef.Kind { case AgentKindPrompt: promptDef := agentManifest.Template.(PromptAgent) From 1fd0e6b46e4b12ae5cce8fdb34ccaf1ab5c17308 Mon Sep 17 00:00:00 2001 From: trangevi Date: Mon, 27 Oct 2025 14:41:17 -0700 Subject: [PATCH 04/12] remove old file Signed-off-by: trangevi --- .../pkg/agents/agent_yaml/yaml.go.old | 389 ------------------ 1 file changed, 389 deletions(-) delete mode 100644 cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go.old diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go.old b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go.old deleted file mode 100644 index 6af25922e5b..00000000000 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go.old +++ /dev/null @@ -1,389 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package agent_yaml - -// AgentKind represents the type of agent -type AgentKind string - -const ( - AgentKindPrompt AgentKind = "prompt" - AgentKindHosted AgentKind = "hosted" - AgentKindContainerApp AgentKind = "container_app" - // Same as AgentKindContainerApp but this is the expected way to refer to container based agents in yaml files - AgentKindYamlContainerApp AgentKind = "container" - AgentKindWorkflow AgentKind = "workflow" -) - -// IsValidAgentKind checks if the provided AgentKind is valid -func IsValidAgentKind(kind AgentKind) bool { - switch kind { - case AgentKindPrompt, AgentKindHosted, AgentKindContainerApp, AgentKindWorkflow, AgentKindYamlContainerApp: - return true - default: - return false - } -} - -// ValidAgentKinds returns a slice of all valid AgentKind values -func ValidAgentKinds() []AgentKind { - return []AgentKind{ - AgentKindPrompt, - AgentKindHosted, - AgentKindContainerApp, - AgentKindWorkflow, - } -} - -// AgentDefinition represents The following is a specification for defining AI agents with structured metadata, inputs, outputs, tools, and templates. -// It provides a way to create reusable and composable AI agents that can be executed with specific configurations. -// The specification includes metadata about the agent, model configuration, input parameters, expected outputs, -// available tools, and template configurations for prompt rendering. -type AgentDefinition struct { - Kind AgentKind `json:"kind"` // Kind represented by the document - Name string `json:"name"` // Human-readable name of the agent - Description string `json:"description,omitempty"` // Description of the agent's capabilities and purpose - Instructions string `json:"instructions,omitempty"` // Give your agent clear directions on what to do and how to do it - Metadata map[string]interface{} `json:"metadata,omitempty"` // Additional metadata including authors, tags, and other arbitrary properties - Model Model `json:"model"` // Primary AI model configuration for the agent - InputSchema InputSchema `json:"inputSchema,omitempty"` // Input parameters that participate in template rendering - OutputSchema OutputSchema `json:"outputSchema,omitempty"` // Expected output format and structure from the agent - Tools []Tool `json:"tools,omitempty"` // Tools available to the agent for extended functionality -} - -// PromptAgent represents Prompt based agent definition. Used to create agents that can be executed directly. -// These agents can leverage tools, input parameters, and templates to generate responses. -// They are designed to be straightforward and easy to use for various applications. -type PromptAgent struct { - AgentDefinition - Kind AgentKind `json:"kind"` // Type of agent, e.g., 'prompt' - Template Template `json:"template,omitempty"` // Template configuration for prompt rendering - Instructions string `json:"instructions,omitempty"` // Give your agent clear directions on what to do and how to do it. Include specific tasks, their order, and any special instructions like tone or engagement style. (can use this for a pure yaml declaration or as content in the markdown format) - AdditionalInstructions string `json:"additionalInstructions,omitempty"` // Additional instructions or context for the agent, can be used to provide extra guidance (can use this for a pure yaml declaration) -} - -// ContainerAgent represents The following represents a containerized agent that can be deployed and hosted. -// It includes details about the container image, registry information, and environment variables. -// This model allows for the definition of agents that can run in isolated environments, -// making them suitable for deployment in various cloud or on-premises scenarios. -// -// The containerized agent can communicate using specified protocols and can be scaled -// based on the provided configuration. -// -// This kind of agent represents the users intent to bring their own container specific -// app hosting platform that they manage. -type ContainerAgent struct { - AgentDefinition - Kind AgentKind `json:"kind"` // Type of agent, e.g., 'container' - Protocol string `json:"protocol"` // Protocol used by the containerized agent - Options map[string]interface{} `json:"options,omitempty"` // Container definition including image, registry, and scaling information -} - -// AgentManifest represents The following represents a manifest that can be used to create agents dynamically. -// It includes a list of models that the publisher of the manifest has tested and -// has confidence will work with an instantiated prompt agent. -// The manifest also includes parameters that can be used to configure the agent's behavior. -// These parameters include values that can be used as publisher parameters that can -// be used to describe additional variables that have been tested and are known to work. -// -// Variables described here are then used to project into a prompt agent that can be executed. -// Once parameters are provided, these can be referenced in the manifest using the following notation: -// -// `${param:MyParameter}` -// -// This allows for dynamic configuration of the agent based on the provided parameters. -// (This notation is used elsewhere, but only the `param` scope is supported here) -type AgentManifest struct { - Agent AgentDefinition `json:"agent"` // The agent that this manifest is based on - Parameters map[string]Parameter `json:"parameters"` // Parameters for configuring the agent's behavior and execution -} - -// Binding represents Represents a binding between an input property and a tool parameter. -type Binding struct { - Name string `json:"name"` // Name of the binding - Input string `json:"input"` // The input property that will be bound to the tool parameter argument -} - -// BingSearchConfiguration represents Configuration options for the Bing search tool. -type BingSearchConfiguration struct { - Name string `json:"name"` // The name of the Bing search tool instance, used to identify the specific instance in the system - Market string `json:"market,omitempty"` // The market where the results come from. - SetLang string `json:"setLang,omitempty"` // The language to use for user interface strings when calling Bing API. - Count int64 `json:"count,omitempty"` // The number of search results to return in the bing api response - Freshness string `json:"freshness,omitempty"` // Filter search results by a specific time range. Accepted values: https://learn.microsoft.com/bing/search-apis/bing-web-search/reference/query-parameters -} - -// Connection represents Connection configuration for AI agents. -// `provider`, `kind`, and `endpoint` are required properties here, -// but this section can accept additional via options. -type Connection struct { - Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) - Authority string `json:"authority"` // The authority level for the connection, indicating under whose authority the connection is made (e.g., 'user', 'agent', 'system') - UsageDescription string `json:"usageDescription,omitempty"` // The usage description for the connection, providing context on how this connection will be used -} - -// GenericConnection represents Generic connection configuration for AI services. -type GenericConnection struct { - Connection - Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) - Options map[string]interface{} `json:"options,omitempty"` // Additional options for the connection -} - -// ReferenceConnection represents Connection configuration for AI services using named connections. -type ReferenceConnection struct { - Connection - Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) - Name string `json:"name"` // The name of the connection -} - -// KeyConnection represents Connection configuration for AI services using API keys. -type KeyConnection struct { - Connection - Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) - Endpoint string `json:"endpoint"` // The endpoint URL for the AI service - Key string `json:"key"` // The API key for authenticating with the AI service -} - -// OAuthConnection represents Connection configuration for AI services using OAuth authentication. -type OAuthConnection struct { - Connection - Kind string `json:"kind"` // The Authentication kind for the AI service (e.g., 'key' for API key, 'oauth' for OAuth tokens) - Endpoint string `json:"endpoint"` // The endpoint URL for the AI service - ClientId string `json:"clientId"` // The OAuth client ID for authenticating with the AI service - ClientSecret string `json:"clientSecret"` // The OAuth client secret for authenticating with the AI service - TokenUrl string `json:"tokenUrl"` // The OAuth token URL for obtaining access tokens - Scopes []interface{} `json:"scopes"` // The scopes required for the OAuth token -} - -// Format represents Template format definition -type Format struct { - Kind string `json:"kind"` // Template rendering engine used for slot filling prompts (e.g., mustache, jinja2) - Strict bool `json:"strict,omitempty"` // Whether the template can emit structural text for parsing output - Options map[string]interface{} `json:"options,omitempty"` // Options for the template engine -} - -// HostedContainerDefinition represents Definition for a containerized AI agent hosted by the provider. -// This includes the container registry information and scaling configuration. -type HostedContainerDefinition struct { - Scale Scale `json:"scale"` // Instance scaling configuration - Context interface{} `json:"context"` // Container context for building the container image -} - -// Input represents Represents a single input property for a prompt. -// * This model defines the structure of input properties that can be used in prompts, -// including their type, description, whether they are required, and other attributes. -// * It allows for the definition of dynamic inputs that can be filled with data -// and processed to generate prompts for AI models. -type Input struct { - Name string `json:"name"` // Name of the input property - Kind string `json:"kind"` // The data type of the input property - Description string `json:"description,omitempty"` // A short description of the input property - Required bool `json:"required,omitempty"` // Whether the input property is required - Strict bool `json:"strict,omitempty"` // Whether the input property can emit structural text when parsing output - Default interface{} `json:"default,omitempty"` // The default value of the input - this represents the default value if none is provided - Sample interface{} `json:"sample,omitempty"` // A sample value of the input for examples and tooling -} - -// ArrayInput represents Represents an array output property. -// This extends the base Output model to represent an array of items. -type ArrayInput struct { - Input - Kind string `json:"kind"` - Items Input `json:"items"` // The type of items contained in the array -} - -// ObjectInput represents Represents an object output property. -// This extends the base Output model to represent a structured object. -type ObjectInput struct { - Input - Kind string `json:"kind"` - Properties []interface{} `json:"properties"` // The properties contained in the object -} - -// InputSchema represents Definition for the input schema of a prompt. -// This includes the properties and example records. -type InputSchema struct { - Examples []interface{} `json:"examples,omitempty"` // Example records for the input schema - Strict bool `json:"strict,omitempty"` // Whether the input schema is strict - if true, only the defined properties are allowed - Properties []Input `json:"properties"` // The input properties for the schema -} - -// Model represents Model for defining the structure and behavior of AI agents. -// This model includes properties for specifying the model's provider, connection details, and various options. -// It allows for flexible configuration of AI models to suit different use cases and requirements. -type Model struct { - Id string `json:"id"` // The unique identifier of the model - can be used as the single property shorthand - Publisher string `json:"publisher,omitempty"` // The publisher of the model (e.g., 'openai', 'azure', 'anthropic') - Connection Connection `json:"connection,omitempty"` // The connection configuration for the model - Options ModelOptions `json:"options,omitempty"` // Additional options for the model -} - -// ModelOptions represents Options for configuring the behavior of the AI model. -// `kind` is a required property here, but this section can accept additional via options. -type ModelOptions struct { - Kind string `json:"kind"` -} - -// Output represents Represents the output properties of an AI agent. -// Each output property can be a simple kind, an array, or an object. -type Output struct { - Name string `json:"name"` // Name of the output property - Kind string `json:"kind"` // The data kind of the output property - Description string `json:"description,omitempty"` // A short description of the output property - Required bool `json:"required,omitempty"` // Whether the output property is required -} - -// ArrayOutput represents Represents an array output property. -// This extends the base Output model to represent an array of items. -type ArrayOutput struct { - Output - Kind string `json:"kind"` - Items Output `json:"items"` // The type of items contained in the array -} - -// ObjectOutput represents Represents an object output property. -// This extends the base Output model to represent a structured object. -type ObjectOutput struct { - Output - Kind string `json:"kind"` - Properties []interface{} `json:"properties"` // The properties contained in the object -} - -// OutputSchema represents Definition for the output schema of an AI agent. -// This includes the properties and example records. -type OutputSchema struct { - Examples []interface{} `json:"examples,omitempty"` // Example records for the output schema - Properties []Output `json:"properties"` // The output properties for the schema -} - -// Parameter represents Represents a parameter for a tool. -type Parameter struct { - Name string `json:"name"` // Name of the parameter - Kind string `json:"kind"` // The data type of the parameter - Description string `json:"description,omitempty"` // A short description of the property - Required bool `json:"required,omitempty"` // Whether the tool parameter is required - Default interface{} `json:"default,omitempty"` // The default value of the parameter - this represents the default value if none is provided - Value interface{} `json:"value,omitempty"` // Parameter value used for initializing manifest examples and tooling - Enum []interface{} `json:"enum,omitempty"` // Allowed enumeration values for the parameter -} - -// ObjectParameter represents Represents an object parameter for a tool. -type ObjectParameter struct { - Parameter - Kind string `json:"kind"` - Properties []Parameter `json:"properties"` // The properties of the object parameter -} - -// ArrayParameter represents Represents an array parameter for a tool. -type ArrayParameter struct { - Parameter - Kind string `json:"kind"` - Items interface{} `json:"items"` // The kind of items contained in the array -} - -// Parser represents Template parser definition -type Parser struct { - Kind string `json:"kind"` // Parser used to process the rendered template into API-compatible format - Options map[string]interface{} `json:"options,omitempty"` // Options for the parser -} - -// Scale represents Configuration for scaling container instances. -type Scale struct { - MinReplicas int32 `json:"minReplicas,omitempty"` // Minimum number of container instances to run - MaxReplicas int32 `json:"maxReplicas,omitempty"` // Maximum number of container instances to run - Cpu float32 `json:"cpu"` // CPU allocation per instance (in cores) - Memory float32 `json:"memory"` // Memory allocation per instance (in GB) -} - -// Template represents Template model for defining prompt templates. -// -// This model specifies the rendering engine used for slot filling prompts, -// the parser used to process the rendered template into API-compatible format, -// and additional options for the template engine. -// -// It allows for the creation of reusable templates that can be filled with dynamic data -// and processed to generate prompts for AI models. -type Template struct { - Format Format `json:"format"` // Template rendering engine used for slot filling prompts (e.g., mustache, jinja2) - Parser Parser `json:"parser"` // Parser used to process the rendered template into API-compatible format -} - -// Tool represents Represents a tool that can be used in prompts. -type Tool struct { - Name string `json:"name"` // Name of the tool. If a function tool, this is the function name, otherwise it is the type - Kind string `json:"kind"` // The kind identifier for the tool - Description string `json:"description,omitempty"` // A short description of the tool for metadata purposes - Bindings []Binding `json:"bindings,omitempty"` // Tool argument bindings to input properties -} - -// FunctionTool represents Represents a local function tool. -type FunctionTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for function tools - Parameters []Parameter `json:"parameters"` // Parameters accepted by the function tool -} - -// ServerTool represents Represents a generic server tool that runs on a server -// This tool kind is designed for operations that require server-side execution -// It may include features such as authentication, data storage, and long-running processes -// This tool kind is ideal for tasks that involve complex computations or access to secure resources -// Server tools can be used to offload heavy processing from client applications -type ServerTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for server tools. This is a wildcard and can represent any server tool type not explicitly defined. - Connection interface{} `json:"connection"` // Connection configuration for the server tool - Options map[string]interface{} `json:"options"` // Configuration options for the server tool -} - -// BingSearchTool represents The Bing search tool. -type BingSearchTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for Bing search tools - Connection interface{} `json:"connection"` // The connection configuration for the Bing search tool - Configurations []BingSearchConfiguration `json:"configurations"` // The configuration options for the Bing search tool -} - -// FileSearchTool represents A tool for searching files. -// This tool allows an AI agent to search for files based on a query. -type FileSearchTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for file search tools - Connection interface{} `json:"connection"` // The connection configuration for the file search tool - MaxNumResults int32 `json:"maxNumResults,omitempty"` // The maximum number of search results to return. - Ranker string `json:"ranker"` // File search ranker. - ScoreThreshold float32 `json:"scoreThreshold"` // Ranker search threshold. - VectorStoreIds []interface{} `json:"vectorStoreIds"` // The IDs of the vector stores to search within. -} - -// McpTool represents The MCP Server tool. -type McpTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for MCP tools - Connection interface{} `json:"connection"` // The connection configuration for the MCP tool - Name string `json:"name"` // The name of the MCP tool - Url string `json:"url"` // The URL of the MCP server - Allowed []interface{} `json:"allowed"` // List of allowed operations or resources for the MCP tool -} - -// ModelTool represents The MCP Server tool. -type ModelTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for a model connection as a tool - Model interface{} `json:"model"` // The connection configuration for the model tool -} - -// OpenApiTool represents -type OpenApiTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for OpenAPI tools - Connection interface{} `json:"connection"` // The connection configuration for the OpenAPI tool - Specification string `json:"specification"` // The URL or relative path to the OpenAPI specification document (JSON or YAML format) -} - -// CodeInterpreterTool represents A tool for interpreting and executing code. -// This tool allows an AI agent to run code snippets and analyze data files. -type CodeInterpreterTool struct { - Tool - Kind string `json:"kind"` // The kind identifier for code interpreter tools - FileIds []interface{} `json:"fileIds"` // The IDs of the files to be used by the code interpreter tool. -} From a4f82b3c32ee4986fb56ad5d176eed9804813fff Mon Sep 17 00:00:00 2001 From: Travis Angevine Date: Mon, 27 Oct 2025 14:42:50 -0700 Subject: [PATCH 05/12] Update cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go Co-authored-by: JeffreyCA --- cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go index a94841c59bb..b9e96afd3f6 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go @@ -767,7 +767,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa case "container": host = "containerapp" default: - host = "foundry.agent" + host = "foundry.containeragent" } serviceConfig := &azdext.ServiceConfig{ From 0e6aea23635bfca0b87eaab91a33d4daba73e662 Mon Sep 17 00:00:00 2001 From: trangevi Date: Mon, 27 Oct 2025 16:35:03 -0700 Subject: [PATCH 06/12] PR Comments Signed-off-by: trangevi --- .../azure.foundry.ai.agents/internal/cmd/init.go | 13 ++++++++----- 1 file changed, 8 insertions(+), 5 deletions(-) diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go index b9e96afd3f6..43a79cecb74 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go @@ -720,10 +720,13 @@ func (a *InitAction) downloadAgentYaml( } if isGitHubUrl { - // Check if the template is a HostedContainerAgent - if _, isHostedContainer := agentManifest.Template.(agent_yaml.HostedContainerAgent); isHostedContainer { - // For hosted agents, download the entire parent directory - fmt.Println("Downloading full directory for hosted agent") + // Check if the template is a HostedContainerAgent or ContainerAgent + _, isHostedContainer := agentManifest.Template.(agent_yaml.HostedContainerAgent) + _, isContainerAgent := agentManifest.Template.(agent_yaml.ContainerAgent) + + if isHostedContainer || isContainerAgent { + // For container agents, download the entire parent directory + fmt.Println("Downloading full directory for container agent") err := downloadParentDirectory(ctx, urlInfo, targetDir, ghCli, console) if err != nil { return nil, "", fmt.Errorf("downloading parent directory: %w", err) @@ -771,7 +774,7 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa } serviceConfig := &azdext.ServiceConfig{ - Name: agentDef.Name, + Name: strings.ReplaceAll(agentDef.Name, " ", ""), RelativePath: targetDir, Host: host, Language: "python", From 6eed47e643ecbc5c28ce42caff4a6caa06b1efd0 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 28 Oct 2025 10:55:43 -0700 Subject: [PATCH 07/12] Add model details env var Signed-off-by: trangevi --- .../extensions/azure.foundry.ai.agents/go.mod | 4 +- .../extensions/azure.foundry.ai.agents/go.sum | 4 + .../internal/cmd/init.go | 201 ++++++++++++++---- 3 files changed, 165 insertions(+), 44 deletions(-) diff --git a/cli/azd/extensions/azure.foundry.ai.agents/go.mod b/cli/azd/extensions/azure.foundry.ai.agents/go.mod index 32229523ed5..b8827a85ad0 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/go.mod +++ b/cli/azd/extensions/azure.foundry.ai.agents/go.mod @@ -11,6 +11,7 @@ require ( github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armresources v1.2.0 github.com/Azure/azure-sdk-for-go/sdk/resourcemanager/resources/armsubscriptions v1.3.0 github.com/azure/azure-dev v0.0.0-20251024053325-326f63f72d65 + github.com/braydonk/yaml v0.9.0 github.com/fatih/color v1.18.0 github.com/google/uuid v1.6.0 github.com/mark3labs/mcp-go v0.41.1 @@ -20,6 +21,7 @@ require ( ) require ( + dario.cat/mergo v1.0.2 // indirect github.com/AlecAivazis/survey/v2 v2.3.7 // indirect github.com/Azure/azure-sdk-for-go/sdk/internal v1.11.2 // indirect github.com/AzureAD/microsoft-authentication-library-for-go v1.5.0 // indirect @@ -29,7 +31,6 @@ require ( github.com/aymerick/douceur v0.2.0 // indirect github.com/bahlo/generic-list-go v0.2.0 // indirect github.com/blang/semver/v4 v4.0.0 // indirect - github.com/braydonk/yaml v0.9.0 // indirect github.com/buger/goterm v1.0.4 // indirect github.com/buger/jsonparser v1.1.1 // indirect github.com/charmbracelet/colorprofile v0.3.2 // indirect @@ -43,6 +44,7 @@ require ( github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc // indirect github.com/dlclark/regexp2 v1.11.5 // indirect github.com/drone/envsubst v1.0.3 // indirect + github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 // indirect github.com/go-logr/logr v1.4.3 // indirect github.com/go-logr/stdr v1.2.2 // indirect github.com/golang-jwt/jwt/v5 v5.3.0 // indirect diff --git a/cli/azd/extensions/azure.foundry.ai.agents/go.sum b/cli/azd/extensions/azure.foundry.ai.agents/go.sum index 3b919fcae0d..8774544ce0e 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/go.sum +++ b/cli/azd/extensions/azure.foundry.ai.agents/go.sum @@ -1,4 +1,6 @@ code.cloudfoundry.org/clock v0.0.0-20180518195852-02e53af36e6c/go.mod h1:QD9Lzhd/ux6eNQVUDVRJX/RKTigpewimNYBi7ivZKY8= +dario.cat/mergo v1.0.2 h1:85+piFYR1tMbRrLcDwR18y4UKJ3aH1Tbzi24VRW1TK8= +dario.cat/mergo v1.0.2/go.mod h1:E/hbnu0NxMFBjpMIE34DRGLWqDy0g5FuKDhCb31ngxA= github.com/AlecAivazis/survey/v2 v2.3.7 h1:6I/u8FvytdGsgonrYsVn2t8t4QiRnh6QSTqkkhIiSjQ= github.com/AlecAivazis/survey/v2 v2.3.7/go.mod h1:xUTIdE4KCOIjsBAE1JYsUPoCqYdZ1reCfTwbto0Fduo= github.com/Azure/azure-sdk-for-go/sdk/azcore v1.19.1 h1:5YTBM8QDVIBN3sxBil89WfdAAqDZbyJTgh688DSxX5w= @@ -90,6 +92,8 @@ github.com/dlclark/regexp2 v1.11.5 h1:Q/sSnsKerHeCkc/jSTNq1oCm7KiVgUMZRDUoRu0JQZ github.com/dlclark/regexp2 v1.11.5/go.mod h1:DHkYz0B9wPfa6wondMfaivmHpzrQ3v9q8cnmRbL6yW8= github.com/drone/envsubst v1.0.3 h1:PCIBwNDYjs50AsLZPYdfhSATKaRg/FJmDc2D6+C2x8g= github.com/drone/envsubst v1.0.3/go.mod h1:N2jZmlMufstn1KEqvbHjw40h1KyTmnVzHcSc9bFiJ2g= +github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203 h1:XBBHcIb256gUJtLmY22n99HaZTz+r2Z51xUPi01m3wg= +github.com/eiannone/keyboard v0.0.0-20220611211555-0d226195f203/go.mod h1:E1jcSv8FaEny+OP/5k9UxZVw9YFWGj7eI4KR/iOBqCg= github.com/fatih/color v1.18.0 h1:S8gINlzdQ840/4pfAwic/ZE0djQEH3wM94VfqLTZcOM= github.com/fatih/color v1.18.0/go.mod h1:4FelSpRwEGDpQ12mAdzqdOukCy4u8WUtOY6lkT/6HfU= github.com/frankban/quicktest v1.14.6 h1:7Xjx+VpznH+oBnejlPUj8oUpdxnVs4f8XU8WnHkI4W8= diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go index 43a79cecb74..d71807ce323 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go @@ -15,13 +15,16 @@ import ( "azureaiagent/internal/pkg/agents/agent_yaml" "azureaiagent/internal/pkg/agents/registry_api" + "azureaiagent/internal/pkg/azure/ai" + "github.com/Azure/azure-sdk-for-go/sdk/azcore" "github.com/Azure/azure-sdk-for-go/sdk/azidentity" "github.com/azure/azure-dev/cli/azd/pkg/azdext" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/input" "github.com/azure/azure-dev/cli/azd/pkg/osutil" "github.com/azure/azure-dev/cli/azd/pkg/tools/github" + "github.com/azure/azure-dev/cli/azd/pkg/ux" "github.com/fatih/color" "github.com/spf13/cobra" "gopkg.in/yaml.v3" @@ -41,14 +44,14 @@ type AiProjectResourceConfig struct { type InitAction struct { azdClient *azdext.AzdClient //azureClient *azure.AzureClient - // azureContext *azdext.AzureContext + azureContext *azdext.AzureContext //composedResources []*azdext.ComposedResource //console input.Console - //credential azcore.TokenCredential - //modelCatalog map[string]*ai.AiModel - //modelCatalogService *ai.ModelCatalogService - projectConfig *azdext.ProjectConfig - environment *azdext.Environment + credential azcore.TokenCredential + modelCatalog map[string]*ai.AiModel + modelCatalogService *ai.ModelCatalogService + projectConfig *azdext.ProjectConfig + environment *azdext.Environment } // GitHubUrlInfo holds parsed information from a GitHub URL @@ -75,8 +78,7 @@ func newInitCommand() *cobra.Command { } defer azdClient.Close() - //azureContext, projectConfig, err := ensureAzureContext(ctx, azdClient) - projectConfig, err := ensureProject(ctx, azdClient) + azureContext, projectConfig, err := ensureAzureContext(ctx, azdClient) if err != nil { return fmt.Errorf("failed to ground into a project context: %w", err) } @@ -91,13 +93,13 @@ func newInitCommand() *cobra.Command { // return fmt.Errorf("failed to get composed resources: %w", err) // } - // credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ - // TenantID: azureContext.Scope.TenantId, - // AdditionallyAllowedTenants: []string{"*"}, - // }) - // if err != nil { - // return fmt.Errorf("failed to create azure credential: %w", err) - // } + credential, err := azidentity.NewAzureDeveloperCLICredential(&azidentity.AzureDeveloperCLICredentialOptions{ + TenantID: azureContext.Scope.TenantId, + AdditionallyAllowedTenants: []string{"*"}, + }) + if err != nil { + return fmt.Errorf("failed to create azure credential: %w", err) + } // console := input.NewConsole( // false, // noPrompt @@ -115,13 +117,13 @@ func newInitCommand() *cobra.Command { action := &InitAction{ azdClient: azdClient, // azureClient: azure.NewAzureClient(credential), - // azureContext: azureContext, + azureContext: azureContext, // composedResources: getComposedResourcesResponse.Resources, // console: console, - // credential: credential, - // modelCatalogService: ai.NewModelCatalogService(credential), - projectConfig: projectConfig, - environment: environment, + credential: credential, + modelCatalogService: ai.NewModelCatalogService(credential), + projectConfig: projectConfig, + environment: environment, } if err := action.Run(ctx, flags); err != nil { @@ -1176,32 +1178,32 @@ func downloadDirectoryContents( // return modelDeployment, nil // } -// func (a *InitAction) loadAiCatalog(ctx context.Context) error { -// if a.modelCatalog != nil { -// return nil -// } +func (a *InitAction) loadAiCatalog(ctx context.Context) error { + if a.modelCatalog != nil { + return nil + } -// spinner := ux.NewSpinner(&ux.SpinnerOptions{ -// Text: "Loading AI Model Catalog", -// ClearOnStop: true, -// }) + spinner := ux.NewSpinner(&ux.SpinnerOptions{ + Text: "Loading AI Model Catalog", + ClearOnStop: true, + }) -// if err := spinner.Start(ctx); err != nil { -// return fmt.Errorf("failed to start spinner: %w", err) -// } + if err := spinner.Start(ctx); err != nil { + return fmt.Errorf("failed to start spinner: %w", err) + } -// aiModelCatalog, err := a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId) -// if err != nil { -// return fmt.Errorf("failed to load AI model catalog: %w", err) -// } + aiModelCatalog, err := a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId) + if err != nil { + return fmt.Errorf("failed to load AI model catalog: %w", err) + } -// if err := spinner.Stop(ctx); err != nil { -// return err -// } + if err := spinner.Stop(ctx); err != nil { + return err + } -// a.modelCatalog = aiModelCatalog -// return nil -// } + a.modelCatalog = aiModelCatalog + return nil +} // // generateResourceName generates a unique resource name, similar to the AI builder pattern // func generateResourceName(desiredName string, existingResources []*azdext.ComposedResource) string { @@ -1290,6 +1292,7 @@ func (a *InitAction) updateEnvironment(ctx context.Context, agentManifest *agent } envName := envResponse.Environment.Name + deploymentDetails := []Deployment{} // Set environment variables based on agent kind switch agentDef.Kind { @@ -1298,16 +1301,49 @@ func (a *InitAction) updateEnvironment(ctx context.Context, agentManifest *agent if err := a.setEnvVar(ctx, envName, "AZURE_AI_FOUNDRY_MODEL_NAME", agentDef.Model.Id); err != nil { return err } + + modelDeployment, err := a.getModelDeploymentDetails(ctx, agentDef.Model) + if err != nil { + return fmt.Errorf("failed to get model deployment details: %w", err) + } + deploymentDetails = append(deploymentDetails, *modelDeployment) case agent_yaml.AgentKindHosted: - // Set environment variables for hosted agents + agentDef := agentManifest.Template.(agent_yaml.HostedContainerAgent) if err := a.setEnvVar(ctx, envName, "ENABLE_HOSTED_AGENTS", "true"); err != nil { return err } + + // Iterate over all models in the hosted container agent + for _, model := range agentDef.Models { + modelDeployment, err := a.getModelDeploymentDetails(ctx, model) + if err != nil { + return fmt.Errorf("failed to get model deployment details: %w", err) + } + deploymentDetails = append(deploymentDetails, *modelDeployment) + } + case agent_yaml.AgentKindYamlContainerApp: - // Set environment variables for foundry agents + agentDef := agentManifest.Template.(agent_yaml.ContainerAgent) if err := a.setEnvVar(ctx, envName, "ENABLE_CONTAINER_AGENTS", "true"); err != nil { return err } + + // Iterate over all models in the container agent + for _, model := range agentDef.Models { + modelDeployment, err := a.getModelDeploymentDetails(ctx, model) + if err != nil { + return fmt.Errorf("failed to get model deployment details: %w", err) + } + deploymentDetails = append(deploymentDetails, *modelDeployment) + } + } + + deploymentsJson, err := json.Marshal(deploymentDetails) + if err != nil { + return fmt.Errorf("failed to marshal deployment details to JSON: %w", err) + } + if err := a.setEnvVar(ctx, envName, "AI_PROJECT_DEPLOYMENTS", string(deploymentsJson)); err != nil { + return err } fmt.Printf("Successfully updated environment variables for agent kind: %s\n", agentDef.Kind) @@ -1327,3 +1363,82 @@ func (a *InitAction) setEnvVar(ctx context.Context, envName, key, value string) fmt.Printf("Set environment variable: %s=%s\n", key, value) return nil } + +// Deployment represents a single cognitive service account deployment +type Deployment struct { + // Specify the name of cognitive service account deployment. + Name string `json:"name"` + + // Required. Properties of Cognitive Services account deployment model. + Model DeploymentModel `json:"model"` + + // The resource model definition representing SKU. + Sku DeploymentSku `json:"sku"` +} + +// DeploymentModel represents the model configuration for a cognitive services deployment +type DeploymentModel struct { + // Required. The name of Cognitive Services account deployment model. + Name string `json:"name"` + + // Required. The format of Cognitive Services account deployment model. + Format string `json:"format"` + + // Required. The version of Cognitive Services account deployment model. + Version string `json:"version"` +} + +// DeploymentSku represents the resource model definition representing SKU +type DeploymentSku struct { + // Required. The name of the resource model definition representing SKU. + Name string `json:"name"` + + // The capacity of the resource model definition representing SKU. + Capacity int `json:"capacity"` +} + +func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_yaml.Model) (*Deployment, error) { + modelDetails, err := a.getModelDetails(ctx, model.Id, *model.Version) + if err != nil { + return nil, fmt.Errorf("failed to get model details: %w", err) + } + + return &Deployment{ + Name: *model.Deployment, + Model: DeploymentModel{ + Name: model.Id, + Format: modelDetails.Format, + Version: *model.Version, + }, + Sku: DeploymentSku{ + Name: "GlobalStandard", + Capacity: 100, + }, + }, nil +} + +func (a *InitAction) getModelDetails(ctx context.Context, modelName string, modelVersion string) (*ai.AiModelDeployment, error) { + // Load the AI model catalog if not already loaded + if err := a.loadAiCatalog(ctx); err != nil { + return nil, err + } + + // Check if the model exists in the catalog + var model *ai.AiModel + model, exists := a.modelCatalog[modelName] + if !exists { + return nil, fmt.Errorf("model '%s' not found in AI model catalog", modelName) + } + + deploymentOptions := ai.AiModelDeploymentOptions{ + Versions: []string{modelVersion}, + Skus: []string{"GlobalStandard"}, + } + + modelDeployment, err := a.modelCatalogService.GetModelDeployment(ctx, model, &deploymentOptions) + if err != nil { + return nil, fmt.Errorf("failed to get model deployment: %w", err) + } + + return modelDeployment, nil +} From 3c40f2c52bb35ba45d924731b9f497c9fe2903a6 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 28 Oct 2025 11:13:46 -0700 Subject: [PATCH 08/12] Fix registry get Signed-off-by: trangevi --- .../pkg/agents/registry_api/helpers.go | 36 ++++++++++++------- 1 file changed, 23 insertions(+), 13 deletions(-) diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go index 3070d591f76..558811d36f6 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go +++ b/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go @@ -21,14 +21,18 @@ import ( type ParameterValues map[string]interface{} func ProcessRegistryManifest(ctx context.Context, manifest *Manifest, azdClient *azdext.AzdClient) (*agent_yaml.AgentManifest, error) { + // Debug: Print manifest.Template + fmt.Printf("DEBUG: manifest.Template: %+v\n", manifest.Template) + // Convert the agent API definition into a MAML definition - agentDef, err := ConvertAgentDefinition(manifest.Template) + promptAgent, err := ConvertAgentDefinition(manifest.Template) if err != nil { return nil, fmt.Errorf("failed to convert agentDefinition: %w", err) } // Inject Agent API Manifest properties into MAML Agent properties as needed - agentDef = MergeManifestIntoAgentDefinition(manifest, agentDef) + updatedAgentDef := MergeManifestIntoAgentDefinition(manifest, &promptAgent.AgentDefinition) + promptAgent.AgentDefinition = *updatedAgentDef // Convert the agent API parameters into MAML parameters parameters, err := ConvertParameters(manifest.Parameters) @@ -36,19 +40,19 @@ func ProcessRegistryManifest(ctx context.Context, manifest *Manifest, azdClient return nil, fmt.Errorf("failed to convert parameters: %w", err) } - // Create the AgentManifest with the converted AgentDefinition + // Create the AgentManifest with the converted PromptAgent result := &agent_yaml.AgentManifest{ Name: manifest.Name, DisplayName: manifest.DisplayName, Description: &manifest.Description, - Template: *agentDef, + Template: *promptAgent, Parameters: parameters, } return result, nil } -func ConvertAgentDefinition(template agent_api.PromptAgentDefinition) (*agent_yaml.AgentDefinition, error) { +func ConvertAgentDefinition(template agent_api.PromptAgentDefinition) (*agent_yaml.PromptAgent, error) { // Convert tools from agent_api.Tool to agent_yaml.Tool var tools []agent_yaml.Tool for _, apiTool := range template.Tools { @@ -59,16 +63,22 @@ func ConvertAgentDefinition(template agent_api.PromptAgentDefinition) (*agent_ya tools = append(tools, yamlTool) } - // Create the AgentDefinition - agentDef := &agent_yaml.AgentDefinition{ - Kind: agent_yaml.AgentKindPrompt, // Set to prompt kind - Name: "", // Will be set later from manifest or user input - Description: nil, // Will be set later from manifest or user input - Tools: &tools, - // Metadata: make(map[string]interface{}), // TODO, Where does this come from? + // Create the PromptAgent + promptAgent := &agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, // Set to prompt kind + Name: "", // Will be set later from manifest or user input + Description: nil, // Will be set later from manifest or user input + Tools: &tools, + // Metadata: make(map[string]interface{}), // TODO, Where does this come from? + }, + Model: agent_yaml.Model{ + Id: template.Model, + }, + Instructions: template.Instructions, } - return agentDef, nil + return promptAgent, nil } func ConvertParameters(parameters map[string]OpenApiParameter) (*map[string]agent_yaml.Parameter, error) { From 3a2e3fe538c4575536a40105ad44fa2fe5da9e19 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 28 Oct 2025 15:55:37 -0700 Subject: [PATCH 09/12] Merge branch 'main' into trangevi/model-deployment-details --- .github/CODEOWNERS | 2 +- cli/azd/.vscode/cspell.yaml | 4 +- cli/azd/CHANGELOG.md | 9 +- cli/azd/cmd/env.go | 5 +- cli/azd/cmd/middleware/hooks_test.go | 5 +- cli/azd/docs/extension-framework.md | 945 +++++++++++++++++- .../extensions/azure.ai.agents/CHANGELOG.md | 5 + .../README.md | 4 +- .../build.ps1 | 0 .../build.sh | 0 .../ci-build.ps1 | 0 .../extension.yaml | 50 +- .../go.mod | 0 .../go.sum | 0 .../internal/cmd/deploy.go | 0 .../internal/cmd/init.go | 155 ++- .../internal/cmd/listen.go | 2 +- .../internal/cmd/mcp.go | 0 .../internal/cmd/root.go | 0 .../internal/cmd/version.go | 0 .../internal/pkg/agents/agent_api/models.go | 0 .../pkg/agents/agent_api/operations.go | 0 .../internal/pkg/agents/agent_yaml/map.go | 0 .../internal/pkg/agents/agent_yaml/parse.go | 0 .../pkg/agents/agent_yaml/parse_test.go.old | 0 .../agent_yaml/sample_integration_test.go.old | 0 .../internal/pkg/agents/agent_yaml/yaml.go | 0 .../pkg/agents/agent_yaml/yaml_test.go | 0 .../pkg/agents/registry_api/helpers.go | 3 - .../pkg/agents/registry_api/helpers.go.orig | 541 ++++++++++ .../pkg/agents/registry_api/models.go | 0 .../pkg/agents/registry_api/operations.go | 0 .../internal/pkg/azure/ai/model_catalog.go | 43 +- .../internal/pkg/azure/azure_client.go | 0 .../internal/project/parser.go | 0 .../internal/project/service_target_agent.go | 0 .../internal/tools/add_agent.go | 0 .../main.go | 0 .../extensions/azure.ai.agents/tests/go.sum | 4 + .../samples/declarativeNoTools/agent.yaml | 35 + .../tests/samples/githubMcpAgent/agent.yaml | 46 + .../extensions/azure.ai.agents/version.txt | 1 + .../azure.foundry.ai.agents/CHANGELOG.md | 9 - .../azure.foundry.ai.agents/version.txt | 1 - .../microsoft.azd.demo/internal/cmd/listen.go | 14 +- cli/azd/extensions/registry.json | 166 --- cli/azd/grpc/proto/event.proto | 2 + cli/azd/internal/appdetect/java_test.go | 54 +- cli/azd/internal/cmd/provision.go | 5 +- cli/azd/internal/grpcserver/event_service.go | 29 +- .../internal/grpcserver/event_service_test.go | 464 +++++++++ cli/azd/pkg/azdext/account.pb.go | 2 +- cli/azd/pkg/azdext/compose.pb.go | 2 +- cli/azd/pkg/azdext/container.pb.go | 2 +- cli/azd/pkg/azdext/deployment.pb.go | 2 +- cli/azd/pkg/azdext/environment.pb.go | 2 +- cli/azd/pkg/azdext/event.pb.go | 38 +- cli/azd/pkg/azdext/event_manager.go | 22 +- cli/azd/pkg/azdext/event_manager_test.go | 466 +++++++++ cli/azd/pkg/azdext/extension.pb.go | 2 +- cli/azd/pkg/azdext/extension_host.go | 6 +- cli/azd/pkg/azdext/extension_host_test.go | 4 +- cli/azd/pkg/azdext/framework_service.pb.go | 2 +- cli/azd/pkg/azdext/models.pb.go | 2 +- cli/azd/pkg/azdext/project.pb.go | 2 +- cli/azd/pkg/azdext/prompt.pb.go | 2 +- cli/azd/pkg/azdext/service_target.pb.go | 2 +- cli/azd/pkg/azdext/user_config.pb.go | 2 +- cli/azd/pkg/azdext/workflow.pb.go | 2 +- cli/azd/pkg/ext/event_dispatcher.go | 11 + cli/azd/pkg/ext/event_dispatcher_test.go | 134 +++ cli/azd/pkg/extensions/manager_test.go | 8 +- cli/azd/pkg/input/progress_log.go | 48 +- cli/azd/pkg/input/progress_log_test.go | 74 ++ .../project/framework_service_dotnet_test.go | 6 +- cli/azd/pkg/project/service_config_test.go | 117 ++- cli/azd/pkg/project/service_manager.go | 70 +- cli/azd/pkg/project/service_models.go | 7 +- .../pkg/project/service_target_aks_test.go | 5 +- cli/azd/test/functional/vs_server_test.go | 62 +- cli/azd/test/recording/recording.go | 6 +- cli/azd/test/recording/resilient_transport.go | 89 ++ .../recording/resilient_transport_test.go | 184 ++++ cli/version.txt | 2 +- ...ts.yml => release-ext-azure-ai-agents.yml} | 10 +- 85 files changed, 3563 insertions(+), 435 deletions(-) create mode 100644 cli/azd/extensions/azure.ai.agents/CHANGELOG.md rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/README.md (94%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/build.ps1 (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/build.sh (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/ci-build.ps1 (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/extension.yaml (88%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/go.mod (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/go.sum (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/cmd/deploy.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/cmd/init.go (93%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/cmd/listen.go (94%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/cmd/mcp.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/cmd/root.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/cmd/version.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/agent_api/models.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/agent_api/operations.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/agent_yaml/map.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/agent_yaml/parse.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/agent_yaml/parse_test.go.old (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/agent_yaml/sample_integration_test.go.old (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/agent_yaml/yaml.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/agent_yaml/yaml_test.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/registry_api/helpers.go (99%) create mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go.orig rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/registry_api/models.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/agents/registry_api/operations.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/azure/ai/model_catalog.go (89%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/pkg/azure/azure_client.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/project/parser.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/project/service_target_agent.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/internal/tools/add_agent.go (100%) rename cli/azd/extensions/{azure.foundry.ai.agents => azure.ai.agents}/main.go (100%) create mode 100644 cli/azd/extensions/azure.ai.agents/tests/go.sum create mode 100644 cli/azd/extensions/azure.ai.agents/tests/samples/declarativeNoTools/agent.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/tests/samples/githubMcpAgent/agent.yaml create mode 100644 cli/azd/extensions/azure.ai.agents/version.txt delete mode 100644 cli/azd/extensions/azure.foundry.ai.agents/CHANGELOG.md delete mode 100644 cli/azd/extensions/azure.foundry.ai.agents/version.txt create mode 100644 cli/azd/internal/grpcserver/event_service_test.go create mode 100644 cli/azd/pkg/azdext/event_manager_test.go create mode 100644 cli/azd/test/recording/resilient_transport.go create mode 100644 cli/azd/test/recording/resilient_transport_test.go rename eng/pipelines/{release-ext-azure-foundry-ai-agents.yml => release-ext-azure-ai-agents.yml} (71%) diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS index 74bbd80e648..94312e4314c 100644 --- a/.github/CODEOWNERS +++ b/.github/CODEOWNERS @@ -23,4 +23,4 @@ /.github/CODEOWNERS @rajeshkamal5050 @Azure/azure-sdk-eng -/cli/azd/extensions/azure.foundry.ai.agents/ @wbreza @vhvb1989 @hemarina @weikanglim @JeffreyCA @tg-msft @rajeshkamal5050 @trangevi @travisw +/cli/azd/extensions/azure.ai.agents/ @wbreza @vhvb1989 @hemarina @weikanglim @JeffreyCA @tg-msft @rajeshkamal5050 @trangevi @travisw diff --git a/cli/azd/.vscode/cspell.yaml b/cli/azd/.vscode/cspell.yaml index 027bd1e8be8..758034afbde 100644 --- a/cli/azd/.vscode/cspell.yaml +++ b/cli/azd/.vscode/cspell.yaml @@ -167,12 +167,14 @@ overrides: - getenv - errorf - println + - myext + - Fatalf - filename: extensions/microsoft.azd.ai.builder/internal/cmd/start.go words: - dall - datasource - vectorizing - - filename: extensions/azure.foundry.ai.agents/internal/cmd/listen.go + - filename: extensions/azure.ai.agents/internal/cmd/listen.go words: - hostedagent - filename: docs/new-azd-command.md diff --git a/cli/azd/CHANGELOG.md b/cli/azd/CHANGELOG.md index 46630af99b9..6dc5a47b3cb 100644 --- a/cli/azd/CHANGELOG.md +++ b/cli/azd/CHANGELOG.md @@ -1,14 +1,17 @@ # Release History -## 1.21.0-beta.1 (Unreleased) +## 1.20.3 (2025-10-28) ### Features Added -### Breaking Changes +- [[#5995]](https://github.com/Azure/azure-dev/pull/5995) Add AccountService gRPC API and server implementation. +- [[#6002]](https://github.com/Azure/azure-dev/pull/6002) Exposes ServiceContext in Service lifecycle events. ### Bugs Fixed -### Other Changes +- [[#5985]](https://github.com/Azure/azure-dev/pull/5985) Fix potential concurrent map write panics in FrameworkService. +- [[#6001]](https://github.com/Azure/azure-dev/pull/6001) fix: prevent index out of range panic in progressLog.Write(). +- [[#6012]](https://github.com/Azure/azure-dev/pull/6012) Fixes issue with duplicate event registration in workflow commands. ## 1.20.2 (2025-10-22) diff --git a/cli/azd/cmd/env.go b/cli/azd/cmd/env.go index 4e88d82985c..acdd6ac5209 100644 --- a/cli/azd/cmd/env.go +++ b/cli/azd/cmd/env.go @@ -1127,8 +1127,9 @@ func (ef *envRefreshAction) Run(ctx context.Context) (*actions.ActionResult, err for _, svc := range servicesStable { eventArgs := project.ServiceLifecycleEventArgs{ - Project: ef.projectConfig, - Service: svc, + Project: ef.projectConfig, + Service: svc, + ServiceContext: project.NewServiceContext(), Args: map[string]any{ "bicepOutput": state.Outputs, }, diff --git a/cli/azd/cmd/middleware/hooks_test.go b/cli/azd/cmd/middleware/hooks_test.go index 87ac47d070c..b1c350f4b35 100644 --- a/cli/azd/cmd/middleware/hooks_test.go +++ b/cli/azd/cmd/middleware/hooks_test.go @@ -272,8 +272,9 @@ func Test_ServiceHooks_Registered(t *testing.T) { nextFn := func(ctx context.Context) (*actions.ActionResult, error) { err := serviceConfig.Invoke(ctx, project.ServiceEventDeploy, project.ServiceLifecycleEventArgs{ - Project: &projectConfig, - Service: serviceConfig, + Project: &projectConfig, + Service: serviceConfig, + ServiceContext: project.NewServiceContext(), }, func() error { return nil }) diff --git a/cli/azd/docs/extension-framework.md b/cli/azd/docs/extension-framework.md index cf51ca4de5c..294b6bef677 100644 --- a/cli/azd/docs/extension-framework.md +++ b/cli/azd/docs/extension-framework.md @@ -12,8 +12,12 @@ Table of Contents - [Environment Service](#environment-service) - [User Config Service](#user-config-service) - [Deployment Service](#deployment-service) + - [Account Service](#account-service) - [Prompt Service](#prompt-service) - [Event Service](#event-service) + - [Container Service](#container-service) + - [Framework Service](#framework-service) + - [Service Target Service](#service-target-service) - [Compose Service](#compose-service) - [Workflow Service](#workflow-service) @@ -190,6 +194,117 @@ type ServiceTargetProvider interface { } ``` +#### Complete Extension Host Builder Pattern + +The `ExtensionHost` provides a fluent builder API that allows you to register all extension capabilities in a single, unified pattern. This is the **recommended approach** for extension development as it handles all service registration, readiness signaling, and lifecycle management automatically. + +**Complete Example - Registering All Extension Capabilities:** + +```go +func newListenCommand() *cobra.Command { + return &cobra.Command{ + Use: "listen", + Short: "Starts the extension and listens for events.", + RunE: func(cmd *cobra.Command, args []string) error { + // Create a new context that includes the AZD access token. + ctx := azdext.WithAccessToken(cmd.Context()) + + // Create a new AZD client. + azdClient, err := azdext.NewAzdClient() + if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) + } + defer azdClient.Close() + + // Register all extension capabilities using the builder pattern + host := azdext.NewExtensionHost(azdClient). + // Register a custom service target for specialized deployments + WithServiceTarget("demo", func() azdext.ServiceTargetProvider { + return project.NewDemoServiceTargetProvider(azdClient) + }). + // Register a custom framework service for language support + WithFrameworkService("rust", func() azdext.FrameworkServiceProvider { + return project.NewDemoFrameworkServiceProvider(azdClient) + }). + // Register project-level event handlers + WithProjectEventHandler("preprovision", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + fmt.Printf("Preparing provisioning for project: %s\n", args.Project.Name) + // Perform pre-provisioning logic + return nil + }). + WithProjectEventHandler("predeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + fmt.Printf("Preparing deployment for project: %s\n", args.Project.Name) + // Perform pre-deployment validation + return nil + }). + WithProjectEventHandler("postdeploy", func(ctx context.Context, args *azdext.ProjectEventArgs) error { + fmt.Printf("Deployment completed for project: %s\n", args.Project.Name) + // Perform post-deployment tasks (e.g., health checks, notifications) + return nil + }). + // Register service-level event handlers with optional filtering + WithServiceEventHandler("prepackage", func(ctx context.Context, args *azdext.ServiceEventArgs) error { + fmt.Printf("Packaging service: %s\n", args.Service.Name) + + // Access artifacts from previous phases + if len(args.ServiceContext.Build) > 0 { + fmt.Printf("Found %d build artifacts\n", len(args.ServiceContext.Build)) + } + + return nil + }, nil). // No filtering - applies to all services + WithServiceEventHandler("postpackage", func(ctx context.Context, args *azdext.ServiceEventArgs) error { + fmt.Printf("Package completed for service: %s\n", args.Service.Name) + + // Check package artifacts + for _, artifact := range args.ServiceContext.Package { + fmt.Printf("Package artifact: %s\n", artifact.Path) + } + + return nil + }, &azdext.ServiceEventOptions{ + // Optional: Filter to only handle specific service types + Host: "containerapp", + Language: "python", + }) + + // Start the extension host - this blocks until shutdown + if err := host.Run(ctx); err != nil { + return fmt.Errorf("failed to run extension: %w", err) + } + + return nil + }, + } +} +``` + +**Key Benefits of the Builder Pattern:** + +1. **Unified Registration**: Register all capabilities in one place +2. **Automatic Lifecycle Management**: ExtensionHost handles readiness signaling and shutdown +3. **Fluent API**: Chain multiple registrations for clean, readable code +4. **Type Safety**: Compile-time checking of provider interfaces +5. **Centralized Configuration**: All extension behavior defined in one location + +**Extension Capabilities You Can Register:** + +- **Service Target Providers**: Custom deployment targets (e.g., VMs, custom Azure services) +- **Framework Service Providers**: Language/framework-specific build and package logic +- **Project Event Handlers**: Project-level lifecycle events (preprovision, predeploy, etc.) +- **Service Event Handlers**: Service-level lifecycle events with optional filtering + +**Event Handler Filtering:** + +Service event handlers support optional filtering to only handle specific service types: + +```go +WithServiceEventHandler("prepackage", handler, &azdext.ServiceEventOptions{ + Host: "containerapp", // Only handle Container App services + Language: "python", // Only handle Python services +}) +``` + ### Supported Languages `azd` extensions can be built in any programming language but starter templates are included for the following: @@ -478,18 +593,18 @@ Common issues you might encounter when developing and publishing extensions: ### Capabilities -#### Current Capabilities (May 2025) +#### Current Capabilities (October 2025) -The following lists the current capabilities of `azd extensions`. +The following lists the current capabilities available to `azd` extensions: -##### Extension Commands +##### Extension Commands (`custom-commands`) > Extensions must declare the `custom-commands` capability in their `extension.yaml` file. Extensions can register commands under a namespace or command group within `azd`. For example, installing the AI extension adds a new `ai` command group. -##### Lifecycle Hooks +##### Lifecycle Events (`lifecycle-events`) > Extensions must declare the `lifecycle-events` capability in their `extension.yaml` file. @@ -504,17 +619,47 @@ Extensions can subscribe to project and service lifecycle events (both pre and p Your extension _**must**_ include a `listen` command to subscribe to these events. `azd` will automatically invoke your extension during supported commands to establish bi-directional communication. +##### Service Target Providers (`service-target-provider`) + +> Extensions must declare the `service-target-provider` capability in their `extension.yaml` file. + +Extensions can provide custom service targets that handle the full deployment lifecycle (package, publish, deploy) for specialized Azure services or custom deployment patterns. Examples include: + +- Custom VM deployment targets +- Specialized container platforms +- Edge computing platforms +- Custom cloud providers + +##### Framework Service Providers (`framework-service-provider`) + +> Extensions must declare the `framework-service-provider` capability in their `extension.yaml` file. + +Extensions can provide custom language and framework support for build, restore, and package operations. Examples include: + +- Custom language support (Rust, PHP, etc.) +- Framework-specific build systems +- Custom package managers +- Specialized build toolchains + +##### Model Context Protocol Server (`mcp-server`) + +> Extensions must declare the `mcp-server` capability in their `extension.yaml` file. + +Extensions can provide AI agent tools through the Model Context Protocol, enabling: + +- Custom AI tools and integrations +- Specialized knowledge bases +- Azure service automation for AI agents +- Custom development workflows for AI-assisted development + #### Future Considerations Future ideas include: - Registration of pluggable providers for: - - Language support (e.g., Go) - - New Azure service targets (e.g., VMs, ACI) - Infrastructure providers (e.g., Pulumi) - Source control providers (e.g., GitLab) - Pipeline providers (e.g., TeamCity) - --- ### Developer Workflow @@ -564,11 +709,194 @@ Once PR has been merged the extension updates are now live in the official `azd` ### Extension Manifest -Each extension must declare an `extension.yaml` file that describe the metadata for the extension and the capabilities that it supports. This metadata is used within the extension registry to provide details to developers when searching for and installing extensions. +Each extension must declare an `extension.yaml` file that describes the metadata for the extension and the capabilities that it supports. This metadata is used within the extension registry to provide details to developers when searching for and installing extensions. + +A [JSON schema](../extensions/extension.schema.json) is available to support authoring extension manifests. + +#### Schema Properties + +**Required Properties:** +- `id`: Unique identifier for the extension +- `version`: Semantic version following MAJOR.MINOR.PATCH format +- `capabilities`: Array of extension capabilities (see below) +- `displayName`: Human-readable name of the extension +- `description`: Detailed description of the extension + +**Optional Properties:** +- `namespace`: Command namespace for grouping extension commands +- `entryPoint`: Executable or script that serves as the entry point +- `usage`: Instructions on how to use the extension +- `examples`: Array of usage examples with name, description, and usage +- `tags`: Keywords for categorization and filtering +- `dependencies`: Other extensions this extension depends on +- `providers`: List of providers this extension registers +- `platforms`: Platform-specific metadata +- `mcp`: Model Context Protocol server configuration + +#### Extension Capabilities + +Extensions can declare the following capabilities in their manifest: + +- **`custom-commands`**: Expose new command groups and commands to azd +- **`lifecycle-events`**: Subscribe to azd project and service lifecycle events +- **`mcp-server`**: Provide Model Context Protocol tools for AI agents +- **`service-target-provider`**: Provide custom service deployment targets +- **`framework-service-provider`**: Provide custom language frameworks and build systems + +#### Complete Extension Manifest Example + +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json + +id: microsoft.azd.demo +namespace: demo +displayName: Demo Extension +description: This extension provides examples of the AZD extension framework. +usage: azd demo [options] +version: 0.1.0 +entryPoint: demo + +capabilities: + - custom-commands + - lifecycle-events + - service-target-provider + - framework-service-provider + - mcp-server + +examples: + - name: context + description: Displays the current azd project & environment context. + usage: azd demo context + - name: prompt + description: Display prompt capabilities. + usage: azd demo prompt + - name: deploy-vm + description: Deploy application to virtual machine using custom service target. + usage: azd demo deploy-vm + +tags: + - demo + - example + - development + +dependencies: + - id: microsoft.azd.core + version: "^1.0.0" + +providers: + - name: demo-vm + type: service-target + description: Custom VM deployment target for demonstration purposes + - name: rust-framework + type: framework-service + description: Custom Rust language framework support + +platforms: + windows: + executable: demo.exe + linux: + executable: demo + darwin: + executable: demo + +mcp: + serve: + args: ["mcp", "serve"] + env: ["DEMO_CONFIG=production"] +``` + +#### Extension Dependencies + +Extensions can declare dependencies on other extensions using the `dependencies` array: + +```yaml +dependencies: + - id: microsoft.azd.ai.builder + version: "^1.2.0" + - id: contoso.custom.tools + version: "~2.1.0" +``` + +Dependencies support semantic versioning constraints: +- `^1.0.0`: Compatible with version 1.x.x +- `~1.2.0`: Compatible with version 1.2.x +- `>=1.0.0 <2.0.0`: Range specification + +#### Provider Registration + +When your extension provides custom service targets or framework services, declare them in the `providers` section: + +```yaml +providers: + - name: azure-vm + type: service-target + description: Deploy applications to Azure Virtual Machines + - name: go-gin + type: framework-service + description: Support for Go applications using the Gin framework +``` + +This metadata helps azd understand what providers your extension offers and enables proper capability validation. + +#### Model Context Protocol (MCP) Configuration + +For extensions that provide AI agent tools, configure the MCP server: + +```yaml +capabilities: + - mcp-server + +mcp: + serve: + args: ["mcp", "serve"] + env: + - "API_KEY=${API_KEY}" + - "LOG_LEVEL=info" +``` + +The `mcp.serve.args` specifies the command arguments to start your MCP server, while `env` sets additional environment variables. + +#### Platform-Specific Configuration + +Extensions can provide platform-specific metadata for different operating systems: + +```yaml +platforms: + windows: + executable: myext.exe + installScript: install.ps1 + linux: + executable: myext + installScript: install.sh + darwin: + executable: myext + installScript: install.sh +``` + +#### Basic Extension Manifest Example + +Here's a simple extension manifest for getting started: -A [JSON schema](../extensions/registry.schema.json) is available to support authoring extension manifests. +```yaml +# yaml-language-server: $schema=https://raw.githubusercontent.com/Azure/azure-dev/refs/heads/main/cli/azd/extensions/extension.schema.json -#### Example +id: microsoft.azd.demo +namespace: demo +displayName: Demo Extension +description: This extension provides examples of the AZD extension framework. +usage: azd demo [options] +version: 0.1.0 +capabilities: + - custom-commands + - lifecycle-events +examples: + - name: context + description: Displays the current `azd` project & environment context. + usage: azd demo context + - name: prompt + description: Display prompt capabilities. + usage: azd demo prompt +``` The following is an example of an [extension manifest](../extensions/microsoft.azd.demo/extension.yaml). @@ -692,18 +1020,52 @@ host := azdext.NewExtensionHost(azdClient). return nil }). WithServiceEventHandler("prepackage", func(ctx context.Context, args *azdext.ServiceEventArgs) error { - // This is your event handler - for i := 1; i <= 20; i++ { - fmt.Printf("%d. Doing important work in extension...\n", i) + // Access service context with artifacts from previous phases + fmt.Printf("Processing service: %s\n", args.Service.Name) + + // Check build artifacts from previous build phase + if len(args.ServiceContext.Build) > 0 { + fmt.Printf("Found %d build artifacts:\n", len(args.ServiceContext.Build)) + for _, artifact := range args.ServiceContext.Build { + fmt.Printf(" - %s (kind: %s)\n", artifact.Path, artifact.Kind) + } + } + + // Check restore artifacts + if len(args.ServiceContext.Restore) > 0 { + fmt.Printf("Found %d restore artifacts:\n", len(args.ServiceContext.Restore)) + for _, artifact := range args.ServiceContext.Restore { + fmt.Printf(" - %s\n", artifact.Path) + } + } + + // Perform your custom packaging logic here + for i := 1; i <= 10; i++ { + fmt.Printf("%d. Preparing package for %s...\n", i, args.Service.Name) time.Sleep(250 * time.Millisecond) } return nil - }, &azdext.ServerEventOptions{ + }, &azdext.ServiceEventOptions{ // Optionally filter your subscription by service host and/or language Host: "containerapp", Language: "python", - }) + }). + WithServiceEventHandler("postdeploy", func(ctx context.Context, args *azdext.ServiceEventArgs) error { + // Access deployment results + fmt.Printf("Service %s deployment completed\n", args.Service.Name) + + // Check deployment artifacts + for _, artifact := range args.ServiceContext.Deploy { + if artifact.Kind == azdext.ARTIFACT_KIND_ENDPOINT { + fmt.Printf("Service endpoint: %s\n", artifact.Path) + } else if artifact.Kind == azdext.ARTIFACT_KIND_DEPLOYMENT { + fmt.Printf("Deployment ID: %s\n", artifact.Path) + } + } + + return nil + }, nil) // Start the extension host // This is a blocking call and will not return until the server connection is closed. @@ -739,11 +1101,14 @@ The following are a list of available gRPC services for extension developer to i - [Environment Service](#environment-service) - [User Config Service](#user-config-service) - [Deployment Service](#deployment-service) +- [Account Service](#account-service) - [Prompt Service](#prompt-service) - [Event Service](#event-service) +- [Container Service](#container-service) +- [Framework Service](#framework-service) +- [Service Target Service](#service-target-service) - [Compose Service](#compose-service) - [Workflow Service](#workflow-service) -- [Account Service](#account-service) --- @@ -1082,6 +1447,57 @@ Prompts the user to select an option from a list. - **Response:** _SelectResponse_ - Contains an optional `value` (int32) +#### MultiSelect + +Prompts the user to select multiple options from a list. + +- **Request:** _MultiSelectRequest_ + - `options` (MultiSelectOptions) with: + - `message` (string) + - `choices` (repeated MultiSelectChoice) with: + - `value` (string): The actual value + - `display` (string): Display text for the choice + - `selected` (bool): Whether initially selected + - `help_message` (string) + - `hint` (string) + - `display_count` (int32) +- **Response:** _MultiSelectResponse_ + - Contains a list of selected **MultiSelectChoice** items + +**Example Usage (Go):** + +```go +// Prompt for multiple environment selections +ctx := azdext.WithAccessToken(cmd.Context()) +azdClient, err := azdext.NewAzdClient() +if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) +} +defer azdClient.Close() + +choices := []*azdext.MultiSelectChoice{ + {Value: "dev", Display: "Development Environment", Selected: true}, + {Value: "staging", Display: "Staging Environment", Selected: false}, + {Value: "prod", Display: "Production Environment", Selected: false}, +} + +response, err := azdClient.Prompt().MultiSelect(ctx, &azdext.MultiSelectRequest{ + Options: &azdext.MultiSelectOptions{ + Message: "Select environments to deploy to:", + Choices: choices, + HelpMessage: "Choose one or more environments for deployment", + Hint: "Use space to select, enter to confirm", + }, +}) +if err != nil { + return fmt.Errorf("failed to prompt for environments: %w", err) +} + +for _, choice := range response.Values { + fmt.Printf("Selected: %s (%s)\n", choice.Display, choice.Value) +} +``` + #### PromptSubscriptionResource Prompts the user to select a resource from a subscription. @@ -1160,6 +1576,7 @@ Clients can subscribe to events and receive notifications via a bidirectional st - `event_name`: The name of the event being invoked. - `project`: The project configuration. - `service`: The specific service configuration. + - `service_context`: The service context containing artifacts from all lifecycle phases (restore, build, package, publish, deploy). - **ProjectHandlerStatus** Provides status updates for project events. @@ -1176,6 +1593,428 @@ Clients can subscribe to events and receive notifications via a bidirectional st - `status`: Status such as "running", "completed", or "failed". - `message`: Optional additional details. +#### ServiceContext and Service Event Arguments + +When service events are invoked, extensions receive a `ServiceEventArgs` structure that includes: + +- `Project`: The current project configuration +- `Service`: The specific service configuration +- `ServiceContext`: Artifacts accumulated across all service lifecycle phases + +**ServiceContext Structure:** + +```go +type ServiceContext struct { + Restore []*Artifact // Artifacts from restore phase + Build []*Artifact // Artifacts from build phase + Package []*Artifact // Artifacts from package phase + Publish []*Artifact // Artifacts from publish phase + Deploy []*Artifact // Artifacts from deploy phase +} +``` + +**Artifact Definition:** + +```go +type Artifact struct { + Kind ArtifactKind // Type of artifact (Directory, Config, Archive, Container, etc.) + Path string // Location of the artifact + LocationKind LocationKind // Whether it's local, remote, or service-based +} +``` + +**Artifact Kinds:** +- `ARTIFACT_KIND_DIRECTORY`: Directory containing project or build artifacts +- `ARTIFACT_KIND_CONFIG`: Configuration file +- `ARTIFACT_KIND_ARCHIVE`: Zip/archive package +- `ARTIFACT_KIND_CONTAINER`: Docker/container image +- `ARTIFACT_KIND_ENDPOINT`: Service endpoint URL +- `ARTIFACT_KIND_DEPLOYMENT`: Deployment result or endpoint +- `ARTIFACT_KIND_RESOURCE`: Azure Resource + +**Example: Using ServiceContext in Event Handlers** + +```go +host := azdext.NewExtensionHost(azdClient). + WithServiceEventHandler("prepackage", func(ctx context.Context, args *azdext.ServiceEventArgs) error { + // Access build artifacts from previous phase + for _, artifact := range args.ServiceContext.Build { + fmt.Printf("Build artifact: %s (kind: %s)\n", artifact.Path, artifact.Kind) + } + + // Access restore artifacts + for _, artifact := range args.ServiceContext.Restore { + fmt.Printf("Restore artifact: %s\n", artifact.Path) + } + + return nil + }, nil). + WithServiceEventHandler("postdeploy", func(ctx context.Context, args *azdext.ServiceEventArgs) error { + // Access deployment results + for _, artifact := range args.ServiceContext.Deploy { + if artifact.Kind == azdext.ARTIFACT_KIND_ENDPOINT { + fmt.Printf("Service deployed to: %s\n", artifact.Path) + } + } + + return nil + }, nil) +``` + +--- + +### Container Service + +This service provides container build, package, and publish operations for extensions that need to work with containers but don't want to implement the full complexity of Docker CLI integration, registry authentication, etc. + +> See [container.proto](../grpc/proto/container.proto) for more details. + +#### Build + +Builds a service's container image. + +- **Request:** _ContainerBuildRequest_ + - Contains: + - `service_name` (string): Name of the service to build + - `service_context` (ServiceContext): Current service context with artifacts +- **Response:** _ContainerBuildResponse_ + - Contains: + - `result` (ServiceBuildResult): Build result with artifacts + +**Example Usage (Go):** + +```go +// Build a container for a service +buildResponse, err := azdClient.Container().Build(ctx, &azdext.ContainerBuildRequest{ + ServiceName: "api", + ServiceContext: &azdext.ServiceContext{ + Restore: restoreArtifacts, + Build: buildArtifacts, + }, +}) +if err != nil { + return fmt.Errorf("failed to build container: %w", err) +} + +// Access build artifacts +for _, artifact := range buildResponse.Result.Artifacts { + fmt.Printf("Built artifact: %s\n", artifact.Path) +} +``` + +#### Package + +Packages a service's container for deployment. + +- **Request:** _ContainerPackageRequest_ + - Contains: + - `service_name` (string): Name of the service to package + - `service_context` (ServiceContext): Current service context with artifacts +- **Response:** _ContainerPackageResponse_ + - Contains: + - `result` (ServicePackageResult): Package result with artifacts + +#### Publish + +Publishes a container service to a registry. + +- **Request:** _ContainerPublishRequest_ + - Contains: + - `service_name` (string): Name of the service to publish + - `service_context` (ServiceContext): Current service context with artifacts +- **Response:** _ContainerPublishResponse_ + - Contains: + - `result` (ServicePublishResult): Publish result with artifacts + +**Complete Container Workflow Example (Go):** + +```go +ctx := azdext.WithAccessToken(cmd.Context()) +azdClient, err := azdext.NewAzdClient() +if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) +} +defer azdClient.Close() + +serviceName := "web-api" +serviceContext := &azdext.ServiceContext{} + +// Build the container +buildResp, err := azdClient.Container().Build(ctx, &azdext.ContainerBuildRequest{ + ServiceName: serviceName, + ServiceContext: serviceContext, +}) +if err != nil { + return fmt.Errorf("container build failed: %w", err) +} + +// Update context with build artifacts +serviceContext.Build = buildResp.Result.Artifacts + +// Package the container +packageResp, err := azdClient.Container().Package(ctx, &azdext.ContainerPackageRequest{ + ServiceName: serviceName, + ServiceContext: serviceContext, +}) +if err != nil { + return fmt.Errorf("container package failed: %w", err) +} + +// Update context with package artifacts +serviceContext.Package = packageResp.Result.Artifacts + +// Publish the container +publishResp, err := azdClient.Container().Publish(ctx, &azdext.ContainerPublishRequest{ + ServiceName: serviceName, + ServiceContext: serviceContext, +}) +if err != nil { + return fmt.Errorf("container publish failed: %w", err) +} + +fmt.Printf("Container published successfully with %d artifacts\n", len(publishResp.Result.Artifacts)) +``` + +--- + +### Framework Service + +This service handles language and framework-specific operations like restore, build, and package for services. Extensions can register framework service providers to handle custom languages or override default behavior. + +> See [framework_service.proto](../grpc/proto/framework_service.proto) for more details. + +#### Provider Interface + +Framework service providers implement the `FrameworkServiceProvider` interface: + +```go +type FrameworkServiceProvider interface { + Initialize(ctx context.Context, serviceConfig *ServiceConfig) error + RequiredExternalTools(ctx context.Context, serviceConfig *ServiceConfig) ([]*ExternalTool, error) + Requirements() (*FrameworkRequirements, error) + Restore(ctx context.Context, serviceConfig *ServiceConfig, serviceContext *ServiceContext, progress ProgressReporter) (*ServiceRestoreResult, error) + Build(ctx context.Context, serviceConfig *ServiceConfig, serviceContext *ServiceContext, progress ProgressReporter) (*ServiceBuildResult, error) + Package(ctx context.Context, serviceConfig *ServiceConfig, serviceContext *ServiceContext, progress ProgressReporter) (*ServicePackageResult, error) +} +``` + +#### Stream + +The framework service uses a bidirectional stream for communication between azd and the extension. + +- **Request/Response:** _FrameworkServiceMessage_ (bidirectional stream) + - Contains various message types: + - `RegisterFrameworkServiceRequest/Response`: Register a framework provider + - `FrameworkServiceInitializeRequest/Response`: Initialize the service + - `FrameworkServiceRequiredExternalToolsRequest/Response`: Get required tools + - `FrameworkServiceRequirementsRequest/Response`: Get framework requirements + - `FrameworkServiceRestoreRequest/Response`: Restore dependencies + - `FrameworkServiceBuildRequest/Response`: Build the service + - `FrameworkServicePackageRequest/Response`: Package the service + +**Example: Registering a Framework Service Provider (Go):** + +```go +// Custom Rust framework provider +type RustFrameworkProvider struct{} + +func (r *RustFrameworkProvider) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { + // Initialize Rust-specific settings + return nil +} + +func (r *RustFrameworkProvider) RequiredExternalTools(ctx context.Context, serviceConfig *azdext.ServiceConfig) ([]*azdext.ExternalTool, error) { + return []*azdext.ExternalTool{ + { + Name: "cargo", + InstallUrl: "https://rustup.rs/", + }, + }, nil +} + +func (r *RustFrameworkProvider) Requirements() (*azdext.FrameworkRequirements, error) { + return &azdext.FrameworkRequirements{ + Package: &azdext.FrameworkPackageRequirements{ + RequireRestore: true, + RequireBuild: true, + }, + }, nil +} + +func (r *RustFrameworkProvider) Restore(ctx context.Context, serviceConfig *azdext.ServiceConfig, serviceContext *azdext.ServiceContext, progress azdext.ProgressReporter) (*azdext.ServiceRestoreResult, error) { + // Run cargo fetch or similar + return &azdext.ServiceRestoreResult{ + Artifacts: []*azdext.Artifact{ + { + Kind: azdext.ARTIFACT_KIND_DIRECTORY, + Path: "target/deps", + }, + }, + }, nil +} + +func (r *RustFrameworkProvider) Build(ctx context.Context, serviceConfig *azdext.ServiceConfig, serviceContext *azdext.ServiceContext, progress azdext.ProgressReporter) (*azdext.ServiceBuildResult, error) { + // Run cargo build + return &azdext.ServiceBuildResult{ + Artifacts: []*azdext.Artifact{ + { + Kind: azdext.ARTIFACT_KIND_DIRECTORY, + Path: "target/release", + }, + }, + }, nil +} + +func (r *RustFrameworkProvider) Package(ctx context.Context, serviceConfig *azdext.ServiceConfig, serviceContext *azdext.ServiceContext, progress azdext.ProgressReporter) (*azdext.ServicePackageResult, error) { + // Package Rust application + return &azdext.ServicePackageResult{ + Artifacts: []*azdext.Artifact{ + { + Kind: azdext.ARTIFACT_KIND_ARCHIVE, + Path: "dist/app.tar.gz", + }, + }, + }, nil +} + +// Register the framework provider +func main() { + ctx := azdext.WithAccessToken(context.Background()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + log.Fatal(err) + } + defer azdClient.Close() + + host := azdext.NewExtensionHost(azdClient). + WithFrameworkService("rust", func() azdext.FrameworkServiceProvider { + return &RustFrameworkProvider{} + }) + + if err := host.Run(ctx); err != nil { + log.Fatalf("failed to run extension: %v", err) + } +} +``` + +--- + +### Service Target Service + +This service handles the full deployment lifecycle for services, including packaging, publishing, and deploying to Azure resources. Extensions can register service target providers for custom deployment scenarios. + +> See [service_target.proto](../grpc/proto/service_target.proto) for more details. + +#### Provider Interface + +Service target providers implement the `ServiceTargetProvider` interface: + +```go +type ServiceTargetProvider interface { + Initialize(ctx context.Context, serviceConfig *ServiceConfig) error + GetTargetResource(ctx context.Context, subscriptionId string, serviceConfig *ServiceConfig) (*TargetResource, error) + Package(ctx context.Context, serviceConfig *ServiceConfig, frameworkPackage *ServicePackageResult, progress ProgressReporter) (*ServicePackageResult, error) + Publish(ctx context.Context, serviceConfig *ServiceConfig, servicePackage *ServicePackageResult, targetResource *TargetResource, progress ProgressReporter) (*ServicePublishResult, error) + Deploy(ctx context.Context, serviceConfig *ServiceConfig, servicePackage *ServicePackageResult, servicePublish *ServicePublishResult, targetResource *TargetResource, progress ProgressReporter) (*ServiceDeployResult, error) + Endpoints(ctx context.Context, serviceConfig *ServiceConfig, targetResource *TargetResource) ([]string, error) +} +``` + +#### Stream + +The service target service uses a bidirectional stream for communication between azd and the extension. + +- **Request/Response:** _ServiceTargetMessage_ (bidirectional stream) + - Contains various message types: + - `RegisterServiceTargetRequest/Response`: Register a service target provider + - `ServiceTargetInitializeRequest/Response`: Initialize the service target + - `GetTargetResourceRequest/Response`: Get the target Azure resource + - `ServiceTargetPackageRequest/Response`: Package the service + - `ServiceTargetPublishRequest/Response`: Publish the service + - `ServiceTargetDeployRequest/Response`: Deploy the service + - `ServiceTargetEndpointsRequest/Response`: Get service endpoints + +**Example: Custom Service Target Provider (Go):** + +```go +// Custom VM service target provider +type VMServiceTargetProvider struct{} + +func (v *VMServiceTargetProvider) Initialize(ctx context.Context, serviceConfig *azdext.ServiceConfig) error { + // Initialize VM-specific settings + return nil +} + +func (v *VMServiceTargetProvider) GetTargetResource(ctx context.Context, subscriptionId string, serviceConfig *azdext.ServiceConfig) (*azdext.TargetResource, error) { + return &azdext.TargetResource{ + ResourceId: fmt.Sprintf("/subscriptions/%s/resourceGroups/%s/providers/Microsoft.Compute/virtualMachines/%s", + subscriptionId, serviceConfig.ResourceGroupName, serviceConfig.ResourceName), + }, nil +} + +func (v *VMServiceTargetProvider) Package(ctx context.Context, serviceConfig *azdext.ServiceConfig, frameworkPackage *azdext.ServicePackageResult, progress azdext.ProgressReporter) (*azdext.ServicePackageResult, error) { + // Create deployment package for VM + return &azdext.ServicePackageResult{ + Artifacts: []*azdext.Artifact{ + { + Kind: azdext.ARTIFACT_KIND_ARCHIVE, + Path: "vm-deploy.zip", + }, + }, + }, nil +} + +func (v *VMServiceTargetProvider) Publish(ctx context.Context, serviceConfig *azdext.ServiceConfig, servicePackage *azdext.ServicePackageResult, targetResource *azdext.TargetResource, progress azdext.ProgressReporter) (*azdext.ServicePublishResult, error) { + // Upload package to storage or registry + return &azdext.ServicePublishResult{ + Artifacts: []*azdext.Artifact{ + { + Kind: azdext.ARTIFACT_KIND_ENDPOINT, + Path: "https://storage.azure.com/packages/vm-deploy.zip", + }, + }, + }, nil +} + +func (v *VMServiceTargetProvider) Deploy(ctx context.Context, serviceConfig *azdext.ServiceConfig, servicePackage *azdext.ServicePackageResult, servicePublish *azdext.ServicePublishResult, targetResource *azdext.TargetResource, progress azdext.ProgressReporter) (*azdext.ServiceDeployResult, error) { + // Deploy to VM using scripts or ARM templates + return &azdext.ServiceDeployResult{ + Artifacts: []*azdext.Artifact{ + { + Kind: azdext.ARTIFACT_KIND_DEPLOYMENT, + Path: "deployment-12345", + }, + }, + }, nil +} + +func (v *VMServiceTargetProvider) Endpoints(ctx context.Context, serviceConfig *azdext.ServiceConfig, targetResource *azdext.TargetResource) ([]string, error) { + // Return VM endpoints + return []string{"https://myvm.azure.com:8080"}, nil +} + +// Register the service target provider +func main() { + ctx := azdext.WithAccessToken(context.Background()) + azdClient, err := azdext.NewAzdClient() + if err != nil { + log.Fatal(err) + } + defer azdClient.Close() + + host := azdext.NewExtensionHost(azdClient). + WithServiceTarget("vm", func() azdext.ServiceTargetProvider { + return &VMServiceTargetProvider{} + }) + + if err := host.Run(ctx); err != nil { + log.Fatalf("failed to run extension: %v", err) + } +} +``` + +--- + ### Compose Service This service manages composability resources in an AZD project. @@ -1274,26 +2113,50 @@ Lists all subscriptions accessible by the current account. **Example Usage (Go):** ```go +ctx := azdext.WithAccessToken(cmd.Context()) +azdClient, err := azdext.NewAzdClient() +if err != nil { + return fmt.Errorf("failed to create azd client: %w", err) +} +defer azdClient.Close() + // List all subscriptions response, err := azdClient.Account().ListSubscriptions(ctx, &azdext.ListSubscriptionsRequest{}) if err != nil { return fmt.Errorf("failed to list subscriptions: %w", err) } +fmt.Printf("Found %d subscriptions:\n", len(response.Subscriptions)) for _, sub := range response.Subscriptions { - fmt.Printf("%s (%s)\n", sub.Name, sub.Id) + defaultMarker := "" + if sub.IsDefault { + defaultMarker = " (default)" + } + fmt.Printf(" %s (%s)%s\n", sub.Name, sub.Id, defaultMarker) + fmt.Printf(" Tenant: %s\n", sub.TenantId) + if sub.UserTenantId != sub.TenantId { + fmt.Printf(" User Tenant: %s\n", sub.UserTenantId) + } } // Filter by tenant ID tenantId := "your-tenant-id" -response, err := azdClient.Account().ListSubscriptions(ctx, &azdext.ListSubscriptionsRequest{ +filteredResponse, err := azdClient.Account().ListSubscriptions(ctx, &azdext.ListSubscriptionsRequest{ TenantId: &tenantId, }) +if err != nil { + return fmt.Errorf("failed to list subscriptions for tenant: %w", err) +} + +fmt.Printf("Subscriptions in tenant %s: %d\n", tenantId, len(filteredResponse.Subscriptions)) ``` **Use Cases:** + - List available subscriptions for user selection - Filter subscriptions by tenant in multi-tenant scenarios +- Display subscription details for user confirmation +- Find default subscription for automated operations #### LookupTenant @@ -1310,16 +2173,54 @@ Resolves the tenant ID required by the current account to access a given subscri ```go // Look up the tenant for a specific subscription +subscriptionId := "12345678-1234-1234-1234-123456789abc" response, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ - SubscriptionId: "12345678-1234-1234-1234-123456789abc", + SubscriptionId: subscriptionId, }) if err != nil { - return fmt.Errorf("failed to lookup tenant: %w", err) + return fmt.Errorf("failed to lookup tenant for subscription %s: %w", subscriptionId, err) } -fmt.Printf("Access subscription via tenant: %s\n", response.TenantId) +fmt.Printf("Subscription %s requires tenant %s for access\n", subscriptionId, response.TenantId) + +// Complete example: Get subscription info and tenant +func getSubscriptionDetails(ctx context.Context, azdClient *azdext.AzdClient, subscriptionId string) error { + // First lookup the required tenant + tenantResp, err := azdClient.Account().LookupTenant(ctx, &azdext.LookupTenantRequest{ + SubscriptionId: subscriptionId, + }) + if err != nil { + return fmt.Errorf("failed to lookup tenant: %w", err) + } + + // Then get subscription details using the tenant filter + subsResp, err := azdClient.Account().ListSubscriptions(ctx, &azdext.ListSubscriptionsRequest{ + TenantId: &tenantResp.TenantId, + }) + if err != nil { + return fmt.Errorf("failed to list subscriptions: %w", err) + } + + // Find the specific subscription + for _, sub := range subsResp.Subscriptions { + if sub.Id == subscriptionId { + fmt.Printf("Subscription Details:\n") + fmt.Printf(" Name: %s\n", sub.Name) + fmt.Printf(" ID: %s\n", sub.Id) + fmt.Printf(" Tenant: %s\n", sub.TenantId) + fmt.Printf(" User Tenant: %s\n", sub.UserTenantId) + fmt.Printf(" Is Default: %t\n", sub.IsDefault) + break + } + } + + return nil +} ``` **Use Cases:** + - Determine which tenant ID to use when making Azure API calls for a subscription -- Handle multi-tenant scenarios +- Handle multi-tenant scenarios where users access subscriptions through different tenants +- Validate subscription access before performing operations +- Set up proper authentication context for Azure SDK calls diff --git a/cli/azd/extensions/azure.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md new file mode 100644 index 00000000000..55e180e95bb --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/CHANGELOG.md @@ -0,0 +1,5 @@ +# Release History + +## 0.0.1 (2025-10-28) + +- Initial release diff --git a/cli/azd/extensions/azure.foundry.ai.agents/README.md b/cli/azd/extensions/azure.ai.agents/README.md similarity index 94% rename from cli/azd/extensions/azure.foundry.ai.agents/README.md rename to cli/azd/extensions/azure.ai.agents/README.md index 956855d49d2..0e4b081398c 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/README.md +++ b/cli/azd/extensions/azure.ai.agents/README.md @@ -22,7 +22,7 @@ 1. **Navigate to the extension directory**: ```bash - cd cli/azd/extensions/azure.foundry.ai.agents + cd cli/azd/extensions/azure.ai.agents ``` 2. **Initial setup** (first time only): @@ -48,7 +48,7 @@ 3. **Install the extension**: ```bash - azd ext install azure.foundry.ai.agents + azd ext install azure.ai.agents ``` 4. **For subsequent development** (after initial setup): diff --git a/cli/azd/extensions/azure.foundry.ai.agents/build.ps1 b/cli/azd/extensions/azure.ai.agents/build.ps1 similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/build.ps1 rename to cli/azd/extensions/azure.ai.agents/build.ps1 diff --git a/cli/azd/extensions/azure.foundry.ai.agents/build.sh b/cli/azd/extensions/azure.ai.agents/build.sh similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/build.sh rename to cli/azd/extensions/azure.ai.agents/build.sh diff --git a/cli/azd/extensions/azure.foundry.ai.agents/ci-build.ps1 b/cli/azd/extensions/azure.ai.agents/ci-build.ps1 similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/ci-build.ps1 rename to cli/azd/extensions/azure.ai.agents/ci-build.ps1 diff --git a/cli/azd/extensions/azure.foundry.ai.agents/extension.yaml b/cli/azd/extensions/azure.ai.agents/extension.yaml similarity index 88% rename from cli/azd/extensions/azure.foundry.ai.agents/extension.yaml rename to cli/azd/extensions/azure.ai.agents/extension.yaml index c81448f12da..c4119b77956 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/extension.yaml +++ b/cli/azd/extensions/azure.ai.agents/extension.yaml @@ -1,25 +1,25 @@ -# yaml-language-server: $schema=../extension.schema.json -id: azure.foundry.ai.agents -namespace: ai.agent -displayName: AI Foundry Agents -description: This extension provides custom commands for working with agents using Azure Developer CLI. -usage: azd ai agent [options] -# NOTE: Make sure version.txt is in sync with this version. -version: 0.0.2 -language: go -capabilities: - - custom-commands - - lifecycle-events - - mcp-server - - service-target-provider -providers: - - name: foundry.containeragent - type: service-target - description: Deploys agents to AI Foundry container agent service. -examples: - - name: init - description: Initialize a new AI agent project. - usage: azd ai agent init - - name: mcp - description: Start MCP server with AI Foundry agent tools. - usage: azd ai agent mcp start +# yaml-language-server: $schema=../extension.schema.json +id: azure.ai.agents +namespace: ai.agent +displayName: AI Foundry Agents +description: This extension provides custom commands for working with agents using Azure Developer CLI. +usage: azd ai agent [options] +# NOTE: Make sure version.txt is in sync with this version. +version: 0.0.1 +language: go +capabilities: + - custom-commands + - lifecycle-events + - mcp-server + - service-target-provider +providers: + - name: azure.ai.agents + type: service-target + description: Deploys agents to AI Foundry container agent service. +examples: + - name: init + description: Initialize a new AI agent project. + usage: azd ai agent init + - name: mcp + description: Start MCP server with AI Foundry agent tools. + usage: azd ai agent mcp start diff --git a/cli/azd/extensions/azure.foundry.ai.agents/go.mod b/cli/azd/extensions/azure.ai.agents/go.mod similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/go.mod rename to cli/azd/extensions/azure.ai.agents/go.mod diff --git a/cli/azd/extensions/azure.foundry.ai.agents/go.sum b/cli/azd/extensions/azure.ai.agents/go.sum similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/go.sum rename to cli/azd/extensions/azure.ai.agents/go.sum diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/deploy.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/deploy.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/deploy.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go similarity index 93% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index d71807ce323..3aee1f1e580 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -11,6 +11,7 @@ import ( "os" "path/filepath" "regexp" + "slices" "strings" "azureaiagent/internal/pkg/agents/agent_yaml" @@ -46,7 +47,7 @@ type InitAction struct { //azureClient *azure.AzureClient azureContext *azdext.AzureContext //composedResources []*azdext.ComposedResource - //console input.Console + console input.Console credential azcore.TokenCredential modelCatalog map[string]*ai.AiModel modelCatalogService *ai.ModelCatalogService @@ -101,25 +102,25 @@ func newInitCommand() *cobra.Command { return fmt.Errorf("failed to create azure credential: %w", err) } - // console := input.NewConsole( - // false, // noPrompt - // true, // isTerminal - // input.Writers{Output: os.Stdout}, - // input.ConsoleHandles{ - // Stderr: os.Stderr, - // Stdin: os.Stdin, - // Stdout: os.Stdout, - // }, - // nil, // formatter - // nil, // externalPromptCfg - // ) + console := input.NewConsole( + false, // noPrompt + true, // isTerminal + input.Writers{Output: os.Stdout}, + input.ConsoleHandles{ + Stderr: os.Stderr, + Stdin: os.Stdin, + Stdout: os.Stdout, + }, + nil, // formatter + nil, // externalPromptCfg + ) action := &InitAction{ azdClient: azdClient, // azureClient: azure.NewAzureClient(credential), azureContext: azureContext, // composedResources: getComposedResourcesResponse.Resources, - // console: console, + console: console, credential: credential, modelCatalogService: ai.NewModelCatalogService(credential), projectConfig: projectConfig, @@ -771,8 +772,13 @@ func (a *InitAction) addToProject(ctx context.Context, targetDir string, agentMa switch agentDef.Kind { case "container": host = "containerapp" + case "hosted": + host = "azure.ai.agents" + case "prompt": + host = "azure.ai.agents" default: - host = "foundry.containeragent" + // except here + return fmt.Errorf("unsupported agent kind: %s", agentDef.Kind) } serviceConfig := &azdext.ServiceConfig{ @@ -1192,7 +1198,7 @@ func (a *InitAction) loadAiCatalog(ctx context.Context) error { return fmt.Errorf("failed to start spinner: %w", err) } - aiModelCatalog, err := a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId) + aiModelCatalog, err := a.modelCatalogService.ListAllModels(ctx, a.azureContext.Scope.SubscriptionId, a.azureContext.Scope.Location) if err != nil { return fmt.Errorf("failed to load AI model catalog: %w", err) } @@ -1226,33 +1232,48 @@ func (a *InitAction) loadAiCatalog(ctx context.Context) error { // } // } -// func selectFromList( -// ctx context.Context, console input.Console, q string, options []string, defaultOpt *string) (string, error) { +func (a *InitAction) selectFromList( + ctx context.Context, property string, options []string, defaultOpt string) (string, error) { -// if len(options) == 1 { -// return options[0], nil -// } + if len(options) == 1 { + fmt.Printf("Only one %s available: %s\n", property, options[0]) + return options[0], nil + } -// defOpt := options[0] + slices.Sort(options) -// if defaultOpt != nil { -// defOpt = *defaultOpt -// } + // Convert default value to string for comparison + defaultStr := options[0] + if defaultOpt != "" { + defaultStr = defaultOpt + } -// slices.Sort(options) -// selectedIndex, err := console.Select(ctx, input.ConsoleOptions{ -// Message: q, -// Options: options, -// DefaultValue: defOpt, -// }) + // Create choices for the select prompt + choices := make([]*azdext.SelectChoice, len(options)) + defaultIndex := int32(0) + for i, val := range options { + choices[i] = &azdext.SelectChoice{ + Value: val, + Label: val, + } + if val == defaultStr { + defaultIndex = int32(i) + } + } -// if err != nil { -// return "", err -// } + resp, err := a.azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: fmt.Sprintf("Select %s", property), + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if err != nil { + return "", fmt.Errorf("failed to prompt for enum value: %w", err) + } -// chosen := options[selectedIndex] -// return chosen, nil -// } + return options[*resp.Value], nil +} func (a *InitAction) updateEnvironment(ctx context.Context, agentManifest *agent_yaml.AgentManifest) error { // Convert the template to bytes @@ -1398,21 +1419,45 @@ type DeploymentSku struct { } func (a *InitAction) getModelDeploymentDetails(ctx context.Context, model agent_yaml.Model) (*Deployment, error) { - modelDetails, err := a.getModelDetails(ctx, model.Id, *model.Version) + version := "" + if model.Version != nil { + version = *model.Version + } + modelDetails, err := a.getModelDetails(ctx, model.Id, version) if err != nil { return nil, fmt.Errorf("failed to get model details: %w", err) } + var modelDeployment string + if model.Deployment != nil { + modelDeployment = *model.Deployment + } else { + message := fmt.Sprintf("Enter model deployment name for model '%s' (defaults to model name):", model.Id) + + modelDeploymentInput, err := a.azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: message, + IgnoreHintKeys: true, + DefaultValue: model.Id, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for text value: %w", err) + } + + modelDeployment = modelDeploymentInput.Value + } + return &Deployment{ - Name: *model.Deployment, + Name: modelDeployment, Model: DeploymentModel{ Name: model.Id, Format: modelDetails.Format, - Version: *model.Version, + Version: modelDetails.Version, }, Sku: DeploymentSku{ - Name: "GlobalStandard", - Capacity: 100, + Name: modelDetails.Sku.Name, + Capacity: int(modelDetails.Sku.Capacity), }, }, nil } @@ -1430,9 +1475,33 @@ func (a *InitAction) getModelDetails(ctx context.Context, modelName string, mode return nil, fmt.Errorf("model '%s' not found in AI model catalog", modelName) } + if modelVersion == "" { + availableVersions, defaultVersion, err := a.modelCatalogService.ListModelVersions(ctx, model) + if err != nil { + return nil, fmt.Errorf("listing versions for model '%s': %w", modelName, err) + } + + modelVersionSelection, err := a.selectFromList(ctx, "model version", availableVersions, defaultVersion) + if err != nil { + return nil, err + } + + modelVersion = modelVersionSelection + } + + availableSkus, err := a.modelCatalogService.ListModelSkus(ctx, model, modelVersion) + if err != nil { + return nil, fmt.Errorf("listing SKUs for model '%s': %w", modelName, err) + } + + skuSelection, err := a.selectFromList(ctx, "model SKU", availableSkus, "") + if err != nil { + return nil, err + } + deploymentOptions := ai.AiModelDeploymentOptions{ Versions: []string{modelVersion}, - Skus: []string{"GlobalStandard"}, + Skus: []string{skuSelection}, } modelDeployment, err := a.modelCatalogService.GetModelDeployment(ctx, model, &deploymentOptions) diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/listen.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go similarity index 94% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/listen.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go index 8a9a1b4ebfb..2d5a9016261 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/listen.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/listen.go @@ -30,7 +30,7 @@ func newListenCommand() *cobra.Command { projectParser := &project.FoundryParser{AzdClient: azdClient} // IMPORTANT: service target name here must match the name used in the extension manifest. host := azdext.NewExtensionHost(azdClient). - WithServiceTarget("foundry.containeragent", func() azdext.ServiceTargetProvider { + WithServiceTarget("azure.ai.agents", func() azdext.ServiceTargetProvider { return project.NewAgentServiceTargetProvider(azdClient) }). WithProjectEventHandler("preprovision", projectParser.SetIdentity). diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/mcp.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/mcp.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/mcp.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/mcp.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/root.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/root.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/root.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/root.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/version.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/version.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/cmd/version.go rename to cli/azd/extensions/azure.ai.agents/internal/cmd/version.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_api/models.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/models.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_api/operations.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_api/operations.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/map.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/map.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/map.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go.old b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go.old similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go.old rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/parse_test.go.old diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/sample_integration_test.go.old b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/sample_integration_test.go.old similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/sample_integration_test.go.old rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/sample_integration_test.go.old diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/agent_yaml/yaml_test.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go similarity index 99% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go index 558811d36f6..aa6e37c9ec6 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go @@ -21,9 +21,6 @@ import ( type ParameterValues map[string]interface{} func ProcessRegistryManifest(ctx context.Context, manifest *Manifest, azdClient *azdext.AzdClient) (*agent_yaml.AgentManifest, error) { - // Debug: Print manifest.Template - fmt.Printf("DEBUG: manifest.Template: %+v\n", manifest.Template) - // Convert the agent API definition into a MAML definition promptAgent, err := ConvertAgentDefinition(manifest.Template) if err != nil { diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go.orig b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go.orig new file mode 100644 index 00000000000..50d31d1eb21 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go.orig @@ -0,0 +1,541 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package registry_api + +import ( + "context" + "encoding/json" + "fmt" + "reflect" + "strconv" + "strings" + + "azureaiagent/internal/pkg/agents/agent_api" + "azureaiagent/internal/pkg/agents/agent_yaml" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" +) + +// ParameterValues represents the user-provided values for manifest parameters +type ParameterValues map[string]interface{} + +func ProcessRegistryManifest(ctx context.Context, manifest *Manifest, azdClient *azdext.AzdClient) (*agent_yaml.AgentManifest, error) { + // Convert the agent API definition into a MAML definition + promptAgent, err := ConvertAgentDefinition(manifest.Template) + if err != nil { + return nil, fmt.Errorf("failed to convert agentDefinition: %w", err) + } + + // Inject Agent API Manifest properties into MAML Agent properties as needed + updatedAgentDef := MergeManifestIntoAgentDefinition(manifest, &promptAgent.AgentDefinition) + promptAgent.AgentDefinition = *updatedAgentDef + + // Convert the agent API parameters into MAML parameters + parameters, err := ConvertParameters(manifest.Parameters) + if err != nil { + return nil, fmt.Errorf("failed to convert parameters: %w", err) + } + + // Create the AgentManifest with the converted PromptAgent + result := &agent_yaml.AgentManifest{ + Name: manifest.Name, + DisplayName: manifest.DisplayName, + Description: &manifest.Description, +<<<<<<< HEAD:cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go + Template: *promptAgent, +======= + Template: *agentDef, +>>>>>>> main:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go + Parameters: parameters, + } + + return result, nil +} + +<<<<<<< HEAD:cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go +func ConvertAgentDefinition(template agent_api.PromptAgentDefinition) (*agent_yaml.PromptAgent, error) { +======= +func ConvertAgentDefinition(template agent_api.PromptAgentDefinition) (*agent_yaml.AgentDefinition, error) { +>>>>>>> main:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go + // Convert tools from agent_api.Tool to agent_yaml.Tool + var tools []agent_yaml.Tool + for _, apiTool := range template.Tools { + yamlTool := agent_yaml.Tool{ + Name: apiTool.Type, // Use Type as Name + Kind: "", // TODO: Where does this come from? + } + tools = append(tools, yamlTool) + } + +<<<<<<< HEAD:cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go + // Create the PromptAgent + promptAgent := &agent_yaml.PromptAgent{ + AgentDefinition: agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, // Set to prompt kind + Name: "", // Will be set later from manifest or user input + Description: nil, // Will be set later from manifest or user input + Tools: &tools, + // Metadata: make(map[string]interface{}), // TODO, Where does this come from? + }, + Model: agent_yaml.Model{ + Id: template.Model, + }, + Instructions: template.Instructions, + } + + return promptAgent, nil +======= + // Create the AgentDefinition + agentDef := &agent_yaml.AgentDefinition{ + Kind: agent_yaml.AgentKindPrompt, // Set to prompt kind + Name: "", // Will be set later from manifest or user input + Description: nil, // Will be set later from manifest or user input + Tools: &tools, + // Metadata: make(map[string]interface{}), // TODO, Where does this come from? + } + + return agentDef, nil +>>>>>>> main:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go +} + +func ConvertParameters(parameters map[string]OpenApiParameter) (*map[string]agent_yaml.Parameter, error) { + if len(parameters) == 0 { + return nil, nil + } + + result := make(map[string]agent_yaml.Parameter, len(parameters)) + + for paramName, openApiParam := range parameters { + // Create a basic Parameter from the OpenApiParameter + param := agent_yaml.Parameter{ + Name: paramName, + Description: &openApiParam.Description, + Required: &openApiParam.Required, + } + + // Extract type/kind from schema if available + if openApiParam.Schema != nil { + param.Schema = agent_yaml.ParameterSchema{ + Type: openApiParam.Schema.Type, + Default: &openApiParam.Schema.Default, + } + + // Convert enum values if present + if len(openApiParam.Schema.Enum) > 0 { + param.Schema.Enum = &openApiParam.Schema.Enum + } + } + + // Use example as default if no schema default is provided + if param.Schema.Default == nil && openApiParam.Example != nil { + param.Schema.Default = &openApiParam.Example + } + + // Fallback to string type if no type specified + if param.Schema.Type == "" { + param.Schema.Type = "string" + } + + result[paramName] = param + } + + return &result, nil +} + +// ProcessManifestParameters prompts the user for parameter values and injects them into the template +func ProcessManifestParameters(ctx context.Context, manifest *agent_yaml.AgentManifest, azdClient *azdext.AzdClient) (*agent_yaml.AgentManifest, error) { + // If no parameters are defined, return the manifest as-is + if manifest.Parameters == nil || len(*manifest.Parameters) == 0 { + fmt.Println("The manifest does not contain parameters that need to be configured.") + return manifest, nil + } + + fmt.Println("The manifest contains parameters that need to be configured:") + fmt.Println() + + // Collect parameter values from user + paramValues, err := promptForYamlParameterValues(ctx, *manifest.Parameters, azdClient) + if err != nil { + return nil, fmt.Errorf("failed to collect parameter values: %w", err) + } + + // Inject parameter values into the manifest + processedManifest, err := injectParameterValuesIntoManifest(manifest, paramValues) + if err != nil { + return nil, fmt.Errorf("failed to inject parameter values into manifest: %w", err) + } + + return processedManifest, nil +} + +// promptForYamlParameterValues prompts the user for values for each YAML parameter +func promptForYamlParameterValues(ctx context.Context, parameters map[string]agent_yaml.Parameter, azdClient *azdext.AzdClient) (ParameterValues, error) { + paramValues := make(ParameterValues) + + for paramName, param := range parameters { + fmt.Printf("Parameter: %s\n", paramName) + if param.Description != nil && *param.Description != "" { + fmt.Printf(" Description: %s\n", *param.Description) + } + + // Get default value + var defaultValue interface{} + if param.Schema.Default != nil { + defaultValue = *param.Schema.Default + } + + // Get enum values if available + var enumValues []string + if param.Schema.Enum != nil && len(*param.Schema.Enum) > 0 { + enumValues = make([]string, len(*param.Schema.Enum)) + for i, val := range *param.Schema.Enum { + enumValues[i] = fmt.Sprintf("%v", val) + } + } + + // Show available options if it's an enum + if len(enumValues) > 0 { + fmt.Printf(" Available values: %v\n", enumValues) + } + + // Show default value if available + if defaultValue != nil { + fmt.Printf(" Default: %v\n", defaultValue) + } + + fmt.Println() + + // Prompt for value + var value interface{} + var err error + isRequired := param.Required != nil && *param.Required + if len(enumValues) > 0 { + // Use selection for enum parameters + value, err = promptForEnumValue(ctx, paramName, enumValues, defaultValue, azdClient) + } else { + // Use text input for other parameters + value, err = promptForTextValue(ctx, paramName, defaultValue, isRequired, azdClient) + } + + if err != nil { + return nil, fmt.Errorf("failed to get value for parameter %s: %w", paramName, err) + } + + paramValues[paramName] = value + } + + return paramValues, nil +} + +// injectParameterValuesIntoManifest replaces parameter placeholders in the manifest with actual values +func injectParameterValuesIntoManifest(manifest *agent_yaml.AgentManifest, paramValues ParameterValues) (*agent_yaml.AgentManifest, error) { + // Convert manifest to JSON for processing + manifestBytes, err := json.Marshal(manifest) + if err != nil { + return nil, fmt.Errorf("failed to marshal manifest: %w", err) + } + + // Inject parameter values + processedBytes, err := injectParameterValues(json.RawMessage(manifestBytes), paramValues) + if err != nil { + return nil, fmt.Errorf("failed to inject parameter values: %w", err) + } + + // Convert back to AgentManifest + processedManifest, err := agent_yaml.LoadAndValidateAgentManifest(processedBytes) + if err != nil { + return nil, fmt.Errorf("failed to reload processed manifest: %w", err) + } + + return processedManifest, nil +} + +// promptForEnumValue prompts the user to select from enumerated values +func promptForEnumValue(ctx context.Context, paramName string, enumValues []string, defaultValue interface{}, azdClient *azdext.AzdClient) (interface{}, error) { + // Convert default value to string for comparison + var defaultStr string + if defaultValue != nil { + defaultStr = fmt.Sprintf("%v", defaultValue) + } + + // Create choices for the select prompt + choices := make([]*azdext.SelectChoice, len(enumValues)) + defaultIndex := int32(0) + for i, val := range enumValues { + choices[i] = &azdext.SelectChoice{ + Value: val, + Label: val, + } + if val == defaultStr { + defaultIndex = int32(i) + } + } + + resp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ + Options: &azdext.SelectOptions{ + Message: fmt.Sprintf("Select value for parameter '%s':", paramName), + Choices: choices, + SelectedIndex: &defaultIndex, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for enum value: %w", err) + } + + // Return the selected value + if resp.Value != nil && int(*resp.Value) < len(enumValues) { + return enumValues[*resp.Value], nil + } + + return enumValues[0], nil // fallback to first option +} + +// promptForTextValue prompts the user for a text value +func promptForTextValue(ctx context.Context, paramName string, defaultValue interface{}, required bool, azdClient *azdext.AzdClient) (interface{}, error) { + var defaultStr string + if defaultValue != nil { + defaultStr = fmt.Sprintf("%v", defaultValue) + } + + message := fmt.Sprintf("Enter value for parameter '%s':", paramName) + if defaultStr != "" { + message += fmt.Sprintf(" (default: %s)", defaultStr) + } + + resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ + Options: &azdext.PromptOptions{ + Message: message, + IgnoreHintKeys: true, + }, + }) + if err != nil { + return nil, fmt.Errorf("failed to prompt for text value: %w", err) + } + + // Use default value if user provided empty input + if strings.TrimSpace(resp.Value) == "" { + if defaultValue != nil { + return defaultValue, nil + } + if required { + return nil, fmt.Errorf("parameter '%s' is required but no value was provided", paramName) + } + } + + return resp.Value, nil +} + +// injectParameterValues replaces parameter placeholders in the template with actual values +func injectParameterValues(template json.RawMessage, paramValues ParameterValues) ([]byte, error) { + // Convert template to string for processing + templateStr := string(template) + + // Replace each parameter placeholder with its value + for paramName, paramValue := range paramValues { + placeholder := fmt.Sprintf("{{%s}}", paramName) + valueStr := fmt.Sprintf("%v", paramValue) + templateStr = strings.ReplaceAll(templateStr, placeholder, valueStr) + + placeholder = fmt.Sprintf("{{ %s }}", paramName) + templateStr = strings.ReplaceAll(templateStr, placeholder, valueStr) + } + + // Check for any remaining unreplaced placeholders + if strings.Contains(templateStr, "{{") && strings.Contains(templateStr, "}}") { + fmt.Printf("Warning: Template contains unresolved placeholders:\n%s\n", templateStr) + } + + return []byte(templateStr), nil +} + +// ValidateParameterValue validates a parameter value against its schema +func ValidateParameterValue(value interface{}, schema *OpenApiSchema) error { + if schema == nil { + return nil + } + + // Validate type if specified + if schema.Type != "" { + if err := validateType(value, schema.Type); err != nil { + return err + } + } + + // Validate enum if specified + if len(schema.Enum) > 0 { + if err := validateEnum(value, schema.Enum); err != nil { + return err + } + } + + // Additional validations can be added here (min/max length, patterns, etc.) + + return nil +} + +// validateType validates that a value matches the expected type +func validateType(value interface{}, expectedType string) error { + switch expectedType { + case "string": + if _, ok := value.(string); !ok { + return fmt.Errorf("expected string, got %T", value) + } + case "integer": + switch v := value.(type) { + case int, int32, int64: + // Valid integer types + case string: + // Try to parse string as integer + if _, err := strconv.Atoi(v); err != nil { + return fmt.Errorf("expected integer, got string that cannot be parsed as integer: %s", v) + } + default: + return fmt.Errorf("expected integer, got %T", value) + } + case "number": + switch v := value.(type) { + case int, int32, int64, float32, float64: + // Valid numeric types + case string: + // Try to parse string as number + if _, err := strconv.ParseFloat(v, 64); err != nil { + return fmt.Errorf("expected number, got string that cannot be parsed as number: %s", v) + } + default: + return fmt.Errorf("expected number, got %T", value) + } + case "boolean": + switch v := value.(type) { + case bool: + // Valid boolean + case string: + // Try to parse string as boolean + if _, err := strconv.ParseBool(v); err != nil { + return fmt.Errorf("expected boolean, got string that cannot be parsed as boolean: %s", v) + } + default: + return fmt.Errorf("expected boolean, got %T", value) + } + } + + return nil +} + +// validateEnum validates that a value is one of the allowed enum values +func validateEnum(value interface{}, enumValues []interface{}) error { + valueStr := fmt.Sprintf("%v", value) + + for _, enumVal := range enumValues { + enumStr := fmt.Sprintf("%v", enumVal) + if valueStr == enumStr { + return nil + } + } + + return fmt.Errorf("value '%v' is not one of the allowed values: %v", value, enumValues) +} + +// MergeManifestIntoAgentDefinition takes a Manifest and an AgentDefinition and updates +// the AgentDefinition with values from the Manifest for properties that are empty/zero. +// Returns the updated AgentDefinition. +func MergeManifestIntoAgentDefinition(manifest *Manifest, agentDef *agent_yaml.AgentDefinition) *agent_yaml.AgentDefinition { + // Create a copy of the agent definition to avoid modifying the original + result := *agentDef + + // Use reflection to iterate through AgentDefinition fields + resultValue := reflect.ValueOf(&result).Elem() + resultType := resultValue.Type() + + // Get manifest properties as a map using reflection + manifestValue := reflect.ValueOf(manifest).Elem() + manifestType := manifestValue.Type() + + // Create a map of manifest field names to values for easier lookup + manifestFields := make(map[string]reflect.Value) + for i := 0; i < manifestValue.NumField(); i++ { + field := manifestType.Field(i) + fieldValue := manifestValue.Field(i) + + // Use the json tag name if available, otherwise use the field name + jsonTag := field.Tag.Get("json") + fieldName := field.Name + if jsonTag != "" && jsonTag != "-" { + // Remove omitempty and other options from json tag + parts := strings.Split(jsonTag, ",") + if parts[0] != "" { + fieldName = parts[0] + } + } + manifestFields[strings.ToLower(fieldName)] = fieldValue + } + + // Iterate through AgentDefinition fields + for i := 0; i < resultValue.NumField(); i++ { + field := resultType.Field(i) + fieldValue := resultValue.Field(i) + + // Skip unexported fields + if !fieldValue.CanSet() { + continue + } + + // Get the field name to match with manifest + jsonTag := field.Tag.Get("json") + fieldName := field.Name + if jsonTag != "" && jsonTag != "-" { + // Remove omitempty and other options from json tag + parts := strings.Split(jsonTag, ",") + if parts[0] != "" { + fieldName = parts[0] + } + } + + // Check if this field exists in the manifest and if the agent definition field is empty + if manifestFieldValue, exists := manifestFields[strings.ToLower(fieldName)]; exists { + if isEmptyValue(fieldValue) && !isEmptyValue(manifestFieldValue) { + // Only set if types are compatible + if manifestFieldValue.Type().AssignableTo(fieldValue.Type()) { + fieldValue.Set(manifestFieldValue) + } else { + // Handle type conversion for common cases + if fieldValue.Kind() == reflect.String && manifestFieldValue.Kind() == reflect.String { + fieldValue.SetString(manifestFieldValue.String()) + } + } + } + } + } + + return &result +} + +// isEmptyValue checks if a reflect.Value represents an empty/zero value +func isEmptyValue(v reflect.Value) bool { + switch v.Kind() { + case reflect.String: + return v.String() == "" + case reflect.Bool: + return !v.Bool() + case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: + return v.Int() == 0 + case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: + return v.Uint() == 0 + case reflect.Float32, reflect.Float64: + return v.Float() == 0 + case reflect.Interface, reflect.Ptr, reflect.Slice, reflect.Map, reflect.Chan, reflect.Func: + return v.IsNil() + case reflect.Array: + return v.Len() == 0 + case reflect.Struct: + // For structs, check if all fields are empty + for i := 0; i < v.NumField(); i++ { + if !isEmptyValue(v.Field(i)) { + return false + } + } + return true + default: + return false + } +} diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/models.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/models.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/models.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/models.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/operations.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/operations.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/operations.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/operations.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/azure/ai/model_catalog.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/ai/model_catalog.go similarity index 89% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/azure/ai/model_catalog.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/azure/ai/model_catalog.go index b98a4d49e50..9fede9baf43 100644 --- a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/azure/ai/model_catalog.go +++ b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/ai/model_catalog.go @@ -68,31 +68,38 @@ func (c *ModelCatalogService) ListAllKinds(ctx context.Context, allModels map[st }) } -func (c *ModelCatalogService) ListModelVersions(ctx context.Context, model *AiModel) ([]string, error) { +func (c *ModelCatalogService) ListModelVersions(ctx context.Context, model *AiModel) ([]string, string, error) { versions := make(map[string]struct{}) + defaultVersion := "" for _, location := range model.Locations { versions[*location.Model.Model.Version] = struct{}{} + if location.Model.Model.IsDefaultVersion != nil && *location.Model.Model.IsDefaultVersion { + defaultVersion = *location.Model.Model.Version + } } - versionList := make([]string, len(versions)) + versionList := make([]string, 0, len(versions)) for version := range versions { versionList = append(versionList, version) } slices.Sort(versionList) - return versionList, nil + return versionList, defaultVersion, nil } -func (c *ModelCatalogService) ListModelSkus(ctx context.Context, model *AiModel) ([]string, error) { +func (c *ModelCatalogService) ListModelSkus(ctx context.Context, model *AiModel, modelVersion string) ([]string, error) { skus := make(map[string]struct{}) + for _, location := range model.Locations { - for _, sku := range location.Model.Model.SKUs { - skus[*sku.Name] = struct{}{} + if *location.Model.Model.Version == modelVersion { + for _, sku := range location.Model.Model.SKUs { + skus[*sku.Name] = struct{}{} + } } } - skuList := make([]string, len(skus)) + skuList := make([]string, 0, len(skus)) // Create with capacity, not length for sku := range skus { skuList = append(skuList, sku) } @@ -173,10 +180,23 @@ func (c *ModelCatalogService) ListFilteredModels( return filteredModels } -func (c *ModelCatalogService) ListAllModels(ctx context.Context, subscriptionId string) (map[string]*AiModel, error) { - locations, err := c.azureClient.ListLocations(ctx, subscriptionId) - if err != nil { - return nil, err +func (c *ModelCatalogService) ListAllModels(ctx context.Context, subscriptionId string, location string) (map[string]*AiModel, error) { + var locations []*armsubscriptions.Location + var err error + + if location == "" { + // If no specific location provided, get all locations + locations, err = c.azureClient.ListLocations(ctx, subscriptionId) + if err != nil { + return nil, err + } + } else { + // If specific location provided, create a single-item slice with that location + locations = []*armsubscriptions.Location{ + { + Name: &location, + }, + } } modelsClient, err := createModelsClient(subscriptionId, c.credential) @@ -315,6 +335,7 @@ func (c *ModelCatalogService) GetModelDeployment( // Check for SKU match if specified for _, sku := range location.Model.Model.SKUs { + if !slices.Contains(options.Skus, *sku.Name) { continue } diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/azure/azure_client.go b/cli/azd/extensions/azure.ai.agents/internal/pkg/azure/azure_client.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/azure/azure_client.go rename to cli/azd/extensions/azure.ai.agents/internal/pkg/azure/azure_client.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/project/parser.go b/cli/azd/extensions/azure.ai.agents/internal/project/parser.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/project/parser.go rename to cli/azd/extensions/azure.ai.agents/internal/project/parser.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/project/service_target_agent.go b/cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/project/service_target_agent.go rename to cli/azd/extensions/azure.ai.agents/internal/project/service_target_agent.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/internal/tools/add_agent.go b/cli/azd/extensions/azure.ai.agents/internal/tools/add_agent.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/internal/tools/add_agent.go rename to cli/azd/extensions/azure.ai.agents/internal/tools/add_agent.go diff --git a/cli/azd/extensions/azure.foundry.ai.agents/main.go b/cli/azd/extensions/azure.ai.agents/main.go similarity index 100% rename from cli/azd/extensions/azure.foundry.ai.agents/main.go rename to cli/azd/extensions/azure.ai.agents/main.go diff --git a/cli/azd/extensions/azure.ai.agents/tests/go.sum b/cli/azd/extensions/azure.ai.agents/tests/go.sum new file mode 100644 index 00000000000..a62c313c5b0 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/tests/go.sum @@ -0,0 +1,4 @@ +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= +gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/azd/extensions/azure.ai.agents/tests/samples/declarativeNoTools/agent.yaml b/cli/azd/extensions/azure.ai.agents/tests/samples/declarativeNoTools/agent.yaml new file mode 100644 index 00000000000..279897063b1 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/tests/samples/declarativeNoTools/agent.yaml @@ -0,0 +1,35 @@ +agent: + kind: prompt + name: Learn French Agent + description: |- + This Agent helps users learn French by providing vocabulary, grammar tips, and practice exercises. + metadata: + example: + - role: user + content: |- + Can you help me learn some basic French phrases for traveling? + - role: assistant + content: |- + Sure! Here are some useful French phrases for your trip: + 1. Bonjour - Hello + 2. Merci - Thank you + 3. Où est…? - Where is…? + 4. Combien ça coûte? - How much does it cost? + 5. Parlez-vous anglais? - Do you speak English? + tags: + - example + - learning + authors: + - jeomhove + + model: + id: gpt-4o-mini + publisher: azure + options: + temperature: 0.7 + maxTokens: 4000 + + instructions: |- + You are a helpful assistant that specializes in teaching French. + Provide clear explanations, examples, + and practice exercises to help users improve their French language skills. diff --git a/cli/azd/extensions/azure.ai.agents/tests/samples/githubMcpAgent/agent.yaml b/cli/azd/extensions/azure.ai.agents/tests/samples/githubMcpAgent/agent.yaml new file mode 100644 index 00000000000..59d98824513 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/tests/samples/githubMcpAgent/agent.yaml @@ -0,0 +1,46 @@ +agent: + kind: prompt + name: github-agent + description: An agent leveraging github-mcp to assist with GitHub-related tasks. + + metadata: + authors: + - jeomhove + tags: + - example + - prompt + + model: + id: gpt-4o-mini + publisher: azure + options: + temperature: 0.7 + maxTokens: 4000 + + tools: + - kind: mcp + connection: + kind: foundry + endpoint: https://api.githubcopilot.com/mcp/ + name: github-mcp-oauth + name: github-mcp-remote + url: https://api.githubcopilot.com/mcp/ + + instructions: |- + You are an expert assistant in using GitHub and the GitHub API. + You have access to a set of tools that allow you to interact with GitHub repositories, issues, pull requests, and more. + Use these tools to help users with their GitHub-related tasks. + Always provide clear and concise responses, and ensure that you use the tools effectively to gather information or perform actions on behalf of the user. + When using the tools, make sure to follow the correct syntax and provide all necessary parameters. + If you need to ask the user for more information, do so in a clear and polite manner. + Always remember to think step-by-step and verify your actions. + Here are some example tasks you can help with: + - Creating, updating, and managing issues and pull requests. + - Fetching information about repositories, commits, and branches. + - Collaborating with team members through comments and reviews. + - Automating workflows and actions using GitHub Actions. + - Providing insights and analytics on repository activity. + Be proactive in suggesting useful actions and improvements to the user's GitHub experience. + When you complete a task, summarize the actions taken and any important information for the user. + Always prioritize the user's goals and ensure their satisfaction with your assistance. + If you encounter any errors or issues while using the tools, handle them gracefully and inform the user. diff --git a/cli/azd/extensions/azure.ai.agents/version.txt b/cli/azd/extensions/azure.ai.agents/version.txt new file mode 100644 index 00000000000..8a9ecc2ea99 --- /dev/null +++ b/cli/azd/extensions/azure.ai.agents/version.txt @@ -0,0 +1 @@ +0.0.1 \ No newline at end of file diff --git a/cli/azd/extensions/azure.foundry.ai.agents/CHANGELOG.md b/cli/azd/extensions/azure.foundry.ai.agents/CHANGELOG.md deleted file mode 100644 index 2ce87e6d369..00000000000 --- a/cli/azd/extensions/azure.foundry.ai.agents/CHANGELOG.md +++ /dev/null @@ -1,9 +0,0 @@ -# Release History - -## 0.0.2 (2025-10-22) - -- Support for container app agents from event handlers - -## 0.0.1 (2025-10-09) - -- Initial release diff --git a/cli/azd/extensions/azure.foundry.ai.agents/version.txt b/cli/azd/extensions/azure.foundry.ai.agents/version.txt deleted file mode 100644 index 7bcd0e3612d..00000000000 --- a/cli/azd/extensions/azure.foundry.ai.agents/version.txt +++ /dev/null @@ -1 +0,0 @@ -0.0.2 \ No newline at end of file diff --git a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/listen.go b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/listen.go index a949ff9ee60..3e33f030734 100644 --- a/cli/azd/extensions/microsoft.azd.demo/internal/cmd/listen.go +++ b/cli/azd/extensions/microsoft.azd.demo/internal/cmd/listen.go @@ -61,26 +61,20 @@ func newListenCommand() *cobra.Command { }). WithServiceEventHandler("prepackage", func(ctx context.Context, args *azdext.ServiceEventArgs) error { for i := 1; i <= 20; i++ { - fmt.Printf("%d. Doing important prepackage service work in extension...\n", i) + fmt.Printf("Service: %s, Artifacts: %d\n", args.Service.Name, len(args.ServiceContext.Package)) time.Sleep(250 * time.Millisecond) } return nil - }, &azdext.ServerEventOptions{ - // Optionally filter your subscription by service host and/or language - Host: "containerapp", - }). + }, nil). WithServiceEventHandler("postpackage", func(ctx context.Context, args *azdext.ServiceEventArgs) error { for i := 1; i <= 20; i++ { - fmt.Printf("%d. Doing important postpackage service work in extension...\n", i) + fmt.Printf("Service: %s, Artifacts: %d\n", args.Service.Name, len(args.ServiceContext.Package)) time.Sleep(250 * time.Millisecond) } return nil - }, &azdext.ServerEventOptions{ - // Optionally filter your subscription by service host and/or language - Host: "containerapp", - }) + }, nil) // Start listening for events // This is a blocking call and will not return until the server connection is closed. diff --git a/cli/azd/extensions/registry.json b/cli/azd/extensions/registry.json index e1081fb859c..92d89443f6c 100644 --- a/cli/azd/extensions/registry.json +++ b/cli/azd/extensions/registry.json @@ -636,172 +636,6 @@ } } ] - }, - { - "id": "azure.foundry.ai.agents", - "namespace": "ai.agent", - "displayName": "AI Foundry Agents", - "description": "This extension provides custom commands for working with agents using Azure Developer CLI.", - "versions": [ - { - "version": "0.0.1", - "capabilities": [ - "custom-commands", - "lifecycle-events", - "mcp-server", - "service-target-provider" - ], - "providers": [ - { - "name": "foundry.containeragent", - "type": "service-target", - "description": "Deploys agents to AI Foundry container agent service." - } - ], - "usage": "azd ai agent \u003ccommand\u003e [options]", - "examples": [ - { - "name": "init", - "description": "Initialize a new AI agent project.", - "usage": "azd ai agent init" - }, - { - "name": "mcp", - "description": "Start MCP server with AI Foundry agent tools.", - "usage": "azd ai agent mcp start" - } - ], - "artifacts": { - "darwin/amd64": { - "checksum": { - "algorithm": "sha256", - "value": "4eecb4a37a9f76ea8e137994b258e5342ecaae06b770c983dc57fabc96c7f783" - }, - "entryPoint": "azure-foundry-ai-agents-darwin-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.1/azure-foundry-ai-agents-darwin-amd64.zip" - }, - "darwin/arm64": { - "checksum": { - "algorithm": "sha256", - "value": "a56189b7ed77415e663b3d98c2c868ee01dfb29ab07a4e6e69bc08b0aaca8796" - }, - "entryPoint": "azure-foundry-ai-agents-darwin-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.1/azure-foundry-ai-agents-darwin-arm64.zip" - }, - "linux/amd64": { - "checksum": { - "algorithm": "sha256", - "value": "d9fcf204d228a9914ee683d8418aef783ba71289cc6f66a2bb5fa74d41da937f" - }, - "entryPoint": "azure-foundry-ai-agents-linux-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.1/azure-foundry-ai-agents-linux-amd64.tar.gz" - }, - "linux/arm64": { - "checksum": { - "algorithm": "sha256", - "value": "91281f8a773c34464834943aed513df4bff145bd04af15b5512b410c28ecf70f" - }, - "entryPoint": "azure-foundry-ai-agents-linux-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.1/azure-foundry-ai-agents-linux-arm64.tar.gz" - }, - "windows/amd64": { - "checksum": { - "algorithm": "sha256", - "value": "efd206ee8950507488b0d1627801317c295fbd128a4cc1f4f080c50bb653181f" - }, - "entryPoint": "azure-foundry-ai-agents-windows-amd64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.1/azure-foundry-ai-agents-windows-amd64.zip" - }, - "windows/arm64": { - "checksum": { - "algorithm": "sha256", - "value": "4b49a0105ed1186c8283690b4e94582b7e1addb8fba9b79bcc42b6487150f2e9" - }, - "entryPoint": "azure-foundry-ai-agents-windows-arm64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.1/azure-foundry-ai-agents-windows-arm64.zip" - } - } - }, - { - "version": "0.0.2", - "capabilities": [ - "custom-commands", - "lifecycle-events", - "mcp-server", - "service-target-provider" - ], - "providers": [ - { - "name": "foundry.containeragent", - "type": "service-target", - "description": "Deploys agents to AI Foundry container agent service." - } - ], - "usage": "azd ai agent \u003ccommand\u003e [options]", - "examples": [ - { - "name": "init", - "description": "Initialize a new AI agent project.", - "usage": "azd ai agent init" - }, - { - "name": "mcp", - "description": "Start MCP server with AI Foundry agent tools.", - "usage": "azd ai agent mcp start" - } - ], - "artifacts": { - "darwin/amd64": { - "checksum": { - "algorithm": "sha256", - "value": "74d8bfac3a2789460279bd28252f57781e3ce6cfe07783e88ad13dfb8b9af876" - }, - "entryPoint": "azure-foundry-ai-agents-darwin-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.2/azure-foundry-ai-agents-darwin-amd64.zip" - }, - "darwin/arm64": { - "checksum": { - "algorithm": "sha256", - "value": "43d5d83c47533b4edf42e2944b4fe4beeab7a144c9005e7700ccb7b9534eda4e" - }, - "entryPoint": "azure-foundry-ai-agents-darwin-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.2/azure-foundry-ai-agents-darwin-arm64.zip" - }, - "linux/amd64": { - "checksum": { - "algorithm": "sha256", - "value": "9fb9b7f13498c1cc941442a126feed9fc2297b187cd08cc981174a8a9a8f401d" - }, - "entryPoint": "azure-foundry-ai-agents-linux-amd64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.2/azure-foundry-ai-agents-linux-amd64.tar.gz" - }, - "linux/arm64": { - "checksum": { - "algorithm": "sha256", - "value": "d43fd400afd2ae779f3036f3698b08e907c88db27656f04c00f88456d4c5289a" - }, - "entryPoint": "azure-foundry-ai-agents-linux-arm64", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.2/azure-foundry-ai-agents-linux-arm64.tar.gz" - }, - "windows/amd64": { - "checksum": { - "algorithm": "sha256", - "value": "d1b222f2cf41767de9f21a2aef223e7a55df6e5820a401181a40b6ca2e085a38" - }, - "entryPoint": "azure-foundry-ai-agents-windows-amd64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.2/azure-foundry-ai-agents-windows-amd64.zip" - }, - "windows/arm64": { - "checksum": { - "algorithm": "sha256", - "value": "d76fe6b0dba63d7b86c38a67b2100c6819ac45b84da04f517dad13375c5f8f0a" - }, - "entryPoint": "azure-foundry-ai-agents-windows-arm64.exe", - "url": "https://github.com/Azure/azure-dev/releases/download/azd-ext-azure-foundry-ai-agents_0.0.2/azure-foundry-ai-agents-windows-arm64.zip" - } - } - } - ] } ] } \ No newline at end of file diff --git a/cli/azd/grpc/proto/event.proto b/cli/azd/grpc/proto/event.proto index 8294740abc5..b4f8328fc18 100644 --- a/cli/azd/grpc/proto/event.proto +++ b/cli/azd/grpc/proto/event.proto @@ -65,6 +65,8 @@ message InvokeServiceHandler { ProjectConfig project = 2; // Specific service configuration. ServiceConfig service = 3; + // Service context with artifacts from all lifecycle phases. + ServiceContext service_context = 4; } // Client sends status updates for project events diff --git a/cli/azd/internal/appdetect/java_test.go b/cli/azd/internal/appdetect/java_test.go index c6df3de6daa..c448a2bcfa4 100644 --- a/cli/azd/internal/appdetect/java_test.go +++ b/cli/azd/internal/appdetect/java_test.go @@ -5,17 +5,26 @@ package appdetect import ( "context" + "fmt" "log/slog" "os" osexec "os/exec" "path/filepath" + "strings" "testing" + "time" "github.com/azure/azure-dev/cli/azd/pkg/exec" "github.com/azure/azure-dev/cli/azd/pkg/tools/maven" + "github.com/sethvargo/go-retry" ) func TestToMavenProject(t *testing.T) { + // Skip in short mode since this test requires network access to Maven Central + if testing.Short() { + t.Skip("Skipping Maven network-dependent test in short mode") + } + path, err := osexec.LookPath("java") if err != nil { t.Skip("Skip readMavenProject because java command doesn't exist.") @@ -358,7 +367,12 @@ func TestToMavenProject(t *testing.T) { testPom := tt.testPoms[0] pomFilePath := filepath.Join(workingDir, testPom.pomFilePath) - mavenProject, err := readMavenProject(context.TODO(), maven.NewCli(exec.NewCommandRunner(nil)), + // Use a timeout context to prevent hanging on network issues + ctx, cancel := context.WithTimeout(context.Background(), 2*time.Minute) + defer cancel() + + // Use retry logic for Maven operations due to potential network issues + mavenProject, err := readMavenProjectWithRetry(ctx, maven.NewCli(exec.NewCommandRunner(nil)), pomFilePath) if err != nil { t.Fatalf("readMavenProject failed: %v", err) @@ -377,6 +391,44 @@ func TestToMavenProject(t *testing.T) { } } +// readMavenProjectWithRetry wraps readMavenProject with retry logic to handle network issues +func readMavenProjectWithRetry(ctx context.Context, mvnCli *maven.Cli, filePath string) (*mavenProject, error) { + var mavenProject *mavenProject + var lastErr error + + err := retry.Do( + ctx, + retry.WithMaxRetries(3, retry.NewExponential(1*time.Second)), + func(ctx context.Context) error { + result, err := readMavenProject(ctx, mvnCli, filePath) + if err != nil { + // Check if error is likely network-related + errStr := strings.ToLower(err.Error()) + if strings.Contains(errStr, "connection") || + strings.Contains(errStr, "timeout") || + strings.Contains(errStr, "network") || + strings.Contains(errStr, "unknown host") || + strings.Contains(errStr, "could not resolve") || + strings.Contains(errStr, "transfer failed") { + lastErr = err + return retry.RetryableError(err) + } + // For non-network errors (parsing, etc.), fail immediately + return err + } + mavenProject = result + return nil + }, + ) + + if err != nil && lastErr != nil { + // If we retried but still failed, include context about retries + return nil, fmt.Errorf("maven operation failed after retries due to network issues: %w", lastErr) + } + + return mavenProject, err +} + type testPom struct { pomFilePath string pomContentString string diff --git a/cli/azd/internal/cmd/provision.go b/cli/azd/internal/cmd/provision.go index 5105166ca70..70593bea7e9 100644 --- a/cli/azd/internal/cmd/provision.go +++ b/cli/azd/internal/cmd/provision.go @@ -398,8 +398,9 @@ func (p *ProvisionAction) Run(ctx context.Context) (*actions.ActionResult, error for _, svc := range servicesStable { eventArgs := project.ServiceLifecycleEventArgs{ - Project: p.projectConfig, - Service: svc, + Project: p.projectConfig, + Service: svc, + ServiceContext: project.NewServiceContext(), Args: map[string]any{ "bicepOutput": deployResult.Deployment.Outputs, }, diff --git a/cli/azd/internal/grpcserver/event_service.go b/cli/azd/internal/grpcserver/event_service.go index 354dac9981c..336e602b913 100644 --- a/cli/azd/internal/grpcserver/event_service.go +++ b/cli/azd/internal/grpcserver/event_service.go @@ -160,7 +160,7 @@ func (s *eventService) createProjectEventHandler( defer s.syncExtensionOutput(ctx, extension, previewTitle)() // Send the invoke message. - if err := s.sendProjectInvokeMessage(stream, eventName, args.Project); err != nil { + if err := s.sendProjectInvokeMessage(stream, eventName, args); err != nil { return err } @@ -172,7 +172,7 @@ func (s *eventService) createProjectEventHandler( func (s *eventService) sendProjectInvokeMessage( stream grpc.BidiStreamingServer[azdext.EventMessage, azdext.EventMessage], eventName string, - proj *project.ProjectConfig, + args project.ProjectLifecycleEventArgs, ) error { resolver := noEnvResolver env, err := s.lazyEnv.GetValue() @@ -181,7 +181,7 @@ func (s *eventService) sendProjectInvokeMessage( } var protoProjectConfig *azdext.ProjectConfig - if err := mapper.WithResolver(resolver).Convert(proj, &protoProjectConfig); err != nil { + if err := mapper.WithResolver(resolver).Convert(args.Project, &protoProjectConfig); err != nil { return err } @@ -270,7 +270,7 @@ func (s *eventService) createServiceEventHandler( defer s.syncExtensionOutput(ctx, extension, previewTitle)() // Send the invoke message. - if err := s.sendServiceInvokeMessage(stream, eventName, args.Project, args.Service); err != nil { + if err := s.sendServiceInvokeMessage(stream, eventName, args); err != nil { return err } @@ -282,8 +282,7 @@ func (s *eventService) createServiceEventHandler( func (s *eventService) sendServiceInvokeMessage( stream grpc.BidiStreamingServer[azdext.EventMessage, azdext.EventMessage], eventName string, - proj *project.ProjectConfig, - svc *project.ServiceConfig, + args project.ServiceLifecycleEventArgs, ) error { resolver := noEnvResolver env, err := s.lazyEnv.GetValue() @@ -291,22 +290,30 @@ func (s *eventService) sendServiceInvokeMessage( resolver = env.Getenv } + objectMapper := mapper.WithResolver(resolver) + var protoProjectConfig *azdext.ProjectConfig - if err := mapper.WithResolver(resolver).Convert(proj, &protoProjectConfig); err != nil { + if err := objectMapper.Convert(args.Project, &protoProjectConfig); err != nil { return err } var protoServiceConfig *azdext.ServiceConfig - if err := mapper.WithResolver(resolver).Convert(svc, &protoServiceConfig); err != nil { + if err := objectMapper.Convert(args.Service, &protoServiceConfig); err != nil { + return err + } + + var protoServiceContext *azdext.ServiceContext + if err := objectMapper.Convert(args.ServiceContext, &protoServiceContext); err != nil { return err } return stream.Send(&azdext.EventMessage{ MessageType: &azdext.EventMessage_InvokeServiceHandler{ InvokeServiceHandler: &azdext.InvokeServiceHandler{ - EventName: eventName, - Project: protoProjectConfig, - Service: protoServiceConfig, + EventName: eventName, + Project: protoProjectConfig, + Service: protoServiceConfig, + ServiceContext: protoServiceContext, }, }, }) diff --git a/cli/azd/internal/grpcserver/event_service_test.go b/cli/azd/internal/grpcserver/event_service_test.go new file mode 100644 index 00000000000..e05a21ece22 --- /dev/null +++ b/cli/azd/internal/grpcserver/event_service_test.go @@ -0,0 +1,464 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package grpcserver + +import ( + "context" + "sync" + "testing" + + "github.com/azure/azure-dev/cli/azd/pkg/azdext" + "github.com/azure/azure-dev/cli/azd/pkg/environment" + "github.com/azure/azure-dev/cli/azd/pkg/ext" + "github.com/azure/azure-dev/cli/azd/pkg/extensions" + "github.com/azure/azure-dev/cli/azd/pkg/lazy" + "github.com/azure/azure-dev/cli/azd/pkg/project" + "github.com/azure/azure-dev/cli/azd/test/mocks/mockinput" + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "github.com/stretchr/testify/require" + "google.golang.org/grpc/metadata" +) + +// MockBidiStreamingServer mocks the gRPC bidirectional streaming server using generics +// Req represents the request message type, Resp represents the response message type +// In most cases they're the same (e.g., both *azdext.EventMessage), but the interface allows them to differ +// +// Usage examples: +// - For EventService: MockBidiStreamingServer[*azdext.EventMessage, *azdext.EventMessage] +// - For FrameworkService: MockBidiStreamingServer[*azdext.FrameworkServiceMessage, *azdext.FrameworkServiceMessage] +// - For ServiceTargetService: MockBidiStreamingServer[*azdext.ServiceTargetMessage, *azdext.ServiceTargetMessage] +// +// The generic design allows this mock to be reused across all gRPC services +// that use bidirectional streaming in the azd codebase. +type MockBidiStreamingServer[Req any, Resp any] struct { + mock.Mock + sentMessages []Resp + receivedMessages []Req + ctx context.Context +} + +func (m *MockBidiStreamingServer[Req, Resp]) Send(msg Resp) error { + args := m.Called(msg) + m.sentMessages = append(m.sentMessages, msg) + return args.Error(0) +} + +func (m *MockBidiStreamingServer[Req, Resp]) Recv() (Req, error) { + args := m.Called() + m.receivedMessages = append(m.receivedMessages, args.Get(0).(Req)) + return args.Get(0).(Req), args.Error(1) +} + +func (m *MockBidiStreamingServer[Req, Resp]) Context() context.Context { + if m.ctx != nil { + return m.ctx + } + return context.Background() +} + +func (m *MockBidiStreamingServer[Req, Resp]) SendMsg(msg interface{}) error { + args := m.Called(msg) + return args.Error(0) +} + +func (m *MockBidiStreamingServer[Req, Resp]) RecvMsg(msg interface{}) error { + args := m.Called(msg) + return args.Error(0) +} + +func (m *MockBidiStreamingServer[Req, Resp]) SetHeader(md metadata.MD) error { + args := m.Called(md) + return args.Error(0) +} + +func (m *MockBidiStreamingServer[Req, Resp]) SendHeader(md metadata.MD) error { + args := m.Called(md) + return args.Error(0) +} + +func (m *MockBidiStreamingServer[Req, Resp]) SetTrailer(md metadata.MD) { + m.Called(md) +} + +// Type aliases for convenience when working with specific message types +type MockEventStreamingServer = MockBidiStreamingServer[*azdext.EventMessage, *azdext.EventMessage] + +// Test helpers +func createTestEventService() (*eventService, *MockEventStreamingServer) { + mockStream := &MockEventStreamingServer{} + extensionManager := &extensions.Manager{} + + // Create lazy project with simple test config + lazyProject := lazy.NewLazy(func() (*project.ProjectConfig, error) { + projectConfig := &project.ProjectConfig{ + Name: "test-project", + Services: map[string]*project.ServiceConfig{ + "api": { + Name: "api", + Language: project.ServiceLanguageTypeScript, + Host: project.ContainerAppTarget, + RelativePath: "./api", + }, + "web": { + Name: "web", + Language: project.ServiceLanguageTypeScript, + Host: project.StaticWebAppTarget, + RelativePath: "./web", + }, + }, + } + // Initialize the event dispatcher + projectConfig.EventDispatcher = ext.NewEventDispatcher[project.ProjectLifecycleEventArgs]() + + // Initialize service event dispatchers + for _, serviceConfig := range projectConfig.Services { + serviceConfig.EventDispatcher = ext.NewEventDispatcher[project.ServiceLifecycleEventArgs]() + } + + return projectConfig, nil + }) + + // Create lazy environment + lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { + env := environment.NewWithValues("test-env", map[string]string{ + "AZURE_SUBSCRIPTION_ID": "test-sub-id", + "AZURE_LOCATION": "eastus2", + }) + return env, nil + }) + + console := mockinput.NewMockConsole() + + service := NewEventService(extensionManager, lazyProject, lazyEnv, console) + return service.(*eventService), mockStream +} + +func createTestExtension() *extensions.Extension { + return &extensions.Extension{ + Id: "test.extension", + DisplayName: "Test Extension", + Version: "1.0.0", + // Don't call Initialize() as it causes hangs in tests + } +} + +func TestEventService_handleSubscribeProjectEvent(t *testing.T) { + service, mockStream := createTestEventService() + extension := createTestExtension() + ctx := context.Background() + + tests := []struct { + name string + subscribeMsg *azdext.SubscribeProjectEvent + expectError bool + expectedEvents int + }{ + { + name: "subscribe to single event", + subscribeMsg: &azdext.SubscribeProjectEvent{ + EventNames: []string{"prepackage"}, + }, + expectError: false, + expectedEvents: 1, + }, + { + name: "subscribe to multiple events", + subscribeMsg: &azdext.SubscribeProjectEvent{ + EventNames: []string{"prepackage", "postpackage", "predeploy"}, + }, + expectError: false, + expectedEvents: 3, + }, + { + name: "subscribe to empty events", + subscribeMsg: &azdext.SubscribeProjectEvent{ + EventNames: []string{}, + }, + expectError: false, + expectedEvents: 0, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear previous events + service.projectEvents = sync.Map{} + + err := service.handleSubscribeProjectEvent(ctx, extension, tt.subscribeMsg, mockStream) + + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + // Verify correct number of events were stored + eventCount := 0 + service.projectEvents.Range(func(key, value interface{}) bool { + eventCount++ + + // Verify the key format: extension.eventName + keyStr := key.(string) + assert.Contains(t, keyStr, extension.Id) + + // Verify channel was created + ch := value.(chan *azdext.ProjectHandlerStatus) + assert.NotNil(t, ch) + assert.Equal(t, 1, cap(ch)) + + return true + }) + assert.Equal(t, tt.expectedEvents, eventCount) + }) + } +} + +func TestEventService_handleSubscribeServiceEvent(t *testing.T) { + service, mockStream := createTestEventService() + extension := createTestExtension() + ctx := context.Background() + + tests := []struct { + name string + subscribeMsg *azdext.SubscribeServiceEvent + expectError bool + expectedEvents int + }{ + { + name: "subscribe to service event for all services", + subscribeMsg: &azdext.SubscribeServiceEvent{ + EventNames: []string{"prepackage"}, + Language: "", // empty means all languages + Host: "", // empty means all hosts + }, + expectError: false, + expectedEvents: 2, // Two services in test config + }, + { + name: "subscribe to service event filtered by language", + subscribeMsg: &azdext.SubscribeServiceEvent{ + EventNames: []string{"prepackage"}, + Language: "ts", // TypeScript constant is "ts", not "typescript" + Host: "", + }, + expectError: false, + expectedEvents: 2, // Both services are TypeScript + }, + { + name: "subscribe to service event filtered by host", + subscribeMsg: &azdext.SubscribeServiceEvent{ + EventNames: []string{"prepackage"}, + Language: "", + Host: "containerapp", + }, + expectError: false, + expectedEvents: 1, // Only api service is containerapp + }, + { + name: "subscribe to service event with multiple filters", + subscribeMsg: &azdext.SubscribeServiceEvent{ + EventNames: []string{"prepackage"}, + Language: "ts", // TypeScript constant is "ts", not "typescript" + Host: "staticwebapp", + }, + expectError: false, + expectedEvents: 1, // Only web service matches both filters + }, + { + name: "subscribe to multiple service events", + subscribeMsg: &azdext.SubscribeServiceEvent{ + EventNames: []string{"prepackage", "postpackage"}, + Language: "", + Host: "", + }, + expectError: false, + expectedEvents: 4, // 2 events * 2 services + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + // Clear previous events + service.serviceEvents = sync.Map{} + + err := service.handleSubscribeServiceEvent(ctx, extension, tt.subscribeMsg, mockStream) + + if tt.expectError { + assert.Error(t, err) + return + } + + assert.NoError(t, err) + + // Verify correct number of events were stored + eventCount := 0 + service.serviceEvents.Range(func(key, value interface{}) bool { + eventCount++ + + // Verify the key format: extension.serviceName.eventName + keyStr := key.(string) + assert.Contains(t, keyStr, extension.Id) + + // Verify channel was created + ch := value.(chan *azdext.ServiceHandlerStatus) + assert.NotNil(t, ch) + assert.Equal(t, 1, cap(ch)) + + return true + }) + assert.Equal(t, tt.expectedEvents, eventCount) + }) + } +} + +func TestEventService_createProjectEventHandler(t *testing.T) { + service, mockStream := createTestEventService() + extension := createTestExtension() + eventName := "prepackage" + + // Create the handler + handler := service.createProjectEventHandler(mockStream, extension, eventName) + require.NotNil(t, handler) + + // Test that the handler function is created correctly + // We won't execute it since that would require complex async setup + assert.NotNil(t, handler) +} + +func TestEventService_createServiceEventHandler(t *testing.T) { + service, mockStream := createTestEventService() + extension := createTestExtension() + eventName := "prepackage" + + serviceConfig := &project.ServiceConfig{ + Name: "test-service", + Language: project.ServiceLanguageTypeScript, + Host: project.ContainerAppTarget, + RelativePath: "./test-service", + } + + // Create the handler + handler := service.createServiceEventHandler(mockStream, serviceConfig, extension, eventName) + require.NotNil(t, handler) + + // Test that the handler function is created correctly + // We won't execute it since that would require complex async setup + assert.NotNil(t, handler) +} + +func TestEventService_sendProjectInvokeMessage(t *testing.T) { + service, mockStream := createTestEventService() + eventName := "prepackage" + + // Setup mock expectations - capture the sent message + var sentMessage *azdext.EventMessage + mockStream.On("Send", mock.AnythingOfType("*azdext.EventMessage")).Run(func(args mock.Arguments) { + sentMessage = args.Get(0).(*azdext.EventMessage) + }).Return(nil) + + // Create test project lifecycle event args + args := project.ProjectLifecycleEventArgs{ + Project: &project.ProjectConfig{ + Name: "test-project", + }, + Args: map[string]any{}, + } + + // Execute the method + err := service.sendProjectInvokeMessage(mockStream, eventName, args) + + // Verify no error occurred + assert.NoError(t, err) + + // Verify the message was sent + mockStream.AssertCalled(t, "Send", mock.AnythingOfType("*azdext.EventMessage")) + + // Verify the message structure + require.NotNil(t, sentMessage) + require.NotNil(t, sentMessage.GetInvokeProjectHandler()) + + invokeMsg := sentMessage.GetInvokeProjectHandler() + assert.Equal(t, eventName, invokeMsg.EventName) + assert.NotNil(t, invokeMsg.Project) + assert.Equal(t, "test-project", invokeMsg.Project.Name) +} + +func TestEventService_sendServiceInvokeMessage(t *testing.T) { + service, mockStream := createTestEventService() + eventName := "prepackage" + + // Setup mock expectations - capture the sent message + var sentMessage *azdext.EventMessage + mockStream.On("Send", mock.AnythingOfType("*azdext.EventMessage")).Run(func(args mock.Arguments) { + sentMessage = args.Get(0).(*azdext.EventMessage) + }).Return(nil) + + serviceConfig := &project.ServiceConfig{ + Name: "test-service", + Language: project.ServiceLanguageTypeScript, + Host: project.ContainerAppTarget, + RelativePath: "./test-service", + } + + // Create test service lifecycle event args with ServiceContext + args := project.ServiceLifecycleEventArgs{ + Project: &project.ProjectConfig{ + Name: "test-project", + }, + Service: serviceConfig, + ServiceContext: project.NewServiceContext(), + Args: map[string]any{}, + } + + // Execute the method + err := service.sendServiceInvokeMessage(mockStream, eventName, args) + + // Verify no error occurred + assert.NoError(t, err) + + // Verify the message was sent + mockStream.AssertCalled(t, "Send", mock.AnythingOfType("*azdext.EventMessage")) + + // Verify the message structure + require.NotNil(t, sentMessage) + require.NotNil(t, sentMessage.GetInvokeServiceHandler()) + + invokeMsg := sentMessage.GetInvokeServiceHandler() + assert.Equal(t, eventName, invokeMsg.EventName) + assert.NotNil(t, invokeMsg.Project) + assert.Equal(t, "test-project", invokeMsg.Project.Name) + assert.NotNil(t, invokeMsg.Service) + assert.Equal(t, "test-service", invokeMsg.Service.Name) + + // Verify ServiceContext was included + assert.NotNil(t, invokeMsg.ServiceContext) +} + +func TestEventService_New(t *testing.T) { + extensionManager := &extensions.Manager{} + + lazyProject := lazy.NewLazy(func() (*project.ProjectConfig, error) { + return &project.ProjectConfig{Name: "test"}, nil + }) + + lazyEnv := lazy.NewLazy(func() (*environment.Environment, error) { + return environment.NewWithValues("test", map[string]string{}), nil + }) + + console := mockinput.NewMockConsole() + + service := NewEventService(extensionManager, lazyProject, lazyEnv, console) + + assert.NotNil(t, service) + + // Verify type assertion works + eventSvc, ok := service.(*eventService) + assert.True(t, ok) + assert.NotNil(t, eventSvc.extensionManager) + assert.NotNil(t, eventSvc.lazyProject) + assert.NotNil(t, eventSvc.lazyEnv) + assert.NotNil(t, eventSvc.console) +} diff --git a/cli/azd/pkg/azdext/account.pb.go b/cli/azd/pkg/azdext/account.pb.go index c8052df99a4..1ea407e0f32 100644 --- a/cli/azd/pkg/azdext/account.pb.go +++ b/cli/azd/pkg/azdext/account.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: account.proto diff --git a/cli/azd/pkg/azdext/compose.pb.go b/cli/azd/pkg/azdext/compose.pb.go index 38cc923a486..b5bc60ae3b5 100644 --- a/cli/azd/pkg/azdext/compose.pb.go +++ b/cli/azd/pkg/azdext/compose.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: compose.proto diff --git a/cli/azd/pkg/azdext/container.pb.go b/cli/azd/pkg/azdext/container.pb.go index bfcd28da18c..55d9d8cbaaa 100644 --- a/cli/azd/pkg/azdext/container.pb.go +++ b/cli/azd/pkg/azdext/container.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: container.proto diff --git a/cli/azd/pkg/azdext/deployment.pb.go b/cli/azd/pkg/azdext/deployment.pb.go index 7a0c7ff92f8..5ce0f2b14c8 100644 --- a/cli/azd/pkg/azdext/deployment.pb.go +++ b/cli/azd/pkg/azdext/deployment.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: deployment.proto diff --git a/cli/azd/pkg/azdext/environment.pb.go b/cli/azd/pkg/azdext/environment.pb.go index c24f31c65b5..465da667245 100644 --- a/cli/azd/pkg/azdext/environment.pb.go +++ b/cli/azd/pkg/azdext/environment.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: environment.proto diff --git a/cli/azd/pkg/azdext/event.pb.go b/cli/azd/pkg/azdext/event.pb.go index c9f3fecfc3d..62a3c2f4778 100644 --- a/cli/azd/pkg/azdext/event.pb.go +++ b/cli/azd/pkg/azdext/event.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: event.proto @@ -412,9 +412,11 @@ type InvokeServiceHandler struct { // Current project configuration. Project *ProjectConfig `protobuf:"bytes,2,opt,name=project,proto3" json:"project,omitempty"` // Specific service configuration. - Service *ServiceConfig `protobuf:"bytes,3,opt,name=service,proto3" json:"service,omitempty"` - unknownFields protoimpl.UnknownFields - sizeCache protoimpl.SizeCache + Service *ServiceConfig `protobuf:"bytes,3,opt,name=service,proto3" json:"service,omitempty"` + // Service context with artifacts from all lifecycle phases. + ServiceContext *ServiceContext `protobuf:"bytes,4,opt,name=service_context,json=serviceContext,proto3" json:"service_context,omitempty"` + unknownFields protoimpl.UnknownFields + sizeCache protoimpl.SizeCache } func (x *InvokeServiceHandler) Reset() { @@ -468,6 +470,13 @@ func (x *InvokeServiceHandler) GetService() *ServiceConfig { return nil } +func (x *InvokeServiceHandler) GetServiceContext() *ServiceContext { + if x != nil { + return x.ServiceContext + } + return nil +} + // Client sends status updates for project events type ProjectHandlerStatus struct { state protoimpl.MessageState `protogen:"open.v1"` @@ -633,12 +642,13 @@ const file_event_proto_rawDesc = "" + "\x14InvokeProjectHandler\x12\x1d\n" + "\n" + "event_name\x18\x01 \x01(\tR\teventName\x12/\n" + - "\aproject\x18\x02 \x01(\v2\x15.azdext.ProjectConfigR\aproject\"\x97\x01\n" + + "\aproject\x18\x02 \x01(\v2\x15.azdext.ProjectConfigR\aproject\"\xd8\x01\n" + "\x14InvokeServiceHandler\x12\x1d\n" + "\n" + "event_name\x18\x01 \x01(\tR\teventName\x12/\n" + "\aproject\x18\x02 \x01(\v2\x15.azdext.ProjectConfigR\aproject\x12/\n" + - "\aservice\x18\x03 \x01(\v2\x15.azdext.ServiceConfigR\aservice\"g\n" + + "\aservice\x18\x03 \x01(\v2\x15.azdext.ServiceConfigR\aservice\x12?\n" + + "\x0fservice_context\x18\x04 \x01(\v2\x16.azdext.ServiceContextR\x0eserviceContext\"g\n" + "\x14ProjectHandlerStatus\x12\x1d\n" + "\n" + "event_name\x18\x01 \x01(\tR\teventName\x12\x16\n" + @@ -677,6 +687,7 @@ var file_event_proto_goTypes = []any{ (*ServiceHandlerStatus)(nil), // 7: azdext.ServiceHandlerStatus (*ProjectConfig)(nil), // 8: azdext.ProjectConfig (*ServiceConfig)(nil), // 9: azdext.ServiceConfig + (*ServiceContext)(nil), // 10: azdext.ServiceContext } var file_event_proto_depIdxs = []int32{ 2, // 0: azdext.EventMessage.subscribe_project_event:type_name -> azdext.SubscribeProjectEvent @@ -689,13 +700,14 @@ var file_event_proto_depIdxs = []int32{ 8, // 7: azdext.InvokeProjectHandler.project:type_name -> azdext.ProjectConfig 8, // 8: azdext.InvokeServiceHandler.project:type_name -> azdext.ProjectConfig 9, // 9: azdext.InvokeServiceHandler.service:type_name -> azdext.ServiceConfig - 0, // 10: azdext.EventService.EventStream:input_type -> azdext.EventMessage - 0, // 11: azdext.EventService.EventStream:output_type -> azdext.EventMessage - 11, // [11:12] is the sub-list for method output_type - 10, // [10:11] is the sub-list for method input_type - 10, // [10:10] is the sub-list for extension type_name - 10, // [10:10] is the sub-list for extension extendee - 0, // [0:10] is the sub-list for field type_name + 10, // 10: azdext.InvokeServiceHandler.service_context:type_name -> azdext.ServiceContext + 0, // 11: azdext.EventService.EventStream:input_type -> azdext.EventMessage + 0, // 12: azdext.EventService.EventStream:output_type -> azdext.EventMessage + 12, // [12:13] is the sub-list for method output_type + 11, // [11:12] is the sub-list for method input_type + 11, // [11:11] is the sub-list for extension type_name + 11, // [11:11] is the sub-list for extension extendee + 0, // [0:11] is the sub-list for field type_name } func init() { file_event_proto_init() } diff --git a/cli/azd/pkg/azdext/event_manager.go b/cli/azd/pkg/azdext/event_manager.go index 69646e4d3f1..4eb7a55c6bb 100644 --- a/cli/azd/pkg/azdext/event_manager.go +++ b/cli/azd/pkg/azdext/event_manager.go @@ -26,8 +26,9 @@ type ProjectEventArgs struct { } type ServiceEventArgs struct { - Project *ProjectConfig - Service *ServiceConfig + Project *ProjectConfig + Service *ServiceConfig + ServiceContext *ServiceContext } type ProjectEventHandler func(ctx context.Context, args *ProjectEventArgs) error @@ -131,7 +132,7 @@ func (em *EventManager) AddProjectEventHandler(ctx context.Context, eventName st return err } -type ServerEventOptions struct { +type ServiceEventOptions struct { Host string Language string } @@ -140,14 +141,14 @@ func (em *EventManager) AddServiceEventHandler( ctx context.Context, eventName string, handler ServiceEventHandler, - options *ServerEventOptions, + options *ServiceEventOptions, ) error { if err := em.init(ctx); err != nil { return err } if options == nil { - options = &ServerEventOptions{} + options = &ServiceEventOptions{} } err := em.stream.Send(&EventMessage{ @@ -241,9 +242,16 @@ func (em *EventManager) invokeServiceHandler(ctx context.Context, invokeMsg *Inv return nil } + // Extract ServiceContext from the message, default to empty instance if nil + serviceContext := invokeMsg.ServiceContext + if serviceContext == nil { + serviceContext = &ServiceContext{} + } + args := &ServiceEventArgs{ - Project: invokeMsg.Project, - Service: invokeMsg.Service, + Project: invokeMsg.Project, + Service: invokeMsg.Service, + ServiceContext: serviceContext, } status := "completed" diff --git a/cli/azd/pkg/azdext/event_manager_test.go b/cli/azd/pkg/azdext/event_manager_test.go new file mode 100644 index 00000000000..c98677e751b --- /dev/null +++ b/cli/azd/pkg/azdext/event_manager_test.go @@ -0,0 +1,466 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package azdext + +import ( + "context" + "errors" + "testing" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/mock" + "google.golang.org/grpc/metadata" +) + +// MockBidiStreamingClient mocks the gRPC bidirectional streaming client using generics +// Req represents the request message type, Resp represents the response message type +// In most cases they're the same (e.g., both *EventMessage), but the interface allows them to differ +// +// Usage examples: +// - For EventService: MockBidiStreamingClient[*EventMessage, *EventMessage] +// - For FrameworkService: MockBidiStreamingClient[*FrameworkServiceMessage, *FrameworkServiceMessage] +// - For ServiceTargetService: MockBidiStreamingClient[*ServiceTargetMessage, *ServiceTargetMessage] +// +// The generic design allows this mock to be reused across all gRPC services +// that use bidirectional streaming in the azd codebase. +type MockBidiStreamingClient[Req any, Resp any] struct { + mock.Mock + sentMessages []Req + receivedMessages []Resp +} + +func (m *MockBidiStreamingClient[Req, Resp]) Send(msg Req) error { + args := m.Called(msg) + m.sentMessages = append(m.sentMessages, msg) + return args.Error(0) +} + +func (m *MockBidiStreamingClient[Req, Resp]) Recv() (Resp, error) { + args := m.Called() + if len(args) > 0 && args.Get(0) != nil { + m.receivedMessages = append(m.receivedMessages, args.Get(0).(Resp)) + return args.Get(0).(Resp), args.Error(1) + } + var zero Resp + return zero, args.Error(1) +} + +func (m *MockBidiStreamingClient[Req, Resp]) CloseSend() error { + args := m.Called() + return args.Error(0) +} + +func (m *MockBidiStreamingClient[Req, Resp]) SendMsg(msg interface{}) error { + args := m.Called(msg) + return args.Error(0) +} + +func (m *MockBidiStreamingClient[Req, Resp]) RecvMsg(msg interface{}) error { + args := m.Called(msg) + return args.Error(0) +} + +func (m *MockBidiStreamingClient[Req, Resp]) Header() (metadata.MD, error) { + args := m.Called() + return args.Get(0).(metadata.MD), args.Error(1) +} + +func (m *MockBidiStreamingClient[Req, Resp]) Trailer() metadata.MD { + args := m.Called() + return args.Get(0).(metadata.MD) +} + +func (m *MockBidiStreamingClient[Req, Resp]) Context() context.Context { + args := m.Called() + return args.Get(0).(context.Context) +} + +// Helper methods for tests +func (m *MockBidiStreamingClient[Req, Resp]) GetSentMessages() []Req { + return m.sentMessages +} + +func (m *MockBidiStreamingClient[Req, Resp]) GetReceivedMessages() []Resp { + return m.receivedMessages +} + +// Test helper functions +func createTestProjectConfigForEvents() *ProjectConfig { + return &ProjectConfig{ + Name: "test-project", + Path: "/test/path", + } +} + +func createTestServiceConfigForEvents() *ServiceConfig { + return &ServiceConfig{ + Name: "test-service", + Host: "containerapp", + } +} + +func createTestServiceContextForEvents() *ServiceContext { + return &ServiceContext{ + Package: []*Artifact{ + { + Kind: ArtifactKind_ARTIFACT_KIND_CONTAINER, + Location: "/test/package/path", + Metadata: map[string]string{ + "name": "test-package", + "language": "go", + }, + }, + }, + } +} + +// Test EventManager creation +func TestNewEventManager(t *testing.T) { + // Create a real AzdClient (without connection) + client := &AzdClient{} + + eventManager := NewEventManager(client) + + assert.NotNil(t, eventManager) + assert.Equal(t, client, eventManager.azdClient) + assert.NotNil(t, eventManager.projectEvents) + assert.NotNil(t, eventManager.serviceEvents) + assert.Empty(t, eventManager.projectEvents) + assert.Empty(t, eventManager.serviceEvents) +} + +// Test invokeProjectHandler with successful handler +func TestEventManager_invokeProjectHandler_Success(t *testing.T) { + ctx := context.Background() + client := &AzdClient{} + mockStream := &MockBidiStreamingClient[*EventMessage, *EventMessage]{} + + // Setup mock expectation for status message + mockStream.On("Send", mock.MatchedBy(func(msg *EventMessage) bool { + // Verify the project handler status message + if status := msg.GetProjectHandlerStatus(); status != nil { + return status.EventName == "prerestore" && + status.Status == "completed" && + status.Message == "" + } + return false + })).Return(nil) + + eventManager := NewEventManager(client) + eventManager.stream = mockStream + + // Add a test handler + handlerCalled := false + var receivedArgs *ProjectEventArgs + handler := func(ctx context.Context, args *ProjectEventArgs) error { + handlerCalled = true + receivedArgs = args + return nil + } + eventManager.projectEvents["prerestore"] = handler + + // Create invoke message + invokeMsg := &InvokeProjectHandler{ + EventName: "prerestore", + Project: createTestProjectConfigForEvents(), + } + + // Invoke the handler + err := eventManager.invokeProjectHandler(ctx, invokeMsg) + + assert.NoError(t, err) + assert.True(t, handlerCalled) + assert.NotNil(t, receivedArgs) + assert.Equal(t, "test-project", receivedArgs.Project.Name) + + mockStream.AssertExpectations(t) +} + +// Test invokeProjectHandler with handler error +func TestEventManager_invokeProjectHandler_HandlerError(t *testing.T) { + ctx := context.Background() + client := &AzdClient{} + mockStream := &MockBidiStreamingClient[*EventMessage, *EventMessage]{} + + // Setup mock expectation for error status message + mockStream.On("Send", mock.MatchedBy(func(msg *EventMessage) bool { + // Verify the project handler status message shows failure + if status := msg.GetProjectHandlerStatus(); status != nil { + return status.EventName == "postbuild" && + status.Status == "failed" && + status.Message == "handler failed" + } + return false + })).Return(nil) + + eventManager := NewEventManager(client) + eventManager.stream = mockStream + + // Add a test handler that fails + handler := func(ctx context.Context, args *ProjectEventArgs) error { + return errors.New("handler failed") + } + eventManager.projectEvents["postbuild"] = handler + + // Create invoke message + invokeMsg := &InvokeProjectHandler{ + EventName: "postbuild", + Project: createTestProjectConfigForEvents(), + } + + // Invoke the handler + err := eventManager.invokeProjectHandler(ctx, invokeMsg) + + assert.NoError(t, err) // invokeProjectHandler doesn't return handler errors + + mockStream.AssertExpectations(t) +} + +// Test invokeProjectHandler with no registered handler +func TestEventManager_invokeProjectHandler_NoHandler(t *testing.T) { + ctx := context.Background() + client := &AzdClient{} + eventManager := NewEventManager(client) + + // Create invoke message for unregistered event + invokeMsg := &InvokeProjectHandler{ + EventName: "nonexistentevent", + Project: createTestProjectConfigForEvents(), + } + + // Invoke should be a no-op + err := eventManager.invokeProjectHandler(ctx, invokeMsg) + + assert.NoError(t, err) +} + +// Test invokeServiceHandler with successful handler +func TestEventManager_invokeServiceHandler_Success(t *testing.T) { + ctx := context.Background() + client := &AzdClient{} + mockStream := &MockBidiStreamingClient[*EventMessage, *EventMessage]{} + + // Setup mock expectation for status message + mockStream.On("Send", mock.MatchedBy(func(msg *EventMessage) bool { + // Verify the service handler status message + if status := msg.GetServiceHandlerStatus(); status != nil { + return status.EventName == "prepackage" && + status.ServiceName == "test-service" && + status.Status == "completed" && + status.Message == "" + } + return false + })).Return(nil) + + eventManager := NewEventManager(client) + eventManager.stream = mockStream + + // Add a test handler + handlerCalled := false + var receivedArgs *ServiceEventArgs + handler := func(ctx context.Context, args *ServiceEventArgs) error { + handlerCalled = true + receivedArgs = args + return nil + } + eventManager.serviceEvents["prepackage"] = handler + + // Create invoke message with ServiceContext + invokeMsg := &InvokeServiceHandler{ + EventName: "prepackage", + Project: createTestProjectConfigForEvents(), + Service: createTestServiceConfigForEvents(), + ServiceContext: createTestServiceContextForEvents(), + } + + // Invoke the handler + err := eventManager.invokeServiceHandler(ctx, invokeMsg) + + assert.NoError(t, err) + assert.True(t, handlerCalled) + assert.NotNil(t, receivedArgs) + assert.Equal(t, "test-project", receivedArgs.Project.Name) + assert.Equal(t, "test-service", receivedArgs.Service.Name) + assert.NotNil(t, receivedArgs.ServiceContext) + assert.NotNil(t, receivedArgs.ServiceContext.Package) + assert.Len(t, receivedArgs.ServiceContext.Package, 1) + assert.Equal(t, "test-package", receivedArgs.ServiceContext.Package[0].Metadata["name"]) + + mockStream.AssertExpectations(t) +} + +// Test invokeServiceHandler with nil ServiceContext (should default to empty) +func TestEventManager_invokeServiceHandler_NilServiceContext(t *testing.T) { + ctx := context.Background() + client := &AzdClient{} + mockStream := &MockBidiStreamingClient[*EventMessage, *EventMessage]{} + + // Setup mock expectation for status message + mockStream.On("Send", mock.MatchedBy(func(msg *EventMessage) bool { + if status := msg.GetServiceHandlerStatus(); status != nil { + return status.EventName == "postdeploy" && + status.ServiceName == "test-service" && + status.Status == "completed" + } + return false + })).Return(nil) + + eventManager := NewEventManager(client) + eventManager.stream = mockStream + + // Add a test handler + var receivedArgs *ServiceEventArgs + handler := func(ctx context.Context, args *ServiceEventArgs) error { + receivedArgs = args + return nil + } + eventManager.serviceEvents["postdeploy"] = handler + + // Create invoke message with nil ServiceContext + invokeMsg := &InvokeServiceHandler{ + EventName: "postdeploy", + Project: createTestProjectConfigForEvents(), + Service: createTestServiceConfigForEvents(), + ServiceContext: nil, // nil context + } + + // Invoke the handler + err := eventManager.invokeServiceHandler(ctx, invokeMsg) + + assert.NoError(t, err) + assert.NotNil(t, receivedArgs) + assert.NotNil(t, receivedArgs.ServiceContext) // Should be defaulted to empty instance + + mockStream.AssertExpectations(t) +} + +// Test invokeServiceHandler with handler error +func TestEventManager_invokeServiceHandler_HandlerError(t *testing.T) { + ctx := context.Background() + client := &AzdClient{} + mockStream := &MockBidiStreamingClient[*EventMessage, *EventMessage]{} + + // Setup mock expectation for error status message + mockStream.On("Send", mock.MatchedBy(func(msg *EventMessage) bool { + // Verify the service handler status message shows failure + if status := msg.GetServiceHandlerStatus(); status != nil { + return status.EventName == "prepublish" && + status.ServiceName == "test-service" && + status.Status == "failed" && + status.Message == "service handler failed" + } + return false + })).Return(nil) + + eventManager := NewEventManager(client) + eventManager.stream = mockStream + + // Add a test handler that fails + handler := func(ctx context.Context, args *ServiceEventArgs) error { + return errors.New("service handler failed") + } + eventManager.serviceEvents["prepublish"] = handler + + // Create invoke message + invokeMsg := &InvokeServiceHandler{ + EventName: "prepublish", + Project: createTestProjectConfigForEvents(), + Service: createTestServiceConfigForEvents(), + ServiceContext: createTestServiceContextForEvents(), + } + + // Invoke the handler + err := eventManager.invokeServiceHandler(ctx, invokeMsg) + + assert.NoError(t, err) // invokeServiceHandler doesn't return handler errors + + mockStream.AssertExpectations(t) +} + +// Test invokeServiceHandler with no registered handler +func TestEventManager_invokeServiceHandler_NoHandler(t *testing.T) { + ctx := context.Background() + client := &AzdClient{} + eventManager := NewEventManager(client) + + // Create invoke message for unregistered event + invokeMsg := &InvokeServiceHandler{ + EventName: "nonexistentevent", + Project: createTestProjectConfigForEvents(), + Service: createTestServiceConfigForEvents(), + ServiceContext: createTestServiceContextForEvents(), + } + + // Invoke should be a no-op + err := eventManager.invokeServiceHandler(ctx, invokeMsg) + + assert.NoError(t, err) +} + +// Test RemoveProjectEventHandler +func TestEventManager_RemoveProjectEventHandler(t *testing.T) { + client := &AzdClient{} + eventManager := NewEventManager(client) + + // Add a handler + handler := func(ctx context.Context, args *ProjectEventArgs) error { + return nil + } + eventManager.projectEvents["preprovision"] = handler + + // Verify it's there + assert.Contains(t, eventManager.projectEvents, "preprovision") + + // Remove it + eventManager.RemoveProjectEventHandler("preprovision") + + // Verify it's gone + assert.NotContains(t, eventManager.projectEvents, "preprovision") +} + +// Test RemoveServiceEventHandler +func TestEventManager_RemoveServiceEventHandler(t *testing.T) { + client := &AzdClient{} + eventManager := NewEventManager(client) + + // Add a handler + handler := func(ctx context.Context, args *ServiceEventArgs) error { + return nil + } + eventManager.serviceEvents["postpackage"] = handler + + // Verify it's there + assert.Contains(t, eventManager.serviceEvents, "postpackage") + + // Remove it + eventManager.RemoveServiceEventHandler("postpackage") + + // Verify it's gone + assert.NotContains(t, eventManager.serviceEvents, "postpackage") +} + +// Test Close +func TestEventManager_Close(t *testing.T) { + mockStream := &MockBidiStreamingClient[*EventMessage, *EventMessage]{} + mockStream.On("CloseSend").Return(nil) + + client := &AzdClient{} + eventManager := NewEventManager(client) + eventManager.stream = mockStream + + err := eventManager.Close() + + assert.NoError(t, err) + mockStream.AssertExpectations(t) +} + +// Test Close with nil stream +func TestEventManager_Close_NilStream(t *testing.T) { + client := &AzdClient{} + eventManager := NewEventManager(client) + + err := eventManager.Close() + + assert.NoError(t, err) +} diff --git a/cli/azd/pkg/azdext/extension.pb.go b/cli/azd/pkg/azdext/extension.pb.go index 2803be57a9d..69494a2cff9 100644 --- a/cli/azd/pkg/azdext/extension.pb.go +++ b/cli/azd/pkg/azdext/extension.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: extension.proto diff --git a/cli/azd/pkg/azdext/extension_host.go b/cli/azd/pkg/azdext/extension_host.go index 5517ac30fc4..54f1809058b 100644 --- a/cli/azd/pkg/azdext/extension_host.go +++ b/cli/azd/pkg/azdext/extension_host.go @@ -25,7 +25,7 @@ type frameworkServiceRegistrar interface { type extensionEventManager interface { AddProjectEventHandler(ctx context.Context, eventName string, handler ProjectEventHandler) error AddServiceEventHandler( - ctx context.Context, eventName string, handler ServiceEventHandler, options *ServerEventOptions, + ctx context.Context, eventName string, handler ServiceEventHandler, options *ServiceEventOptions, ) error Receive(ctx context.Context) error Close() error @@ -53,7 +53,7 @@ type ProjectEventRegistration struct { type ServiceEventRegistration struct { EventName string Handler ServiceEventHandler - Options *ServerEventOptions + Options *ServiceEventOptions } // ProviderFactory describes a function that creates a provider instance @@ -121,7 +121,7 @@ func (er *ExtensionHost) WithProjectEventHandler(eventName string, handler Proje func (er *ExtensionHost) WithServiceEventHandler( eventName string, handler ServiceEventHandler, - options *ServerEventOptions, + options *ServiceEventOptions, ) *ExtensionHost { er.serviceHandlers = append(er.serviceHandlers, ServiceEventRegistration{ EventName: eventName, diff --git a/cli/azd/pkg/azdext/extension_host_test.go b/cli/azd/pkg/azdext/extension_host_test.go index a97bc553193..b923ff4312b 100644 --- a/cli/azd/pkg/azdext/extension_host_test.go +++ b/cli/azd/pkg/azdext/extension_host_test.go @@ -82,7 +82,7 @@ func (m *MockExtensionEventManager) AddServiceEventHandler( ctx context.Context, eventName string, handler ServiceEventHandler, - options *ServerEventOptions, + options *ServiceEventOptions, ) error { args := m.Called(ctx, eventName, handler, options) return args.Error(0) @@ -217,7 +217,7 @@ func TestExtensionHost_EventHandlersOnly(t *testing.T) { mockEventManager := &MockExtensionEventManager{} mockEventManager.On("AddProjectEventHandler", mock.Anything, "preprovision", mock.Anything).Return(nil) mockEventManager.On("AddServiceEventHandler", mock.Anything, "prepackage", mock.Anything, - (*ServerEventOptions)(nil)).Return(nil) + (*ServiceEventOptions)(nil)).Return(nil) mockEventManager.On("Receive", mock.Anything).Return(nil) mockEventManager.On("Close").Return(nil) diff --git a/cli/azd/pkg/azdext/framework_service.pb.go b/cli/azd/pkg/azdext/framework_service.pb.go index 2cf87881694..24b9c048c3c 100644 --- a/cli/azd/pkg/azdext/framework_service.pb.go +++ b/cli/azd/pkg/azdext/framework_service.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: framework_service.proto diff --git a/cli/azd/pkg/azdext/models.pb.go b/cli/azd/pkg/azdext/models.pb.go index 0bff5e22795..2b947bb9df3 100644 --- a/cli/azd/pkg/azdext/models.pb.go +++ b/cli/azd/pkg/azdext/models.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: models.proto diff --git a/cli/azd/pkg/azdext/project.pb.go b/cli/azd/pkg/azdext/project.pb.go index 7e6f57de109..5dc524bc5f2 100644 --- a/cli/azd/pkg/azdext/project.pb.go +++ b/cli/azd/pkg/azdext/project.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: project.proto diff --git a/cli/azd/pkg/azdext/prompt.pb.go b/cli/azd/pkg/azdext/prompt.pb.go index 15ce8cee24e..8330bd6808d 100644 --- a/cli/azd/pkg/azdext/prompt.pb.go +++ b/cli/azd/pkg/azdext/prompt.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: prompt.proto diff --git a/cli/azd/pkg/azdext/service_target.pb.go b/cli/azd/pkg/azdext/service_target.pb.go index 7ba77ef1f19..6d77c65d44d 100644 --- a/cli/azd/pkg/azdext/service_target.pb.go +++ b/cli/azd/pkg/azdext/service_target.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: service_target.proto diff --git a/cli/azd/pkg/azdext/user_config.pb.go b/cli/azd/pkg/azdext/user_config.pb.go index 238ac48baad..3b093f24f04 100644 --- a/cli/azd/pkg/azdext/user_config.pb.go +++ b/cli/azd/pkg/azdext/user_config.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: user_config.proto diff --git a/cli/azd/pkg/azdext/workflow.pb.go b/cli/azd/pkg/azdext/workflow.pb.go index f1d7fb941ca..e4f0a55bc0a 100644 --- a/cli/azd/pkg/azdext/workflow.pb.go +++ b/cli/azd/pkg/azdext/workflow.pb.go @@ -3,7 +3,7 @@ // Code generated by protoc-gen-go. DO NOT EDIT. // versions: -// protoc-gen-go v1.36.9 +// protoc-gen-go v1.36.10 // protoc v6.32.1 // source: workflow.proto diff --git a/cli/azd/pkg/ext/event_dispatcher.go b/cli/azd/pkg/ext/event_dispatcher.go index dfe6d0692ab..b77c3501396 100644 --- a/cli/azd/pkg/ext/event_dispatcher.go +++ b/cli/azd/pkg/ext/event_dispatcher.go @@ -7,6 +7,7 @@ import ( "context" "errors" "fmt" + "log" "strings" "sync" @@ -51,6 +52,16 @@ func (ed *EventDispatcher[T]) AddHandler(ctx context.Context, name Event, handle defer ed.mu.Unlock() events := ed.handlers[name] + + // Check if this handler is already registered for this event + handlerStr := fmt.Sprintf("%v", handler) + for _, existingHandler := range events { + if fmt.Sprintf("%v", existingHandler) == handlerStr { + log.Printf("Warning: Handler for event '%s' is already registered, skipping duplicate registration", name) + return nil + } + } + events = append(events, handler) ed.handlers[name] = events diff --git a/cli/azd/pkg/ext/event_dispatcher_test.go b/cli/azd/pkg/ext/event_dispatcher_test.go index 3ffe205b39f..d0574c65dec 100644 --- a/cli/azd/pkg/ext/event_dispatcher_test.go +++ b/cli/azd/pkg/ext/event_dispatcher_test.go @@ -240,6 +240,140 @@ func Test_Already_Cancelled_Context_Handler_Cleanup(t *testing.T) { require.Equal(t, 0, remainingHandlers, "Handler should be automatically removed for already cancelled context") } +func Test_Duplicate_Handler_Detection(t *testing.T) { + callCount := 0 + handler := func(ctx context.Context, args testEventArgs) error { + callCount++ + return nil + } + + ed := NewEventDispatcher[testEventArgs](testEvent) + + // First registration should succeed + err := ed.AddHandler(context.Background(), testEvent, handler) + require.NoError(t, err) + + // Second registration of same handler should log warning and skip + err = ed.AddHandler(context.Background(), testEvent, handler) + require.NoError(t, err) + + // Verify handler was not added again by checking the handler count + ed.mu.RLock() + handlerCount := len(ed.handlers[testEvent]) + ed.mu.RUnlock() + require.Equal(t, 1, handlerCount, "Should have only 1 handler, not 2") + + // Verify handler is only called once when event is raised + err = ed.RaiseEvent(context.Background(), testEvent, testEventArgs{}) + require.NoError(t, err) + require.Equal(t, 1, callCount, "Handler should be called only once, not twice") +} + +func Test_Duplicate_Handler_Detection_Different_Events(t *testing.T) { + callCount := 0 + handler := func(ctx context.Context, args testEventArgs) error { + callCount++ + return nil + } + + // Create dispatcher with multiple valid events + event1 := Event("event1") + event2 := Event("event2") + ed := NewEventDispatcher[testEventArgs](event1, event2) + + // Register same handler for different events - should succeed both times + err := ed.AddHandler(context.Background(), event1, handler) + require.NoError(t, err) + + err = ed.AddHandler(context.Background(), event2, handler) + require.NoError(t, err) + + // Verify both events have the handler registered + ed.mu.RLock() + handler1Count := len(ed.handlers[event1]) + handler2Count := len(ed.handlers[event2]) + ed.mu.RUnlock() + + require.Equal(t, 1, handler1Count, "Event1 should have 1 handler") + require.Equal(t, 1, handler2Count, "Event2 should have 1 handler") + + // Both events should trigger the handler + err = ed.RaiseEvent(context.Background(), event1, testEventArgs{}) + require.NoError(t, err) + require.Equal(t, 1, callCount, "Handler should be called once for event1") + + err = ed.RaiseEvent(context.Background(), event2, testEventArgs{}) + require.NoError(t, err) + require.Equal(t, 2, callCount, "Handler should be called again for event2") +} + +func Test_Duplicate_Handler_Detection_Different_Handlers(t *testing.T) { + // Different handlers for the same event should not trigger duplicate detection + callCount1 := 0 + handler1 := func(ctx context.Context, args testEventArgs) error { + callCount1++ + return nil + } + + callCount2 := 0 + handler2 := func(ctx context.Context, args testEventArgs) error { + callCount2++ + return nil + } + + ed := NewEventDispatcher[testEventArgs](testEvent) + + // Register two different handlers for the same event + err := ed.AddHandler(context.Background(), testEvent, handler1) + require.NoError(t, err) + + err = ed.AddHandler(context.Background(), testEvent, handler2) + require.NoError(t, err) + + // Verify both handlers are registered + ed.mu.RLock() + handlerCount := len(ed.handlers[testEvent]) + ed.mu.RUnlock() + require.Equal(t, 2, handlerCount, "Should have 2 different handlers") + + // Both handlers should be called when event is raised + err = ed.RaiseEvent(context.Background(), testEvent, testEventArgs{}) + require.NoError(t, err) + require.Equal(t, 1, callCount1, "Handler1 should be called") + require.Equal(t, 1, callCount2, "Handler2 should be called") +} + +func Test_Duplicate_Handler_Detection_Multiple_Duplicates(t *testing.T) { + callCount := 0 + handler := func(ctx context.Context, args testEventArgs) error { + callCount++ + return nil + } + + ed := NewEventDispatcher[testEventArgs](testEvent) + + // First registration + err := ed.AddHandler(context.Background(), testEvent, handler) + require.NoError(t, err) + + // Multiple duplicate registrations + for i := 0; i < 3; i++ { + err = ed.AddHandler(context.Background(), testEvent, handler) + require.NoError(t, err) + } + + // Verify only one handler is registered + ed.mu.RLock() + handlerCount := len(ed.handlers[testEvent]) + ed.mu.RUnlock() + require.Equal(t, 1, handlerCount, "Should still have only 1 handler after multiple duplicate attempts") + + // Handler should only be called once + err = ed.RaiseEvent(context.Background(), testEvent, testEventArgs{}) + require.NoError(t, err) + require.Equal(t, 1, callCount, "Handler should be called only once despite multiple registration attempts") +} + type testEventArgs struct{} const testEvent Event = "test" diff --git a/cli/azd/pkg/extensions/manager_test.go b/cli/azd/pkg/extensions/manager_test.go index 04040388337..d2a0721b569 100644 --- a/cli/azd/pkg/extensions/manager_test.go +++ b/cli/azd/pkg/extensions/manager_test.go @@ -571,7 +571,7 @@ var testRegistry = Registry{ Capabilities: []CapabilityType{ServiceTargetProviderCapability}, Providers: []Provider{ { - Name: "foundry.hostedagent", + Name: "azure.ai.agents", Type: ServiceTargetProviderType, Description: "Deploys to Azure AI Foundry hosted agents", }, @@ -832,12 +832,12 @@ func Test_FilterExtensions_ByCapabilityAndProvider(t *testing.T) { []string{"azure.containerapp", "test.mcp.extension", "foundry.multi.target"}) }) - t.Run("find extension with foundry.hostedagent provider", func(t *testing.T) { + t.Run("find extension with azure.ai.agents provider", func(t *testing.T) { extensions, err := manager.FindExtensions(context.Background(), &FilterOptions{ - Provider: "foundry.hostedagent", + Provider: "azure.ai.agents", }) require.NoError(t, err) - require.Len(t, extensions, 1, "Should find exactly 1 extension with foundry.hostedagent provider") + require.Len(t, extensions, 1, "Should find exactly 1 extension with azure.ai.agents provider") assertExtensionIds(t, extensions, []string{"foundry.multi.target"}, diff --git a/cli/azd/pkg/input/progress_log.go b/cli/azd/pkg/input/progress_log.go index d7b13d121cc..f1532c1cfd4 100644 --- a/cli/azd/pkg/input/progress_log.go +++ b/cli/azd/pkg/input/progress_log.go @@ -148,9 +148,18 @@ func (p *progressLog) Stop(keepLogs bool) { // Write implements oi.Writer and updates the internal buffer before flushing it into the screen. // Calling Write() before Start() or after Stop() is a no-op func (p *progressLog) Write(logBytes []byte) (int, error) { + // Acquire mutex first to prevent race conditions + p.outputMutex.Lock() + defer p.outputMutex.Unlock() + + // Check if component is initialized after acquiring mutex if p.output == nil { return len(logBytes), nil } + + // Safety check: ensure output buffer has at least one element + p.ensureOutputBuffer() + maxWidth := p.terminalWidthFn() if maxWidth <= 0 { // maxWidth <= 0 means there's no terminal to write and the stdout pipe is mostly connected to a file or a buffer @@ -159,8 +168,6 @@ func (p *progressLog) Write(logBytes []byte) (int, error) { } logsScanner := bufio.NewScanner(strings.NewReader(string(logBytes))) - p.outputMutex.Lock() - defer p.outputMutex.Unlock() var afterFirstLine bool for logsScanner.Scan() { @@ -175,25 +182,34 @@ func (p *progressLog) Write(logBytes []byte) (int, error) { afterFirstLine = true } + // Safety check: ensure we have at least one element after slice operations + p.ensureOutputBuffer() + fullLog := log - if p.output[len(p.output)-1] == "" { + lastIndex := len(p.output) - 1 + if lastIndex >= 0 && p.output[lastIndex] == "" { fullLog = p.prefix + log } fullLogLen := len(fullLog) for fullLogLen > 0 { + // Safety check before accessing last element + p.ensureOutputBuffer() + lastIndex = len(p.output) - 1 + // Get whatever is the empty space on current line - currentLineRemaining := maxWidth - len(p.output[len(p.output)-1]) + currentLineRemaining := maxWidth - len(p.output[lastIndex]) if currentLineRemaining == 0 { // line is full, use next line. Add prefix first p.output = append(p.output[1:], p.prefix) currentLineRemaining = maxWidth - len(p.prefix) + lastIndex = len(p.output) - 1 } // Choose between writing fullLog (if it is less than currentLineRemaining) // or writing only currentLineRemaining writeLen := ix.Min(fullLogLen, currentLineRemaining) - p.output[len(p.output)-1] += fullLog[:writeLen] + p.output[lastIndex] += fullLog[:writeLen] fullLog = fullLog[writeLen:] fullLogLen = len(fullLog) } @@ -208,8 +224,13 @@ func (p *progressLog) Write(logBytes []byte) (int, error) { // .Scan() won't add a line break for a line which ends in `\n` // This is because the next Scan() after \n will find EOF. // Adding a line break for such case. - if logBytes[len(logBytes)-1] == '\n' { - p.output = append(p.output[1:], p.prefix) + if len(logBytes) > 0 && logBytes[len(logBytes)-1] == '\n' { + // Safety check: ensure we have elements before slice operation + if len(p.output) > 0 { + p.output = append(p.output[1:], p.prefix) + } else { + p.output = []string{p.prefix} + } } return len(logBytes), nil @@ -238,6 +259,19 @@ func (p *progressLog) Header(header string) { /****************** Not exported method ****************/ +// ensureOutputBuffer ensures the output buffer has at least one element. +// This prevents index out of range panics when accessing p.output[len(p.output)-1]. +func (p *progressLog) ensureOutputBuffer() { + if len(p.output) == 0 { + // Ensure we have at least 1 line even if p.lines is 0 + lineCount := p.lines + if lineCount == 0 { + lineCount = 1 + } + p.output = make([]string, lineCount) + } +} + // clearLine override text with empty spaces. func clearLine() { tm.Print(tm.ResetLine("")) diff --git a/cli/azd/pkg/input/progress_log_test.go b/cli/azd/pkg/input/progress_log_test.go index 49abff12840..b09dc381003 100644 --- a/cli/azd/pkg/input/progress_log_test.go +++ b/cli/azd/pkg/input/progress_log_test.go @@ -330,3 +330,77 @@ func Test_progressChangeHeader(t *testing.T) { snConfig.SnapshotT(t, bufHandler.snap()) pg.Stop(false) } + +func Test_progressLogConcurrentWriteProtection(t *testing.T) { + sizeFn := func() int { + return 40 + } + pg := newProgressLogWithWidthFn(5, prefix, title, header, sizeFn) + + var bufHandler testBufferHandler + tm.Screen = &bufHandler.Buffer + + // Test concurrent access scenario that could cause the panic + pg.Start() + + // This should not panic even if called concurrently or after Stop/Start cycles + done := make(chan bool) + go func() { + defer func() { + if r := recover(); r != nil { + t.Errorf("Panic occurred in concurrent write: %v", r) + } + done <- true + }() + + for i := 0; i < 100; i++ { + _, err := pg.Write([]byte("concurrent test line\n")) + if err != nil { + t.Errorf("Error in concurrent write: %v", err) + return + } + } + }() + + // Try to cause race conditions by stopping/starting + go func() { + for i := 0; i < 10; i++ { + pg.Stop(false) + pg.Start() + } + }() + + <-done + pg.Stop(false) +} + +func Test_progressLogEmptySliceProtection(t *testing.T) { + sizeFn := func() int { + return 40 + } + // Create with 0 lines to test edge case + pg := newProgressLogWithWidthFn(0, prefix, title, header, sizeFn) + + var bufHandler testBufferHandler + tm.Screen = &bufHandler.Buffer + + // This should not panic even with 0 lines + pg.Start() + _, err := pg.Write([]byte("test line\n")) + require.NoError(t, err) + pg.Stop(false) + + // Test with normal lines but force empty slice scenario + pg2 := newProgressLogWithWidthFn(1, prefix, title, header, sizeFn) + pg2.Start() + + // Manually create a scenario that could lead to empty slice + pg2.outputMutex.Lock() + pg2.output = []string{} // Force empty slice + pg2.outputMutex.Unlock() + + // This should not panic due to safety checks + _, err = pg2.Write([]byte("test after empty\n")) + require.NoError(t, err) + pg2.Stop(false) +} diff --git a/cli/azd/pkg/project/framework_service_dotnet_test.go b/cli/azd/pkg/project/framework_service_dotnet_test.go index effc9102fed..a030f36466d 100644 --- a/cli/azd/pkg/project/framework_service_dotnet_test.go +++ b/cli/azd/pkg/project/framework_service_dotnet_test.go @@ -52,6 +52,7 @@ func TestBicepOutputsWithDoubleUnderscoresAreConverted(t *testing.T) { dp := NewDotNetProject(dotNetCli, environment.New("test")).(*dotnetProject) err := dp.setUserSecretsFromOutputs(*mockContext.Context, serviceConfig, ServiceLifecycleEventArgs{ + ServiceContext: NewServiceContext(), Args: map[string]any{ "bicepOutput": map[string]provisioning.OutputParameter{ "EXAMPLE_OUTPUT": {Type: "string", Value: "foo"}, @@ -103,8 +104,9 @@ func Test_DotNetProject_Init(t *testing.T) { require.NoError(t, err) eventArgs := ServiceLifecycleEventArgs{ - Project: serviceConfig.Project, - Service: serviceConfig, + Project: serviceConfig.Project, + Service: serviceConfig, + ServiceContext: NewServiceContext(), Args: map[string]any{ "bicepOutput": map[string]provisioning.OutputParameter{ "EXAMPLE_OUTPUT": {Type: "string", Value: "value"}, diff --git a/cli/azd/pkg/project/service_config_test.go b/cli/azd/pkg/project/service_config_test.go index f7305c29b3f..03496bfbbdb 100644 --- a/cli/azd/pkg/project/service_config_test.go +++ b/cli/azd/pkg/project/service_config_test.go @@ -27,7 +27,10 @@ func TestServiceConfigAddHandler(t *testing.T) { err := service.AddHandler(ctx, ServiceEventDeploy, handler) require.Nil(t, err) - err = service.RaiseEvent(ctx, ServiceEventDeploy, ServiceLifecycleEventArgs{Service: service}) + err = service.RaiseEvent(ctx, ServiceEventDeploy, ServiceLifecycleEventArgs{ + Service: service, + ServiceContext: NewServiceContext(), + }) require.Nil(t, err) require.True(t, handlerCalled) } @@ -60,7 +63,10 @@ func TestServiceConfigRemoveHandler(t *testing.T) { require.NotNil(t, err) // No events are registered at the time event was raised - err = service.RaiseEvent(ctx, ServiceEventDeploy, ServiceLifecycleEventArgs{Service: service}) + err = service.RaiseEvent(ctx, ServiceEventDeploy, ServiceLifecycleEventArgs{ + Service: service, + ServiceContext: NewServiceContext(), + }) require.Nil(t, err) require.False(t, handler1Called) require.False(t, handler2Called) @@ -92,8 +98,9 @@ func TestServiceConfigWithMultipleEventHandlers(t *testing.T) { require.Nil(t, err) err = service.RaiseEvent(ctx, ServiceEventDeploy, ServiceLifecycleEventArgs{ - Project: service.Project, - Service: service, + Project: service.Project, + Service: service, + ServiceContext: NewServiceContext(), }) require.Nil(t, err) require.True(t, handlerCalled1) @@ -122,7 +129,10 @@ func TestServiceConfigWithMultipleEvents(t *testing.T) { err = service.AddHandler(ctx, ServiceEventDeploy, deployHandler) require.Nil(t, err) - err = service.RaiseEvent(ctx, ServiceEventPackage, ServiceLifecycleEventArgs{Service: service}) + err = service.RaiseEvent(ctx, ServiceEventPackage, ServiceLifecycleEventArgs{ + Service: service, + ServiceContext: NewServiceContext(), + }) require.Nil(t, err) require.True(t, provisionHandlerCalled) @@ -146,7 +156,10 @@ func TestServiceConfigWithEventHandlerErrors(t *testing.T) { err = service.AddHandler(ctx, ServiceEventPackage, handler2) require.Nil(t, err) - err = service.RaiseEvent(ctx, ServiceEventPackage, ServiceLifecycleEventArgs{Service: service}) + err = service.RaiseEvent(ctx, ServiceEventPackage, ServiceLifecycleEventArgs{ + Service: service, + ServiceContext: NewServiceContext(), + }) require.NotNil(t, err) require.Contains(t, err.Error(), "sample error 1") require.Contains(t, err.Error(), "sample error 2") @@ -185,7 +198,10 @@ func TestServiceConfigRaiseEventWithoutArgs(t *testing.T) { err := service.AddHandler(ctx, ServiceEventDeploy, handler) require.Nil(t, err) - err = service.RaiseEvent(ctx, ServiceEventDeploy, ServiceLifecycleEventArgs{Service: service}) + err = service.RaiseEvent(ctx, ServiceEventDeploy, ServiceLifecycleEventArgs{ + Service: service, + ServiceContext: NewServiceContext(), + }) require.Nil(t, err) require.True(t, handlerCalled) } @@ -195,8 +211,9 @@ func TestServiceConfigRaiseEventWithArgs(t *testing.T) { service := getServiceConfig() handlerCalled := false eventArgs := ServiceLifecycleEventArgs{ - Service: service, - Args: map[string]any{"foo": "bar"}, + Service: service, + ServiceContext: NewServiceContext(), + Args: map[string]any{"foo": "bar"}, } handler := func(ctx context.Context, eventArgs ServiceLifecycleEventArgs) error { @@ -213,6 +230,88 @@ func TestServiceConfigRaiseEventWithArgs(t *testing.T) { require.True(t, handlerCalled) } +func TestServiceConfigEventHandlerReceivesServiceContext(t *testing.T) { + ctx := context.Background() + service := getServiceConfig() + handlerCalled := false + + // Create a ServiceContext with some test artifacts + serviceContext := NewServiceContext() + + // Add test artifacts to different lifecycle stages + restoreArtifact := &Artifact{ + Kind: ArtifactKindDirectory, + LocationKind: LocationKindLocal, + Location: "/path/to/restored/dependencies", + Metadata: map[string]string{"stage": "restore"}, + } + + buildArtifact := &Artifact{ + Kind: ArtifactKindDirectory, + LocationKind: LocationKindLocal, + Location: "/path/to/build/output", + Metadata: map[string]string{"stage": "build"}, + } + + packageArtifact := &Artifact{ + Kind: ArtifactKindArchive, + LocationKind: LocationKindLocal, + Location: "/path/to/package/app.zip", + Metadata: map[string]string{"stage": "package"}, + } + + err := serviceContext.Restore.Add(restoreArtifact) + require.Nil(t, err) + err = serviceContext.Build.Add(buildArtifact) + require.Nil(t, err) + err = serviceContext.Package.Add(packageArtifact) + require.Nil(t, err) + + handler := func(ctx context.Context, args ServiceLifecycleEventArgs) error { + handlerCalled = true + + // Verify ServiceContext is available + require.NotNil(t, args.ServiceContext) + + // Verify all artifacts are accessible in the handler + require.Len(t, args.ServiceContext.Restore, 1) + require.Len(t, args.ServiceContext.Build, 1) + require.Len(t, args.ServiceContext.Package, 1) + + // Verify artifact details + restoreArtifacts := args.ServiceContext.Restore + restoreArt, found := restoreArtifacts.FindFirst() + require.True(t, found) + require.Equal(t, "/path/to/restored/dependencies", restoreArt.Location) + require.Equal(t, "restore", restoreArt.Metadata["stage"]) + + buildArtifacts := args.ServiceContext.Build + buildArt, found := buildArtifacts.FindFirst() + require.True(t, found) + require.Equal(t, "/path/to/build/output", buildArt.Location) + require.Equal(t, "build", buildArt.Metadata["stage"]) + + packageArtifacts := args.ServiceContext.Package + packageArt, found := packageArtifacts.FindFirst() + require.True(t, found) + require.Equal(t, "/path/to/package/app.zip", packageArt.Location) + require.Equal(t, "package", packageArt.Metadata["stage"]) + + return nil + } + + err = service.AddHandler(ctx, ServiceEventDeploy, handler) + require.Nil(t, err) + + err = service.RaiseEvent(ctx, ServiceEventDeploy, ServiceLifecycleEventArgs{ + Project: service.Project, + Service: service, + ServiceContext: serviceContext, + }) + require.Nil(t, err) + require.True(t, handlerCalled) +} + func createTestServiceConfig(path string, host ServiceTargetKind, language ServiceLanguageKind) *ServiceConfig { return &ServiceConfig{ Name: "api", diff --git a/cli/azd/pkg/project/service_manager.go b/cli/azd/pkg/project/service_manager.go index 4d3551ec3c4..ffede12fcb5 100644 --- a/cli/azd/pkg/project/service_manager.go +++ b/cli/azd/pkg/project/service_manager.go @@ -251,6 +251,7 @@ func (sm *serviceManager) Restore( ctx, ServiceEventRestore, serviceConfig, + serviceContext, func() (*ServiceRestoreResult, error) { return frameworkService.Restore(ctx, serviceConfig, serviceContext, progress) }, @@ -295,6 +296,7 @@ func (sm *serviceManager) Build( ctx, ServiceEventBuild, serviceConfig, + serviceContext, func() (*ServiceBuildResult, error) { return frameworkService.Build(ctx, serviceConfig, serviceContext, progress) }, @@ -346,11 +348,6 @@ func (sm *serviceManager) Package( serviceContext = NewServiceContext() } - eventArgs := ServiceLifecycleEventArgs{ - Project: serviceConfig.Project, - Service: serviceConfig, - } - // Get the language / framework requirements frameworkRequirements := frameworkService.Requirements() @@ -368,35 +365,38 @@ func (sm *serviceManager) Package( } } - var packageResult *ServicePackageResult - - err = serviceConfig.Invoke(ctx, ServiceEventPackage, eventArgs, func() error { - frameworkPackageResult, err := frameworkService.Package(ctx, serviceConfig, serviceContext, progress) - if err != nil { - return err - } - - if err := serviceContext.Package.Add(frameworkPackageResult.Artifacts...); err != nil { - return fmt.Errorf("failed to add framework package artifacts to service context: %w", err) - } + packageResult, err := runCommand( + ctx, + ServiceEventPackage, + serviceConfig, + serviceContext, + func() (*ServicePackageResult, error) { + frameworkPackageResult, err := frameworkService.Package(ctx, serviceConfig, serviceContext, progress) + if err != nil { + return nil, err + } - serviceTargetPackageResult, err := serviceTarget.Package(ctx, serviceConfig, serviceContext, progress) - if err != nil { - return err - } + if err := serviceContext.Package.Add(frameworkPackageResult.Artifacts...); err != nil { + return nil, fmt.Errorf("failed to add framework package artifacts to service context: %w", err) + } - if err := serviceContext.Package.Add(serviceTargetPackageResult.Artifacts...); err != nil { - return fmt.Errorf("failed to add service target package artifacts to service context: %w", err) - } + serviceTargetPackageResult, err := serviceTarget.Package(ctx, serviceConfig, serviceContext, progress) + if err != nil { + return nil, err + } - packageResult = &ServicePackageResult{ - Artifacts: serviceContext.Package, - } + if err := serviceContext.Package.Add(serviceTargetPackageResult.Artifacts...); err != nil { + return nil, fmt.Errorf("failed to add service target package artifacts to service context: %w", err) + } - sm.setOperationResult(serviceConfig, string(ServiceEventPackage), packageResult) + packageResult := &ServicePackageResult{ + Artifacts: serviceContext.Package, + } - return nil - }) + sm.setOperationResult(serviceConfig, string(ServiceEventPackage), packageResult) + return packageResult, nil + }, + ) if err != nil { return nil, fmt.Errorf("failed packaging service '%s': %w", serviceConfig.Name, err) @@ -483,6 +483,7 @@ func (sm *serviceManager) Publish( ctx, ServiceEventPublish, serviceConfig, + serviceContext, func() (*ServicePublishResult, error) { return serviceTarget.Publish(ctx, serviceConfig, serviceContext, targetResource, progress, publishOptions) }, @@ -547,6 +548,7 @@ func (sm *serviceManager) Deploy( ctx, ServiceEventDeploy, serviceConfig, + serviceContext, func() (*ServiceDeployResult, error) { return serviceTarget.Deploy(ctx, serviceConfig, serviceContext, targetResource, progress) }, @@ -749,11 +751,17 @@ func runCommand[T any]( ctx context.Context, eventName ext.Event, serviceConfig *ServiceConfig, + serviceContext *ServiceContext, fn func() (T, error), ) (T, error) { + if serviceContext == nil { + serviceContext = NewServiceContext() + } + eventArgs := ServiceLifecycleEventArgs{ - Project: serviceConfig.Project, - Service: serviceConfig, + Project: serviceConfig.Project, + Service: serviceConfig, + ServiceContext: serviceContext, } var result T diff --git a/cli/azd/pkg/project/service_models.go b/cli/azd/pkg/project/service_models.go index b9747bc3aab..03f48d7ead1 100644 --- a/cli/azd/pkg/project/service_models.go +++ b/cli/azd/pkg/project/service_models.go @@ -32,9 +32,10 @@ func NewServiceContext() *ServiceContext { // ServiceLifecycleEventArgs are the event arguments available when // any service lifecycle event has been triggered type ServiceLifecycleEventArgs struct { - Project *ProjectConfig - Service *ServiceConfig - Args map[string]any + Project *ProjectConfig + Service *ServiceConfig + ServiceContext *ServiceContext + Args map[string]any } // ServiceProgress represents an incremental progress message diff --git a/cli/azd/pkg/project/service_target_aks_test.go b/cli/azd/pkg/project/service_target_aks_test.go index 95d0a7ab411..f2c9caa72d7 100644 --- a/cli/azd/pkg/project/service_target_aks_test.go +++ b/cli/azd/pkg/project/service_target_aks_test.go @@ -1018,8 +1018,9 @@ func simulateInitliaze(ctx context.Context, serviceTarget ServiceTarget, service } err := serviceConfig.RaiseEvent(ctx, "predeploy", ServiceLifecycleEventArgs{ - Project: serviceConfig.Project, - Service: serviceConfig, + Project: serviceConfig.Project, + Service: serviceConfig, + ServiceContext: NewServiceContext(), }) if err != nil { diff --git a/cli/azd/test/functional/vs_server_test.go b/cli/azd/test/functional/vs_server_test.go index 4f41290922f..90ae0869cb1 100644 --- a/cli/azd/test/functional/vs_server_test.go +++ b/cli/azd/test/functional/vs_server_test.go @@ -51,18 +51,33 @@ func Test_CLI_VsServerExternalAuth(t *testing.T) { err := cmd.Start() require.NoError(t, err) - // Wait for the server to start - for i := 0; i < 5; i++ { - time.Sleep(300 * time.Millisecond) + // Wait for the server to start and output complete JSON + var svr contracts.VsServerResult + var outputData []byte + maxAttempts := 20 // Increased from 5 to give more time + for i := 0; i < maxAttempts; i++ { + time.Sleep(150 * time.Millisecond) // Reduced sleep time but more attempts if stdout.Len() > 0 { - break + outputData = stdout.Bytes() + // Try to parse JSON - if it succeeds, we have complete output + if err := json.Unmarshal(outputData, &svr); err == nil { + break + } + // If we're on the last attempt and still can't parse, fail with helpful error + if i == maxAttempts-1 { + require.NoError( + t, + err, + "failed to parse JSON after %d attempts, output: %s", + maxAttempts, + string(outputData), + ) + } + } else if i == maxAttempts-1 { + require.FailNow(t, "server did not produce any output after %d attempts", maxAttempts) } } - var svr contracts.VsServerResult - err = json.Unmarshal(stdout.Bytes(), &svr) - require.NoError(t, err, "value: %s", stdout.String()) - ssConn, _, err := websocket.DefaultDialer.Dial(fmt.Sprintf("ws://127.0.0.1:%d/ServerService/v1.0", svr.Port), nil) require.NoError(t, err) @@ -235,18 +250,33 @@ func Test_CLI_VsServer(t *testing.T) { err = cmd.Start() require.NoError(t, err) - // Wait for the server to start - for i := 0; i < 5; i++ { - time.Sleep(300 * time.Millisecond) + // Wait for the server to start and output complete JSON + var svr contracts.VsServerResult + var outputData []byte + maxAttempts := 20 // Increased from 5 to give more time + for i := 0; i < maxAttempts; i++ { + time.Sleep(150 * time.Millisecond) // Reduced sleep time but more attempts if stdout.Len() > 0 { - break + outputData = stdout.Bytes() + // Try to parse JSON - if it succeeds, we have complete output + if err := json.Unmarshal(outputData, &svr); err == nil { + break + } + // If we're on the last attempt and still can't parse, fail with helpful error + if i == maxAttempts-1 { + require.NoError( + t, + err, + "failed to parse JSON after %d attempts, output: %s", + maxAttempts, + string(outputData), + ) + } + } else if i == maxAttempts-1 { + require.FailNow(t, "server did not produce any output after %d attempts", maxAttempts) } } - var svr contracts.VsServerResult - err = json.Unmarshal(stdout.Bytes(), &svr) - require.NoError(t, err, "value: %s", stdout.String()) - /* #nosec G204 - Subprocess launched with a potential tainted input or cmd arguments false positive */ cmd = exec.CommandContext(context.Background(), "dotnet", "test", diff --git a/cli/azd/test/recording/recording.go b/cli/azd/test/recording/recording.go index b0740acccf4..067698dfa4f 100644 --- a/cli/azd/test/recording/recording.go +++ b/cli/azd/test/recording/recording.go @@ -195,10 +195,14 @@ func Start(t *testing.T, opts ...Options) *Session { } transport := http.DefaultTransport.(*http.Transport).Clone() - vcr.SetRealTransport(&gzip2HttpRoundTripper{ + + // Wrap the transport chain with resilient retry logic and gzip handling + resilientTransport := NewResilientHttpTransport(&gzip2HttpRoundTripper{ transport: transport, }) + vcr.SetRealTransport(resilientTransport) + vcr.SetMatcher(func(r *http.Request, i cassette.Request) bool { // Ignore query parameter 's=...' in containerappOperationResults if strings.Contains(r.URL.Path, "/providers/Microsoft.App/") && diff --git a/cli/azd/test/recording/resilient_transport.go b/cli/azd/test/recording/resilient_transport.go new file mode 100644 index 00000000000..7eabc2de2a1 --- /dev/null +++ b/cli/azd/test/recording/resilient_transport.go @@ -0,0 +1,89 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package recording + +import ( + "context" + "net/http" + "strings" + "time" + + "github.com/sethvargo/go-retry" +) + +// networkErrorKeywords contains error message keywords that indicate network-related failures +// that should be retried +var networkErrorKeywords = []string{ + "timeout", + "connection", + "network", + "dns", + "tls handshake", + "context deadline exceeded", + "request timeout", + "no such host", + "connection refused", + "connection reset", + "i/o timeout", + "network is unreachable", + "temporary failure", + "service unavailable", +} + +// resilientHttpTransport wraps an HTTP transport with retry logic for network failures. +// This makes the test recorder more robust to transient network issues without affecting recorded interactions. +type resilientHttpTransport struct { + transport http.RoundTripper +} + +// NewResilientHttpTransport creates a new resilient HTTP transport that wraps the provided transport +func NewResilientHttpTransport(transport http.RoundTripper) *resilientHttpTransport { + return &resilientHttpTransport{ + transport: transport, + } +} + +// isNetworkError checks if an error message contains keywords indicating a network-related failure +func isNetworkError(err error) bool { + if err == nil { + return false + } + + errStr := strings.ToLower(err.Error()) + for _, keyword := range networkErrorKeywords { + if strings.Contains(errStr, keyword) { + return true + } + } + return false +} + +// RoundTrip implements http.RoundTripper with retry logic for network failures +func (r *resilientHttpTransport) RoundTrip(req *http.Request) (*http.Response, error) { + var resp *http.Response + var err error + + // Retry logic with exponential backoff for network failures + retryErr := retry.Do( + req.Context(), + retry.WithMaxRetries(3, retry.NewExponential(2*time.Second)), + func(ctx context.Context) error { + resp, err = r.transport.RoundTrip(req) + if err != nil { + // Check if error is likely network-related + if isNetworkError(err) { + return retry.RetryableError(err) + } + // For non-network errors, fail immediately + return err + } + return nil + }, + ) + + if retryErr != nil { + return nil, retryErr + } + return resp, nil +} diff --git a/cli/azd/test/recording/resilient_transport_test.go b/cli/azd/test/recording/resilient_transport_test.go new file mode 100644 index 00000000000..3afbfe2a241 --- /dev/null +++ b/cli/azd/test/recording/resilient_transport_test.go @@ -0,0 +1,184 @@ +// Copyright (c) Microsoft Corporation. All rights reserved. +// Licensed under the MIT License. + +package recording + +import ( + "context" + "errors" + "net/http" + "strings" + "testing" + "time" + + "github.com/stretchr/testify/assert" + "github.com/stretchr/testify/require" +) + +// mockTransport simulates network failures for testing +type mockTransport struct { + failureCount int + maxFailures int + errorMessage string +} + +func (m *mockTransport) RoundTrip(req *http.Request) (*http.Response, error) { + if m.failureCount < m.maxFailures { + m.failureCount++ + return nil, errors.New(m.errorMessage) + } + // After max failures, succeed + return &http.Response{ + StatusCode: http.StatusOK, + Header: make(http.Header), + Body: http.NoBody, + }, nil +} + +func TestIsNetworkError(t *testing.T) { + tests := []struct { + name string + error error + shouldBeRetry bool + }{ + { + name: "nil error", + error: nil, + shouldBeRetry: false, + }, + { + name: "timeout error", + error: errors.New("request timeout"), + shouldBeRetry: true, + }, + { + name: "connection error", + error: errors.New("connection refused"), + shouldBeRetry: true, + }, + { + name: "dns error", + error: errors.New("no such host"), + shouldBeRetry: true, + }, + { + name: "i/o timeout error", + error: errors.New("i/o timeout"), + shouldBeRetry: true, + }, + { + name: "network unreachable error", + error: errors.New("network is unreachable"), + shouldBeRetry: true, + }, + { + name: "non-network error", + error: errors.New("invalid json"), + shouldBeRetry: false, + }, + { + name: "application logic error", + error: errors.New("user not found"), + shouldBeRetry: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result := isNetworkError(tt.error) + assert.Equal(t, tt.shouldBeRetry, result) + }) + } +} + +func TestResilientHttpTransport_RetryOnNetworkErrors(t *testing.T) { + tests := []struct { + name string + errorMessage string + maxFailures int + shouldRetry bool + }{ + { + name: "timeout error should retry", + errorMessage: "request timeout", + maxFailures: 2, + shouldRetry: true, + }, + { + name: "connection error should retry", + errorMessage: "connection refused", + maxFailures: 1, + shouldRetry: true, + }, + { + name: "dns error should retry", + errorMessage: "no such host", + maxFailures: 1, + shouldRetry: true, + }, + { + name: "non-network error should not retry", + errorMessage: "invalid json", + maxFailures: 3, + shouldRetry: false, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + mock := &mockTransport{ + maxFailures: tt.maxFailures, + errorMessage: tt.errorMessage, + } + + resilient := NewResilientHttpTransport(mock) + + req, err := http.NewRequestWithContext(context.Background(), "GET", "http://example.com", nil) + require.NoError(t, err) + + // Set a longer timeout to accommodate retries with exponential backoff + ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second) + defer cancel() + req = req.WithContext(ctx) + + resp, err := resilient.RoundTrip(req) + + if tt.shouldRetry && tt.maxFailures <= 3 { // Our max retries is 3 + // Should succeed after retries + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, http.StatusOK, resp.StatusCode) + } else { + // Should fail without retrying or after max retries + assert.Error(t, err) + assert.Nil(t, resp) + if tt.shouldRetry { + // Error message should contain original error + assert.True(t, strings.Contains(err.Error(), tt.errorMessage)) + } + } + }) + } +} + +func TestResilientHttpTransport_ImmediateSuccess(t *testing.T) { + // Test that successful requests go through without delay + mock := &mockTransport{ + maxFailures: 0, // No failures + } + + resilient := NewResilientHttpTransport(mock) + + req, err := http.NewRequestWithContext(context.Background(), "GET", "http://example.com", nil) + require.NoError(t, err) + + start := time.Now() + resp, err := resilient.RoundTrip(req) + duration := time.Since(start) + + assert.NoError(t, err) + assert.NotNil(t, resp) + assert.Equal(t, http.StatusOK, resp.StatusCode) + // Should complete quickly since there's no retry needed + assert.Less(t, duration, 100*time.Millisecond) +} diff --git a/cli/version.txt b/cli/version.txt index 7c51bb101ae..f5b00dc262b 100644 --- a/cli/version.txt +++ b/cli/version.txt @@ -1 +1 @@ -1.21.0-beta.1 +1.20.3 diff --git a/eng/pipelines/release-ext-azure-foundry-ai-agents.yml b/eng/pipelines/release-ext-azure-ai-agents.yml similarity index 71% rename from eng/pipelines/release-ext-azure-foundry-ai-agents.yml rename to eng/pipelines/release-ext-azure-ai-agents.yml index 8cc8b62f413..a9b4cb12bcb 100644 --- a/eng/pipelines/release-ext-azure-foundry-ai-agents.yml +++ b/eng/pipelines/release-ext-azure-ai-agents.yml @@ -6,7 +6,7 @@ trigger: paths: include: - go.mod - - cli/azd/extensions/azure.foundry.ai.agents + - cli/azd/extensions/azure.ai.agents - eng/pipelines/release-azd-extension.yml - /eng/pipelines/templates/jobs/build-azd-extension.yml - /eng/pipelines/templates/jobs/cross-build-azd-extension.yml @@ -15,7 +15,7 @@ trigger: pr: paths: include: - - cli/azd/extensions/azure.foundry.ai.agents + - cli/azd/extensions/azure.ai.agents - eng/pipelines/release-azd-extension.yml - eng/pipelines/templates/steps/publish-cli.yml exclude: @@ -27,6 +27,6 @@ extends: stages: - template: /eng/pipelines/templates/stages/release-azd-extension.yml parameters: - AzdExtensionId: azure.foundry.ai.agents - SanitizedExtensionId: azure-foundry-ai-agents - AzdExtensionDirectory: cli/azd/extensions/azure.foundry.ai.agents + AzdExtensionId: azure.ai.agents + SanitizedExtensionId: azure-ai-agents + AzdExtensionDirectory: cli/azd/extensions/azure.ai.agents From a1128c72ea976a24e5724dbd38cca976bb7c330e Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 28 Oct 2025 16:03:26 -0700 Subject: [PATCH 10/12] Bad merge Signed-off-by: trangevi --- .../azure.foundry.ai.agents/tests/go.sum | 4 -- .../samples/declarativeNoTools/agent.yaml | 35 -------------- .../tests/samples/githubMcpAgent/agent.yaml | 46 ------------------- 3 files changed, 85 deletions(-) delete mode 100644 cli/azd/extensions/azure.foundry.ai.agents/tests/go.sum delete mode 100644 cli/azd/extensions/azure.foundry.ai.agents/tests/samples/declarativeNoTools/agent.yaml delete mode 100644 cli/azd/extensions/azure.foundry.ai.agents/tests/samples/githubMcpAgent/agent.yaml diff --git a/cli/azd/extensions/azure.foundry.ai.agents/tests/go.sum b/cli/azd/extensions/azure.foundry.ai.agents/tests/go.sum deleted file mode 100644 index a62c313c5b0..00000000000 --- a/cli/azd/extensions/azure.foundry.ai.agents/tests/go.sum +++ /dev/null @@ -1,4 +0,0 @@ -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM= -gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= -gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= -gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/cli/azd/extensions/azure.foundry.ai.agents/tests/samples/declarativeNoTools/agent.yaml b/cli/azd/extensions/azure.foundry.ai.agents/tests/samples/declarativeNoTools/agent.yaml deleted file mode 100644 index 279897063b1..00000000000 --- a/cli/azd/extensions/azure.foundry.ai.agents/tests/samples/declarativeNoTools/agent.yaml +++ /dev/null @@ -1,35 +0,0 @@ -agent: - kind: prompt - name: Learn French Agent - description: |- - This Agent helps users learn French by providing vocabulary, grammar tips, and practice exercises. - metadata: - example: - - role: user - content: |- - Can you help me learn some basic French phrases for traveling? - - role: assistant - content: |- - Sure! Here are some useful French phrases for your trip: - 1. Bonjour - Hello - 2. Merci - Thank you - 3. Où est…? - Where is…? - 4. Combien ça coûte? - How much does it cost? - 5. Parlez-vous anglais? - Do you speak English? - tags: - - example - - learning - authors: - - jeomhove - - model: - id: gpt-4o-mini - publisher: azure - options: - temperature: 0.7 - maxTokens: 4000 - - instructions: |- - You are a helpful assistant that specializes in teaching French. - Provide clear explanations, examples, - and practice exercises to help users improve their French language skills. diff --git a/cli/azd/extensions/azure.foundry.ai.agents/tests/samples/githubMcpAgent/agent.yaml b/cli/azd/extensions/azure.foundry.ai.agents/tests/samples/githubMcpAgent/agent.yaml deleted file mode 100644 index c35efd4ac50..00000000000 --- a/cli/azd/extensions/azure.foundry.ai.agents/tests/samples/githubMcpAgent/agent.yaml +++ /dev/null @@ -1,46 +0,0 @@ -agent: - kind: prompt - name: github-agent - description: An agent leveraging github-mcp to assist with GitHub-related tasks. - - metadata: - authors: - - jeomhove - tags: - - example - - prompt - - model: - id: gpt-4o-mini - publisher: azure - options: - temperature: 0.7 - maxTokens: 4000 - - tools: - - kind: mcp - connection: - kind: foundry - endpoint: https://api.githubcopilot.com/mcp/ - name: github-mcp-oauth - name: github-mcp-remote - url: https://api.githubcopilot.com/mcp/ - - instructions: |- - You are an expert assistant in using GitHub and the GitHub API. - You have access to a set of tools that allow you to interact with GitHub repositories, issues, pull requests, and more. - Use these tools to help users with their GitHub-related tasks. - Always provide clear and concise responses, and ensure that you use the tools effectively to gather information or perform actions on behalf of the user. - When using the tools, make sure to follow the correct syntax and provide all necessary parameters. - If you need to ask the user for more information, do so in a clear and polite manner. - Always remember to think step-by-step and verify your actions. - Here are some example tasks you can help with: - - Creating, updating, and managing issues and pull requests. - - Fetching information about repositories, commits, and branches. - - Collaborating with team members through comments and reviews. - - Automating workflows and actions using GitHub Actions. - - Providing insights and analytics on repository activity. - Be proactive in suggesting useful actions and improvements to the user's GitHub experience. - When you complete a task, summarize the actions taken and any important information for the user. - Always prioritize the user's goals and ensure their satisfaction with your assistance. - If you encounter any errors or issues while using the tools, handle them gracefully and inform the user. From 12099c1507fc040dd50ebd4cbb1157ef77f30b89 Mon Sep 17 00:00:00 2001 From: trangevi Date: Tue, 28 Oct 2025 16:10:12 -0700 Subject: [PATCH 11/12] remove .orig file Signed-off-by: trangevi --- .../pkg/agents/registry_api/helpers.go.orig | 541 ------------------ 1 file changed, 541 deletions(-) delete mode 100644 cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go.orig diff --git a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go.orig b/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go.orig deleted file mode 100644 index 50d31d1eb21..00000000000 --- a/cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go.orig +++ /dev/null @@ -1,541 +0,0 @@ -// Copyright (c) Microsoft Corporation. All rights reserved. -// Licensed under the MIT License. - -package registry_api - -import ( - "context" - "encoding/json" - "fmt" - "reflect" - "strconv" - "strings" - - "azureaiagent/internal/pkg/agents/agent_api" - "azureaiagent/internal/pkg/agents/agent_yaml" - - "github.com/azure/azure-dev/cli/azd/pkg/azdext" -) - -// ParameterValues represents the user-provided values for manifest parameters -type ParameterValues map[string]interface{} - -func ProcessRegistryManifest(ctx context.Context, manifest *Manifest, azdClient *azdext.AzdClient) (*agent_yaml.AgentManifest, error) { - // Convert the agent API definition into a MAML definition - promptAgent, err := ConvertAgentDefinition(manifest.Template) - if err != nil { - return nil, fmt.Errorf("failed to convert agentDefinition: %w", err) - } - - // Inject Agent API Manifest properties into MAML Agent properties as needed - updatedAgentDef := MergeManifestIntoAgentDefinition(manifest, &promptAgent.AgentDefinition) - promptAgent.AgentDefinition = *updatedAgentDef - - // Convert the agent API parameters into MAML parameters - parameters, err := ConvertParameters(manifest.Parameters) - if err != nil { - return nil, fmt.Errorf("failed to convert parameters: %w", err) - } - - // Create the AgentManifest with the converted PromptAgent - result := &agent_yaml.AgentManifest{ - Name: manifest.Name, - DisplayName: manifest.DisplayName, - Description: &manifest.Description, -<<<<<<< HEAD:cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go - Template: *promptAgent, -======= - Template: *agentDef, ->>>>>>> main:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go - Parameters: parameters, - } - - return result, nil -} - -<<<<<<< HEAD:cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go -func ConvertAgentDefinition(template agent_api.PromptAgentDefinition) (*agent_yaml.PromptAgent, error) { -======= -func ConvertAgentDefinition(template agent_api.PromptAgentDefinition) (*agent_yaml.AgentDefinition, error) { ->>>>>>> main:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go - // Convert tools from agent_api.Tool to agent_yaml.Tool - var tools []agent_yaml.Tool - for _, apiTool := range template.Tools { - yamlTool := agent_yaml.Tool{ - Name: apiTool.Type, // Use Type as Name - Kind: "", // TODO: Where does this come from? - } - tools = append(tools, yamlTool) - } - -<<<<<<< HEAD:cli/azd/extensions/azure.foundry.ai.agents/internal/pkg/agents/registry_api/helpers.go - // Create the PromptAgent - promptAgent := &agent_yaml.PromptAgent{ - AgentDefinition: agent_yaml.AgentDefinition{ - Kind: agent_yaml.AgentKindPrompt, // Set to prompt kind - Name: "", // Will be set later from manifest or user input - Description: nil, // Will be set later from manifest or user input - Tools: &tools, - // Metadata: make(map[string]interface{}), // TODO, Where does this come from? - }, - Model: agent_yaml.Model{ - Id: template.Model, - }, - Instructions: template.Instructions, - } - - return promptAgent, nil -======= - // Create the AgentDefinition - agentDef := &agent_yaml.AgentDefinition{ - Kind: agent_yaml.AgentKindPrompt, // Set to prompt kind - Name: "", // Will be set later from manifest or user input - Description: nil, // Will be set later from manifest or user input - Tools: &tools, - // Metadata: make(map[string]interface{}), // TODO, Where does this come from? - } - - return agentDef, nil ->>>>>>> main:cli/azd/extensions/azure.ai.agents/internal/pkg/agents/registry_api/helpers.go -} - -func ConvertParameters(parameters map[string]OpenApiParameter) (*map[string]agent_yaml.Parameter, error) { - if len(parameters) == 0 { - return nil, nil - } - - result := make(map[string]agent_yaml.Parameter, len(parameters)) - - for paramName, openApiParam := range parameters { - // Create a basic Parameter from the OpenApiParameter - param := agent_yaml.Parameter{ - Name: paramName, - Description: &openApiParam.Description, - Required: &openApiParam.Required, - } - - // Extract type/kind from schema if available - if openApiParam.Schema != nil { - param.Schema = agent_yaml.ParameterSchema{ - Type: openApiParam.Schema.Type, - Default: &openApiParam.Schema.Default, - } - - // Convert enum values if present - if len(openApiParam.Schema.Enum) > 0 { - param.Schema.Enum = &openApiParam.Schema.Enum - } - } - - // Use example as default if no schema default is provided - if param.Schema.Default == nil && openApiParam.Example != nil { - param.Schema.Default = &openApiParam.Example - } - - // Fallback to string type if no type specified - if param.Schema.Type == "" { - param.Schema.Type = "string" - } - - result[paramName] = param - } - - return &result, nil -} - -// ProcessManifestParameters prompts the user for parameter values and injects them into the template -func ProcessManifestParameters(ctx context.Context, manifest *agent_yaml.AgentManifest, azdClient *azdext.AzdClient) (*agent_yaml.AgentManifest, error) { - // If no parameters are defined, return the manifest as-is - if manifest.Parameters == nil || len(*manifest.Parameters) == 0 { - fmt.Println("The manifest does not contain parameters that need to be configured.") - return manifest, nil - } - - fmt.Println("The manifest contains parameters that need to be configured:") - fmt.Println() - - // Collect parameter values from user - paramValues, err := promptForYamlParameterValues(ctx, *manifest.Parameters, azdClient) - if err != nil { - return nil, fmt.Errorf("failed to collect parameter values: %w", err) - } - - // Inject parameter values into the manifest - processedManifest, err := injectParameterValuesIntoManifest(manifest, paramValues) - if err != nil { - return nil, fmt.Errorf("failed to inject parameter values into manifest: %w", err) - } - - return processedManifest, nil -} - -// promptForYamlParameterValues prompts the user for values for each YAML parameter -func promptForYamlParameterValues(ctx context.Context, parameters map[string]agent_yaml.Parameter, azdClient *azdext.AzdClient) (ParameterValues, error) { - paramValues := make(ParameterValues) - - for paramName, param := range parameters { - fmt.Printf("Parameter: %s\n", paramName) - if param.Description != nil && *param.Description != "" { - fmt.Printf(" Description: %s\n", *param.Description) - } - - // Get default value - var defaultValue interface{} - if param.Schema.Default != nil { - defaultValue = *param.Schema.Default - } - - // Get enum values if available - var enumValues []string - if param.Schema.Enum != nil && len(*param.Schema.Enum) > 0 { - enumValues = make([]string, len(*param.Schema.Enum)) - for i, val := range *param.Schema.Enum { - enumValues[i] = fmt.Sprintf("%v", val) - } - } - - // Show available options if it's an enum - if len(enumValues) > 0 { - fmt.Printf(" Available values: %v\n", enumValues) - } - - // Show default value if available - if defaultValue != nil { - fmt.Printf(" Default: %v\n", defaultValue) - } - - fmt.Println() - - // Prompt for value - var value interface{} - var err error - isRequired := param.Required != nil && *param.Required - if len(enumValues) > 0 { - // Use selection for enum parameters - value, err = promptForEnumValue(ctx, paramName, enumValues, defaultValue, azdClient) - } else { - // Use text input for other parameters - value, err = promptForTextValue(ctx, paramName, defaultValue, isRequired, azdClient) - } - - if err != nil { - return nil, fmt.Errorf("failed to get value for parameter %s: %w", paramName, err) - } - - paramValues[paramName] = value - } - - return paramValues, nil -} - -// injectParameterValuesIntoManifest replaces parameter placeholders in the manifest with actual values -func injectParameterValuesIntoManifest(manifest *agent_yaml.AgentManifest, paramValues ParameterValues) (*agent_yaml.AgentManifest, error) { - // Convert manifest to JSON for processing - manifestBytes, err := json.Marshal(manifest) - if err != nil { - return nil, fmt.Errorf("failed to marshal manifest: %w", err) - } - - // Inject parameter values - processedBytes, err := injectParameterValues(json.RawMessage(manifestBytes), paramValues) - if err != nil { - return nil, fmt.Errorf("failed to inject parameter values: %w", err) - } - - // Convert back to AgentManifest - processedManifest, err := agent_yaml.LoadAndValidateAgentManifest(processedBytes) - if err != nil { - return nil, fmt.Errorf("failed to reload processed manifest: %w", err) - } - - return processedManifest, nil -} - -// promptForEnumValue prompts the user to select from enumerated values -func promptForEnumValue(ctx context.Context, paramName string, enumValues []string, defaultValue interface{}, azdClient *azdext.AzdClient) (interface{}, error) { - // Convert default value to string for comparison - var defaultStr string - if defaultValue != nil { - defaultStr = fmt.Sprintf("%v", defaultValue) - } - - // Create choices for the select prompt - choices := make([]*azdext.SelectChoice, len(enumValues)) - defaultIndex := int32(0) - for i, val := range enumValues { - choices[i] = &azdext.SelectChoice{ - Value: val, - Label: val, - } - if val == defaultStr { - defaultIndex = int32(i) - } - } - - resp, err := azdClient.Prompt().Select(ctx, &azdext.SelectRequest{ - Options: &azdext.SelectOptions{ - Message: fmt.Sprintf("Select value for parameter '%s':", paramName), - Choices: choices, - SelectedIndex: &defaultIndex, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to prompt for enum value: %w", err) - } - - // Return the selected value - if resp.Value != nil && int(*resp.Value) < len(enumValues) { - return enumValues[*resp.Value], nil - } - - return enumValues[0], nil // fallback to first option -} - -// promptForTextValue prompts the user for a text value -func promptForTextValue(ctx context.Context, paramName string, defaultValue interface{}, required bool, azdClient *azdext.AzdClient) (interface{}, error) { - var defaultStr string - if defaultValue != nil { - defaultStr = fmt.Sprintf("%v", defaultValue) - } - - message := fmt.Sprintf("Enter value for parameter '%s':", paramName) - if defaultStr != "" { - message += fmt.Sprintf(" (default: %s)", defaultStr) - } - - resp, err := azdClient.Prompt().Prompt(ctx, &azdext.PromptRequest{ - Options: &azdext.PromptOptions{ - Message: message, - IgnoreHintKeys: true, - }, - }) - if err != nil { - return nil, fmt.Errorf("failed to prompt for text value: %w", err) - } - - // Use default value if user provided empty input - if strings.TrimSpace(resp.Value) == "" { - if defaultValue != nil { - return defaultValue, nil - } - if required { - return nil, fmt.Errorf("parameter '%s' is required but no value was provided", paramName) - } - } - - return resp.Value, nil -} - -// injectParameterValues replaces parameter placeholders in the template with actual values -func injectParameterValues(template json.RawMessage, paramValues ParameterValues) ([]byte, error) { - // Convert template to string for processing - templateStr := string(template) - - // Replace each parameter placeholder with its value - for paramName, paramValue := range paramValues { - placeholder := fmt.Sprintf("{{%s}}", paramName) - valueStr := fmt.Sprintf("%v", paramValue) - templateStr = strings.ReplaceAll(templateStr, placeholder, valueStr) - - placeholder = fmt.Sprintf("{{ %s }}", paramName) - templateStr = strings.ReplaceAll(templateStr, placeholder, valueStr) - } - - // Check for any remaining unreplaced placeholders - if strings.Contains(templateStr, "{{") && strings.Contains(templateStr, "}}") { - fmt.Printf("Warning: Template contains unresolved placeholders:\n%s\n", templateStr) - } - - return []byte(templateStr), nil -} - -// ValidateParameterValue validates a parameter value against its schema -func ValidateParameterValue(value interface{}, schema *OpenApiSchema) error { - if schema == nil { - return nil - } - - // Validate type if specified - if schema.Type != "" { - if err := validateType(value, schema.Type); err != nil { - return err - } - } - - // Validate enum if specified - if len(schema.Enum) > 0 { - if err := validateEnum(value, schema.Enum); err != nil { - return err - } - } - - // Additional validations can be added here (min/max length, patterns, etc.) - - return nil -} - -// validateType validates that a value matches the expected type -func validateType(value interface{}, expectedType string) error { - switch expectedType { - case "string": - if _, ok := value.(string); !ok { - return fmt.Errorf("expected string, got %T", value) - } - case "integer": - switch v := value.(type) { - case int, int32, int64: - // Valid integer types - case string: - // Try to parse string as integer - if _, err := strconv.Atoi(v); err != nil { - return fmt.Errorf("expected integer, got string that cannot be parsed as integer: %s", v) - } - default: - return fmt.Errorf("expected integer, got %T", value) - } - case "number": - switch v := value.(type) { - case int, int32, int64, float32, float64: - // Valid numeric types - case string: - // Try to parse string as number - if _, err := strconv.ParseFloat(v, 64); err != nil { - return fmt.Errorf("expected number, got string that cannot be parsed as number: %s", v) - } - default: - return fmt.Errorf("expected number, got %T", value) - } - case "boolean": - switch v := value.(type) { - case bool: - // Valid boolean - case string: - // Try to parse string as boolean - if _, err := strconv.ParseBool(v); err != nil { - return fmt.Errorf("expected boolean, got string that cannot be parsed as boolean: %s", v) - } - default: - return fmt.Errorf("expected boolean, got %T", value) - } - } - - return nil -} - -// validateEnum validates that a value is one of the allowed enum values -func validateEnum(value interface{}, enumValues []interface{}) error { - valueStr := fmt.Sprintf("%v", value) - - for _, enumVal := range enumValues { - enumStr := fmt.Sprintf("%v", enumVal) - if valueStr == enumStr { - return nil - } - } - - return fmt.Errorf("value '%v' is not one of the allowed values: %v", value, enumValues) -} - -// MergeManifestIntoAgentDefinition takes a Manifest and an AgentDefinition and updates -// the AgentDefinition with values from the Manifest for properties that are empty/zero. -// Returns the updated AgentDefinition. -func MergeManifestIntoAgentDefinition(manifest *Manifest, agentDef *agent_yaml.AgentDefinition) *agent_yaml.AgentDefinition { - // Create a copy of the agent definition to avoid modifying the original - result := *agentDef - - // Use reflection to iterate through AgentDefinition fields - resultValue := reflect.ValueOf(&result).Elem() - resultType := resultValue.Type() - - // Get manifest properties as a map using reflection - manifestValue := reflect.ValueOf(manifest).Elem() - manifestType := manifestValue.Type() - - // Create a map of manifest field names to values for easier lookup - manifestFields := make(map[string]reflect.Value) - for i := 0; i < manifestValue.NumField(); i++ { - field := manifestType.Field(i) - fieldValue := manifestValue.Field(i) - - // Use the json tag name if available, otherwise use the field name - jsonTag := field.Tag.Get("json") - fieldName := field.Name - if jsonTag != "" && jsonTag != "-" { - // Remove omitempty and other options from json tag - parts := strings.Split(jsonTag, ",") - if parts[0] != "" { - fieldName = parts[0] - } - } - manifestFields[strings.ToLower(fieldName)] = fieldValue - } - - // Iterate through AgentDefinition fields - for i := 0; i < resultValue.NumField(); i++ { - field := resultType.Field(i) - fieldValue := resultValue.Field(i) - - // Skip unexported fields - if !fieldValue.CanSet() { - continue - } - - // Get the field name to match with manifest - jsonTag := field.Tag.Get("json") - fieldName := field.Name - if jsonTag != "" && jsonTag != "-" { - // Remove omitempty and other options from json tag - parts := strings.Split(jsonTag, ",") - if parts[0] != "" { - fieldName = parts[0] - } - } - - // Check if this field exists in the manifest and if the agent definition field is empty - if manifestFieldValue, exists := manifestFields[strings.ToLower(fieldName)]; exists { - if isEmptyValue(fieldValue) && !isEmptyValue(manifestFieldValue) { - // Only set if types are compatible - if manifestFieldValue.Type().AssignableTo(fieldValue.Type()) { - fieldValue.Set(manifestFieldValue) - } else { - // Handle type conversion for common cases - if fieldValue.Kind() == reflect.String && manifestFieldValue.Kind() == reflect.String { - fieldValue.SetString(manifestFieldValue.String()) - } - } - } - } - } - - return &result -} - -// isEmptyValue checks if a reflect.Value represents an empty/zero value -func isEmptyValue(v reflect.Value) bool { - switch v.Kind() { - case reflect.String: - return v.String() == "" - case reflect.Bool: - return !v.Bool() - case reflect.Int, reflect.Int8, reflect.Int16, reflect.Int32, reflect.Int64: - return v.Int() == 0 - case reflect.Uint, reflect.Uint8, reflect.Uint16, reflect.Uint32, reflect.Uint64: - return v.Uint() == 0 - case reflect.Float32, reflect.Float64: - return v.Float() == 0 - case reflect.Interface, reflect.Ptr, reflect.Slice, reflect.Map, reflect.Chan, reflect.Func: - return v.IsNil() - case reflect.Array: - return v.Len() == 0 - case reflect.Struct: - // For structs, check if all fields are empty - for i := 0; i < v.NumField(); i++ { - if !isEmptyValue(v.Field(i)) { - return false - } - } - return true - default: - return false - } -} From 78fc8f4f5b244b791fe3e05d9594e8c222766be7 Mon Sep 17 00:00:00 2001 From: trangevi Date: Wed, 29 Oct 2025 08:35:02 -0700 Subject: [PATCH 12/12] Remove old env var handling Signed-off-by: trangevi --- cli/azd/extensions/azure.ai.agents/internal/cmd/init.go | 3 --- 1 file changed, 3 deletions(-) diff --git a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go index 3aee1f1e580..5b95dac52d2 100644 --- a/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go +++ b/cli/azd/extensions/azure.ai.agents/internal/cmd/init.go @@ -1319,9 +1319,6 @@ func (a *InitAction) updateEnvironment(ctx context.Context, agentManifest *agent switch agentDef.Kind { case agent_yaml.AgentKindPrompt: agentDef := agentManifest.Template.(agent_yaml.PromptAgent) - if err := a.setEnvVar(ctx, envName, "AZURE_AI_FOUNDRY_MODEL_NAME", agentDef.Model.Id); err != nil { - return err - } modelDeployment, err := a.getModelDeploymentDetails(ctx, agentDef.Model) if err != nil {