diff --git a/src/agents/common/constants.py b/src/agents/common/constants.py index 22f55ab3..eceb889a 100644 --- a/src/agents/common/constants.py +++ b/src/agents/common/constants.py @@ -44,6 +44,12 @@ KYMA_AGENT = "KymaAgent" +RESPONSE_CONVERTER = "ResponseConverter" + +NEW_YAML = "New" + +UPDATE_YAML = "Update" + SUCCESS_CODE = 200 ERROR_RATE_LIMIT_CODE = 429 diff --git a/src/agents/common/response_converter.py b/src/agents/common/response_converter.py new file mode 100644 index 00000000..e04c4918 --- /dev/null +++ b/src/agents/common/response_converter.py @@ -0,0 +1,272 @@ +import re +from typing import Any, Protocol + +import yaml +from langchain_core.messages import AIMessage +from langgraph.constants import END + +from agents.common.constants import ( + FINALIZER, + MESSAGES, + NEW_YAML, + NEXT, + UPDATE_YAML, +) +from utils.logging import get_logger + +logger = get_logger(__name__) + + +class IResponseConverter(Protocol): + """Protocol for IResponseConverter.""" + + def convert_final_response(self, state: dict[str, Any]) -> dict[str, Any]: + """ + Main conversion method that orchestrates the entire YAML to HTML conversion process. + """ + ... + + +class ResponseConverter: + """ + A class that handles the conversion of YAML responses into HTML format with resource links. + This converter processes both new and update YAML configurations and generates appropriate + resource links based on the YAML content. + """ + + def __init__(self): + # Regular expression patterns to extract YAML blocks + self.new_yaml_pattern = r"\n(.*?)\n" + self.update_yaml_pattern = r"\n(.*?)\n" + + def _extract_yaml(self, finalizer_response: str) -> tuple[list[str], list[str]]: + """ + Extract YAML code blocks from finalizer response using regex patterns. + + Args: + finalizer_response: Response of the finalizer node + + Returns: + Tuple containing lists of new and update YAML blocks + """ + + # Find all YAML blocks marked for new resources + new_yaml_blocks = re.findall( + self.new_yaml_pattern, finalizer_response, re.DOTALL + ) + + # Find all YAML blocks marked for updating existing resources + update_yaml_blocks = re.findall( + self.update_yaml_pattern, finalizer_response, re.DOTALL + ) + + return new_yaml_blocks, update_yaml_blocks + + def _parse_yamls(self, yaml_config: str) -> Any | None: + """ + Parse YAML string into Python object with error handling. + Attempts two parsing methods: + 1. First check: if yaml markers available + - tries parsing after removing yaml markers + 2. Else, tries parsing the raw string + + Args: + yaml_config: YAML configuration string + + Returns: + Parsed YAML object or None if parsing fails + """ + try: + # First check: if yaml markers available + if yaml_config[:7] == "```yaml": + # parsing after removing yaml markers + parsed_yaml = yaml.safe_load(yaml_config[8:-4]) + else: + # Parse raw string + parsed_yaml = yaml.safe_load(yaml_config) + + except Exception as e: + logger.error( + f"Error while parsing the yaml : {yaml_config} , Exception : {e}" + ) + + return None + + return parsed_yaml + + def _generate_resource_link( + self, yaml_config: dict[str, Any], link_type: str + ) -> str | None: + """ + Generate resource link based on YAML metadata and link type. + + Args: + yaml_config: Parsed YAML configuration + link_type: Type of link to generate ('NEW' or 'UPDATE') + + Returns: + Generated resource link or None if required metadata is missing + """ + # Extract required metadata for link generation + try: + namespace = yaml_config["metadata"]["namespace"] + deployment_name = yaml_config["metadata"]["name"] + resource_type = yaml_config["kind"] + except Exception as e: + logger.error( + f"Error in generating link, skipping the yaml: {yaml_config} , Exception : {e}" + ) + return None + + # Generate appropriate link based on type + if link_type == NEW_YAML: + # New resource link format + return f"/namespaces/{namespace}/{resource_type}" + else: + # Update resource link format includes deployment name + return f"/namespaces/{namespace}/{resource_type}/{deployment_name}" + + def _create_html_nested_yaml( + self, yaml_config: str, resource_link: str, link_type: str + ) -> str: + """ + Create HTML structure containing YAML content and resource link. + + Args: + yaml_config: YAML configuration string + resource_link: Generated resource link + link_type: Type of link ('New' or 'Update') + + Returns: + Formatted HTML string containing YAML and link + """ + html_content = f""" +
+ {yaml_config} +
+ + + """ + + return html_content + + def _replace_yaml_with_html( + self, + finalizer_response: str, + replacement_html_list: list[str], + yaml_type: str, + ) -> str: + """ + Replace YAML blocks in the response with corresponding HTML blocks. + + Args: + finalizer_response: Original response containing YAML blocks + replacement_html_list: List of HTML blocks to replace YAML + yaml_type: Type of YAML blocks to replace ('NEW' or 'UPDATE') + + Returns: + Modified response with YAML blocks replaced by HTML + """ + + def replace_func(match: Any) -> Any: + # Replace each match with the next HTML block from the list + if replacement_html_list: + html_content = replacement_html_list.pop(0) + return html_content + return match.group(0) + + # Select appropriate pattern based on YAML type + yaml_pattern = ( + self.new_yaml_pattern if yaml_type == NEW_YAML else self.update_yaml_pattern + ) + + # Perform the replacement + converted_response = re.sub( + yaml_pattern, replace_func, finalizer_response, flags=re.DOTALL + ) + return converted_response + + def _create_replacement_list( + self, yaml_list: list[str], yaml_type: str + ) -> list[str]: + """ + Process list of YAML configs and create corresponding HTML replacements. + + Args: + yaml_list: List of YAML configurations to process + yaml_type: Type of YAML blocks ('NEW' or 'UPDATE') + + Returns: + List of HTML replacements for YAML blocks + """ + replacement_list = [] + + for yaml_config_string in yaml_list: + # Parse YAML and generate replacement + parsed_yaml = self._parse_yamls(yaml_config_string) + if not parsed_yaml: + replacement_list.append(yaml_config_string) + continue + + # Generate resource link + generated_link = self._generate_resource_link(parsed_yaml, yaml_type) + + if generated_link: + # Create HTML if link generation successful + html_string = self._create_html_nested_yaml( + yaml_config_string, generated_link, yaml_type + ) + replacement_list.append(html_string) + else: + # Keep original if link generation fails + replacement_list.append(yaml_config_string) + + return replacement_list + + def convert_final_response(self, state: dict[str, Any]) -> dict[str, Any]: + """ + Main conversion method that orchestrates the entire YAML to HTML conversion process. + + Args: + state: Current supervisor state + + Returns: + Dictionary containing converted messages and next state + """ + finalizer_response = str(state["messages"][-1].content) + try: + # Extract all YAML blocks + new_yaml_list, update_yaml_list = self._extract_yaml(finalizer_response) + + if new_yaml_list or update_yaml_list: + # Process new resource YAML configs + replacement_list = self._create_replacement_list( + new_yaml_list, NEW_YAML + ) + finalizer_response = self._replace_yaml_with_html( + finalizer_response, replacement_list, NEW_YAML + ) + + # Process update resource YAML configs + replacement_list = self._create_replacement_list( + update_yaml_list, UPDATE_YAML + ) + finalizer_response = self._replace_yaml_with_html( + finalizer_response, replacement_list, UPDATE_YAML + ) + + except Exception as e: + logger.error(f"Error in converting final response: {e}") + + return { + MESSAGES: [ + AIMessage( + content=finalizer_response, + name=FINALIZER, + ) + ], + NEXT: END, + } diff --git a/src/agents/supervisor/agent.py b/src/agents/supervisor/agent.py index 40178b96..155a0147 100644 --- a/src/agents/supervisor/agent.py +++ b/src/agents/supervisor/agent.py @@ -19,6 +19,7 @@ NEXT, PLANNER, ) +from agents.common.response_converter import IResponseConverter, ResponseConverter from agents.common.state import Plan from agents.common.utils import create_node_output, filter_messages from agents.supervisor.prompts import ( @@ -81,12 +82,20 @@ class SupervisorAgent: members: list[str] = [] plan_parser = PydanticOutputParser(pydantic_object=Plan) - def __init__(self, models: dict[str, IModel | Embeddings], members: list[str]): + def __init__( + self, + models: dict[str, IModel | Embeddings], + members: list[str], + response_converter: IResponseConverter | None = None, + ) -> None: gpt_4o = cast(IModel, models[ModelType.GPT4O]) self.model = gpt_4o self.members = members self.parser = self._route_create_parser() + self.response_converter: IResponseConverter = ( + response_converter or ResponseConverter() + ) self._planner_chain = self._create_planner_chain(gpt_4o) self._graph = self._build_graph() @@ -235,6 +244,15 @@ async def _generate_final_response(self, state: SupervisorState) -> dict[str, An ] } + async def _get_converted_final_response( + self, state: SupervisorState + ) -> dict[str, Any]: + """Convert the generated final response.""" + + final_response = await self._generate_final_response(state) + + return self.response_converter.convert_final_response(final_response) + def _build_graph(self) -> CompiledGraph: # Define a new graph. workflow = StateGraph(SupervisorState) @@ -242,7 +260,7 @@ def _build_graph(self) -> CompiledGraph: # Define the nodes of the graph. workflow.add_node(PLANNER, self._plan) workflow.add_node(ROUTER, self._route) - workflow.add_node(FINALIZER, self._generate_final_response) + workflow.add_node(FINALIZER, self._get_converted_final_response) # Set the entrypoint: ENTRY --> (planner | router | finalizer) workflow.add_conditional_edges( diff --git a/src/agents/supervisor/prompts.py b/src/agents/supervisor/prompts.py index a8afc668..2d101616 100644 --- a/src/agents/supervisor/prompts.py +++ b/src/agents/supervisor/prompts.py @@ -60,6 +60,7 @@ - Include ALL the provided code blocks (YAML, JavaScript, JSON, etc.) in the final response. - Remove any information regarding the agents and your decision-making process from your final response. - Do not add any more headers or sub-headers to the final response. +- If there is any YAML config , put the config in or block based on whether it is for new deployment or updating existing deployment. """ FINALIZER_PROMPT_FOLLOW_UP = """ diff --git a/tests/unit/agents/common/test_response_converter.py b/tests/unit/agents/common/test_response_converter.py new file mode 100644 index 00000000..d837151b --- /dev/null +++ b/tests/unit/agents/common/test_response_converter.py @@ -0,0 +1,480 @@ +import pytest +from langchain_core.messages import AIMessage + +from agents.common.constants import FINALIZER, MESSAGES, NEW_YAML, UPDATE_YAML +from agents.common.response_converter import ResponseConverter + + +@pytest.fixture +def response_converter(): + return ResponseConverter() + + +@pytest.mark.parametrize( + "description,yaml_content,expected_results", + [ + ( + "Response with new and update yaml configs", + """ +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + namespace: default +spec: + replicas: 3 +``` + + +some text +some text + + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + namespace: default +spec: + replicas: 5 +``` +""", + ( + [ + "```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: nginx-deployment\n namespace: default\nspec:\n replicas: 3\n```" + ], + [ + "```yaml\napiVersion: apps/v1\nkind: Deployment\nmetadata:\n name: nginx-deployment\n namespace: default\nspec:\n replicas: 5\n```" + ], + ), + ), + ("Response with no yaml configs", "No YAML content", ([], [])), + ( + "Response with invalid yaml configs", + "\ninvalid yaml\n", + (["invalid yaml"], []), + ), + ( + "Response with two yaml", + """To create an Nginx deployment with 3 replicas, you can use the following YAML configuration: + + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + namespace: nginx-oom +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:latest + ports: + - containerPort: 80 +``` + + +To apply this configuration, save it to a file (e.g., `nginx-deployment.yaml`) and run the following command: + +```bash +kubectl apply -f nginx-deployment.yaml +``` + +If you need to update the configuration of the existing Nginx deployment, for example, to change the image version or add environment variables, you can use the following YAML: + + +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + namespace: nginx-oom +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.21.0 # Updated image version + ports: + - containerPort: 80 + env: + - name: NGINX_ENV + value: "production" # New environment variable +``` + + +To apply this update, save the YAML to a file (e.g., `nginx-deployment-update.yaml`) and run: + +```bash +kubectl apply -f nginx-deployment-update.yaml +``` + +This will update the existing deployment with the new configuration.""", + ( + [ + """```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + namespace: nginx-oom +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:latest + ports: + - containerPort: 80 +```""" + ], + [ + """```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + namespace: nginx-oom +spec: + replicas: 3 + selector: + matchLabels: + app: nginx + template: + metadata: + labels: + app: nginx + spec: + containers: + - name: nginx + image: nginx:1.21.0 # Updated image version + ports: + - containerPort: 80 + env: + - name: NGINX_ENV + value: "production" # New environment variable +```""" + ], + ), + ), + ], +) +def test_extract_yaml(response_converter, description, yaml_content, expected_results): + new_yaml, update_yaml = response_converter._extract_yaml(yaml_content) + assert (new_yaml, update_yaml) == expected_results + + +@pytest.mark.parametrize( + "description, yaml_content,expected_result", + [ + ( + "yaml with yaml marker", + """```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test + namespace: default +```""", + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": {"name": "test", "namespace": "default"}, + }, + ), + ( + "yaml without yaml marker", + """apiVersion: apps/v1 +kind: Deployment +metadata: + name: test + namespace: default""", + { + "apiVersion": "apps/v1", + "kind": "Deployment", + "metadata": {"name": "test", "namespace": "default"}, + }, + ), + ("invalid yaml", "invalid: :\nyaml: content:", None), + ("No yaml", "", None), + ], +) +def test_parse_yamls(response_converter, description, yaml_content, expected_result): + assert response_converter._parse_yamls(yaml_content) == expected_result + + +@pytest.mark.parametrize( + "description, yaml_config,link_type,expected_link", + [ + ( + "Generate link for new deployment", + { + "metadata": {"namespace": "test-ns", "name": "test-deploy"}, + "kind": "Deployment", + }, + NEW_YAML, + "/namespaces/test-ns/Deployment", + ), + ( + "Generate link for updating old deployment", + { + "metadata": {"namespace": "test-ns", "name": "test-deploy"}, + "kind": "Deployment", + }, + UPDATE_YAML, + "/namespaces/test-ns/Deployment/test-deploy", + ), + ( + "Test no link generation", + {"metadata": {"namespace": "test-ns"}, "kind": "Deployment"}, + NEW_YAML, + None, + ), + ], +) +def test_generate_resource_link( + response_converter, description, yaml_config, link_type, expected_link +): + assert ( + response_converter._generate_resource_link(yaml_config, link_type) + == expected_link + ) + + +@pytest.mark.parametrize( + "description, yaml_content,resource_link,link_type,expected_contents", + [ + ( + "Test html generation", + "kind: Deployment", + "/test/link", + NEW_YAML, + [ + 'class="yaml-block', + 'class="yaml"', + 'class="link"', + "/test/link", + "kind: Deployment", + ], + ) + ], +) +def test_create_html_nested_yaml( + response_converter, + description, + yaml_content, + resource_link, + link_type, + expected_contents, +): + html = response_converter._create_html_nested_yaml( + yaml_content, resource_link, link_type + ) + for content in expected_contents: + assert content in html + + +@pytest.mark.parametrize( + "finalizer_response,replacement_html_list,yaml_type,expected", + [ + ( + """Some text before + + metadata: + name: test + + Some text after""", + ["
HTML Replace 1
"], + NEW_YAML, + """Some text before +
HTML Replace 1
+ Some text after""", + ), + ( + """First block + + block1 + + Second block + + block2 +""", + ["
Replace 1
", "
Replace 2
"], + UPDATE_YAML, + """First block +
Replace 1
+ Second block +
Replace 2
""", + ), + ( + """No YAML blocks here""", + ["
HTML
"], + NEW_YAML, + """No YAML blocks here""", + ), + ( + """block""", + [], + NEW_YAML, + """block""", + ), + ], + ids=[ + "single_replacement", + "multiple_replacements", + "no_blocks", + "empty_replacement_list", + ], +) +def test_replace_yaml_with_html( + response_converter, finalizer_response, replacement_html_list, yaml_type, expected +): + result = response_converter._replace_yaml_with_html( + finalizer_response, replacement_html_list.copy(), yaml_type + ) + # Normalize whitespace for comparison + assert " ".join(result.split()) == " ".join(expected.split()) + + +@pytest.mark.parametrize( + "yaml_list,yaml_type,expected", + [ + ( + [ + """ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + namespace: nginx-oom""" + ], + NEW_YAML, + [ + f""" +
+apiVersion: apps/v1 +kind: Deployment +metadata: + name: nginx-deployment + namespace: nginx-oom +
+ + + + """ + ], + ), + ( + [ + """invalid: :""", + """ +apiVersion: apps/v1 +kind: Service +metadata: + name: test-svc + namespace: test-ns""", + ], + UPDATE_YAML, + [ + """invalid: :""", + f""" +
+apiVersion: apps/v1 +kind: Service +metadata: + name: test-svc + namespace: test-ns +
+ + + + """, + ], + ), + ([], NEW_YAML, []), + ], + ids=["single_valid_yaml", "mixed_valid_invalid", "empty_list"], +) +def test_create_replacement_list(response_converter, yaml_list, yaml_type, expected): + result = response_converter._create_replacement_list(yaml_list, yaml_type) + + # Compare lengths + assert len(result) == len(expected) + + # Compare each element after normalizing whitespace + for res, exp in zip(result, expected, strict=False): + assert " ".join(res.split()) == " ".join(exp.split()) + + +@pytest.mark.parametrize( + "state_content,expected_content", + [ + ( + """ +```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test + namespace: default +``` +""", + """ +
+ ```yaml +apiVersion: apps/v1 +kind: Deployment +metadata: + name: test + namespace: default +``` +
+ + + """, + ), + ("No YAML content", "No YAML content"), + ("", ""), + ], + ids=["single_valid_yaml", "No YAML in Content", "Empty Response"], +) +def test_convert_final_response(response_converter, state_content, expected_content): + state = {"messages": [AIMessage(content=state_content, name=FINALIZER)]} + result = response_converter.convert_final_response(state) + assert " ".join(result[MESSAGES][0].content.split()) == " ".join( + expected_content.split() + ) diff --git a/tests/unit/agents/test_graph.py b/tests/unit/agents/test_graph.py index 807403c5..47aa7800 100644 --- a/tests/unit/agents/test_graph.py +++ b/tests/unit/agents/test_graph.py @@ -383,7 +383,8 @@ def test_agent_initialization(self, companion_graph, mock_models, mock_memory): # Verify SupervisorAgent was constructed with correct arguments mock_supervisor_cls.assert_called_once_with( - mock_models, members=["KymaAgent", "KubernetesAgent", "Common"] + mock_models, + members=["KymaAgent", "KubernetesAgent", "Common"], ) mock_build_graph.assert_called_once()