diff --git a/.image-version b/.image-version new file mode 100644 index 0000000..3eefcb9 --- /dev/null +++ b/.image-version @@ -0,0 +1 @@ +1.0.0 diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..397dc1b --- /dev/null +++ b/Dockerfile @@ -0,0 +1,25 @@ +FROM python:3.12-slim + +WORKDIR /app + +RUN apt-get update && apt-get install -y \ + curl \ + ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app/ ./app/ + +RUN useradd -m -u 1001 appuser && \ + chown -R appuser:appuser /app + +USER appuser + +EXPOSE 8080 + +HEALTHCHECK --interval=30s --timeout=10s --start-period=15s --retries=3 \ + CMD curl -f http://localhost:8080/health || exit 1 + +CMD ["uvicorn", "app.agent:app", "--host", "0.0.0.0", "--port", "8080"] diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/agent.py b/app/agent.py new file mode 100644 index 0000000..7761139 --- /dev/null +++ b/app/agent.py @@ -0,0 +1,148 @@ +""" +AutoRetrieveFilesFromAReposito Agent — auto-generated by Agent Factory. +""" +import asyncio +import logging +import os + +import click +import httpx +import uvicorn +from contextlib import asynccontextmanager +from dotenv import load_dotenv +from langchain_core.rate_limiters import InMemoryRateLimiter +from langchain_openai import AzureChatOpenAI + +from starlette.applications import Starlette +from starlette.requests import Request +from starlette.responses import JSONResponse +from starlette.routing import Route + +from app.config import ( + AGENT_SELF_URL, + AZURE_OPENAI_API_KEY, + AZURE_OPENAI_API_VERSION, + AZURE_OPENAI_DEPLOYMENT, + AZURE_OPENAI_ENDPOINT, + LOG_LEVEL, + REGISTRY_URL, +) +from app.skills import AGENT_CONFIG, AUTO_RETRIEVE_FILES_FROM_A_REPOSITO_SKILLS +from app.workflows.auto_retrieve_files_from_a_reposito_workflow import create_auto_retrieve_files_from_a_reposito_workflow + +load_dotenv() + +# ── Logging ────────────────────────────────────────────────────────────── +logging.basicConfig( + level=getattr(logging, LOG_LEVEL.upper(), logging.INFO), + format="%(asctime)s %(name)s %(levelname)s %(message)s", +) +logger = logging.getLogger(__name__) + +# ── LLM ────────────────────────────────────────────────────────────────── +rate_limiter = InMemoryRateLimiter( + requests_per_second=10 / 60, + check_every_n_seconds=0.1, + max_bucket_size=10, +) + +llm = AzureChatOpenAI( + temperature=0, + azure_deployment=AZURE_OPENAI_DEPLOYMENT, + api_version=AZURE_OPENAI_API_VERSION, + azure_endpoint=AZURE_OPENAI_ENDPOINT or "", + api_key=AZURE_OPENAI_API_KEY or "", + max_retries=5, + timeout=120, + rate_limiter=rate_limiter, +) + +workflow = create_auto_retrieve_files_from_a_reposito_workflow(llm) + + +# ── Endpoints ──────────────────────────────────────────────────────────── + +async def health_check(request: Request) -> JSONResponse: + return JSONResponse({"status": "healthy", "agent": 'AutoRetrieveFilesFromAReposito'}) + + +async def agent_manifest(request: Request) -> JSONResponse: + """GET /.well-known/agent.json""" + return JSONResponse({ + "name": AGENT_CONFIG["name"], + "version": AGENT_CONFIG["version"], + "description": AGENT_CONFIG["description"], + "url": "/", + "skills": [ + { + "id": s.id, + "name": s.name, + "description": s.description, + "tags": s.tags, + "inputSchema": AGENT_CONFIG.get("input_schema"), + "outputSchema": AGENT_CONFIG.get("output_schema"), + } for s in AUTO_RETRIEVE_FILES_FROM_A_REPOSITO_SKILLS + ], + "capabilities": AGENT_CONFIG["capabilities"], + }) + + +async def process_endpoint(request: Request) -> JSONResponse: + """POST /process — run the agent workflow.""" + try: + body = await request.json() + result = await workflow.ainvoke(body) + return JSONResponse(result) + except Exception as exc: + logger.error("Processing failed: %s", exc, exc_info=True) + return JSONResponse({"error": str(exc)}, status_code=500) + + +# ── Self-registration ──────────────────────────────────────────────────── + +async def _register_with_registry(): + if not REGISTRY_URL: + logger.info("REGISTRY_URL not set — skipping self-registration") + return + await asyncio.sleep(2) + url = f"{REGISTRY_URL.rstrip('/')}/agents/register-url" + for attempt in range(3): + try: + async with httpx.AsyncClient(timeout=10.0) as client: + resp = await client.post(url, json={"endpoint": AGENT_SELF_URL}) + if resp.status_code in (200, 201): + logger.info("Self-registered with agent-registry at %s", REGISTRY_URL) + return + logger.warning("Registration attempt %d: HTTP %d", attempt + 1, resp.status_code) + except Exception as exc: + logger.warning("Registration attempt %d failed: %s", attempt + 1, exc) + await asyncio.sleep(5) + logger.error("Failed to self-register after 3 attempts") + + +@asynccontextmanager +async def lifespan(app): + task = asyncio.create_task(_register_with_registry()) + yield + task.cancel() + + +app = Starlette( + routes=[ + Route("/health", methods=["GET"], endpoint=health_check), + Route("/.well-known/agent.json", methods=["GET"], endpoint=agent_manifest), + Route("/process", methods=["POST"], endpoint=process_endpoint), + ], + lifespan=lifespan, +) + + +@click.command() +@click.option("--host", default="0.0.0.0") +@click.option("--port", default=8080, type=int) +def main(host: str, port: int): + uvicorn.run(app, host=host, port=port, log_level=LOG_LEVEL.lower()) + + +if __name__ == "__main__": + main() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..8e92fcf --- /dev/null +++ b/app/config.py @@ -0,0 +1,21 @@ +""" +Configuration for the AutoRetrieveFilesFromAReposito agent. +""" +import os + +# ── Azure OpenAI ───────────────────────────────────────────────────────── +AZURE_OPENAI_ENDPOINT = os.getenv("AZURE_OPENAI_ENDPOINT") +AZURE_OPENAI_API_KEY = os.getenv("AZURE_OPENAI_API_KEY") +AZURE_OPENAI_API_VERSION = os.getenv("AZURE_OPENAI_API_VERSION", "2024-08-01-preview") +AZURE_OPENAI_DEPLOYMENT = os.getenv("AZURE_OPENAI_DEPLOYMENT", "gpt-4o") + +# ── Agent Registry ─────────────────────────────────────────────────────── +REGISTRY_URL = os.getenv( + "REGISTRY_URL", "http://agent-gateway.agents.svc.cluster.local" +) +AGENT_SELF_URL = os.getenv( + "AGENT_SELF_URL", "http://auto-retrieve-files-from-a-reposito.agents.svc.cluster.local" +) + +# ── Logging ────────────────────────────────────────────────────────────── +LOG_LEVEL = os.getenv("LOG_LEVEL", "INFO") diff --git a/app/nodes/__init__.py b/app/nodes/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/nodes/core_node.py b/app/nodes/core_node.py new file mode 100644 index 0000000..68b8475 --- /dev/null +++ b/app/nodes/core_node.py @@ -0,0 +1,26 @@ +async def process(state: dict) -> dict: + """ + This node retrieves files from a repository based on the provided URL and update scope. + It uses an LLM to analyze and extract relevant workflow files from the repository. + """ + from app.agent import llm + from langchain_core.messages import SystemMessage, HumanMessage + + try: + repository_url = state.get("repository_url", "") + update_scope = state.get("update_scope", "") + + if not repository_url or not update_scope: + raise ValueError("Both 'repository_url' and 'update_scope' must be provided.") + + messages = [ + SystemMessage(content="You are an assistant that retrieves workflow files from a repository."), + HumanMessage(content=f"Repository URL: {repository_url}\nUpdate Scope: {update_scope}") + ] + + response = await llm.ainvoke(messages) + workflow_files = response.content.splitlines() # Assuming the LLM returns file names as newline-separated strings. + + return {"workflow_files": workflow_files, "phase": "complete"} + except Exception as exc: + return {"error": str(exc), "phase": "failed"} \ No newline at end of file diff --git a/app/skills.py b/app/skills.py new file mode 100644 index 0000000..2439b38 --- /dev/null +++ b/app/skills.py @@ -0,0 +1,29 @@ +""" +A2A skill declarations for AutoRetrieveFilesFromAReposito. +""" +from a2a.types import AgentSkill + + +AUTO_RETRIEVE_FILES_FROM_A_REPOSITO_SKILLS = [ + AgentSkill( + id="auto_retrieve_files_from_a_reposito_skill", + name="AutoRetrieveFilesFromAReposito", + description="Retrieve files from a repository", + tags=["auto-generated"], + examples=[], + ), +] + + +AGENT_CONFIG = { + "name": "AutoRetrieveFilesFromAReposito", + "description": "Fetch all Java workflow files from the Gitea repository", + "version": "1.0.0", + "framework": "LangGraph + Starlette", + "capabilities": { + "streaming": False, + "async": True, + }, + "input_schema": {"repository_url": "string", "update_scope": "string"}, + "output_schema": {"workflow_files": "string[]"}, +} diff --git a/app/states/__init__.py b/app/states/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/states/auto_retrieve_files_from_a_reposito_state.py b/app/states/auto_retrieve_files_from_a_reposito_state.py new file mode 100644 index 0000000..fc906d6 --- /dev/null +++ b/app/states/auto_retrieve_files_from_a_reposito_state.py @@ -0,0 +1,17 @@ +""" +State definitions for AutoRetrieveFilesFromAReposito agent. +""" +from __future__ import annotations +from typing import Any, Dict, List, Optional +from langgraph.graph import MessagesState + + +class AutoRetrieveFilesFromARepositoState(MessagesState): + """Workflow state for AutoRetrieveFilesFromAReposito.""" + # ── Input fields ───────────────────────────────────────────────────── + pass + # ── Output fields ──────────────────────────────────────────────────── + pass + # ── Internal ───────────────────────────────────────────────────────── + error: Optional[str] + phase: str diff --git a/app/workflows/__init__.py b/app/workflows/__init__.py new file mode 100644 index 0000000..e69de29 diff --git a/app/workflows/auto_retrieve_files_from_a_reposito_workflow.py b/app/workflows/auto_retrieve_files_from_a_reposito_workflow.py new file mode 100644 index 0000000..7426baa --- /dev/null +++ b/app/workflows/auto_retrieve_files_from_a_reposito_workflow.py @@ -0,0 +1,19 @@ +""" +LangGraph workflow for AutoRetrieveFilesFromAReposito agent. +""" +from langgraph.graph import StateGraph, END + +from app.states.auto_retrieve_files_from_a_reposito_state import AutoRetrieveFilesFromARepositoState +from app.nodes.core_node import process + + +def create_auto_retrieve_files_from_a_reposito_workflow(llm): + """Build and compile the AutoRetrieveFilesFromAReposito workflow graph.""" + graph = StateGraph(AutoRetrieveFilesFromARepositoState) + + graph.add_node("process", process) + + graph.set_entry_point("process") + graph.add_edge("process", END) + + return graph.compile() diff --git a/catalog-info.yaml b/catalog-info.yaml new file mode 100644 index 0000000..10b3929 --- /dev/null +++ b/catalog-info.yaml @@ -0,0 +1,35 @@ +apiVersion: backstage.io/v1alpha1 +kind: Component +metadata: + name: auto-retrieve-files-from-a-reposito + description: "Fetch all Java workflow files from the Gitea repository" + annotations: + backstage.io/kubernetes-label-selector: app=auto-retrieve-files-from-a-reposito + backstage.io/kubernetes-namespace: agents + backstage.io/techdocs-ref: dir:. + gitea.kyndemo.live/repo-slug: generated-agents/auto-retrieve-files-from-a-reposito + grafana/grafana-instance: default + grafana/alert-label-selector: app=auto-retrieve-files-from-a-reposito + grafana/dashboard-selector: uid == 'otel-app-observability-v2' + grafana.com/dashboard-url: https://grafana.kyndemo.live/d/otel-app-observability-v2/opentelemetry-application-observability?orgId=1&var-app=auto-retrieve-files-from-a-reposito + tags: + - agent + - a2a + - auto-generated + links: + - icon: github + title: Source Repository + url: https://gitea.kyndemo.live/generated-agents/auto-retrieve-files-from-a-reposito + - icon: code + title: CI/CD Pipelines + url: https://gitea.kyndemo.live/generated-agents/auto-retrieve-files-from-a-reposito/actions + - icon: dashboard + title: Grafana Dashboard + url: https://grafana.kyndemo.live/d/otel-app-observability-v2/opentelemetry-application-observability?orgId=1&var-app=auto-retrieve-files-from-a-reposito +spec: + type: service + lifecycle: experimental + owner: group:default/agentic-agents + system: agentic-agents + dependsOn: + - resource:default/cjot-aks diff --git a/k8s/configmap.yaml b/k8s/configmap.yaml new file mode 100644 index 0000000..48852fe --- /dev/null +++ b/k8s/configmap.yaml @@ -0,0 +1,13 @@ +apiVersion: v1 +kind: ConfigMap +metadata: + name: auto-retrieve-files-from-a-reposito-config + namespace: agents +data: + REGISTRY_URL: "http://agent-gateway.agents.svc.cluster.local" + AGENT_SELF_URL: "http://auto-retrieve-files-from-a-reposito.agents.svc.cluster.local" + AZURE_OPENAI_DEPLOYMENT: "gpt-4o" + AZURE_OPENAI_API_VERSION: "2024-08-01-preview" + LOG_LEVEL: "INFO" + OTEL_SERVICE_NAME: "auto-retrieve-files-from-a-reposito" + OTEL_EXPORTER_OTLP_ENDPOINT: "http://otel-collector.monitoring.svc.cluster.local:4318" diff --git a/k8s/deployment.yaml b/k8s/deployment.yaml new file mode 100644 index 0000000..389090a --- /dev/null +++ b/k8s/deployment.yaml @@ -0,0 +1,81 @@ +apiVersion: apps/v1 +kind: Deployment +metadata: + name: auto-retrieve-files-from-a-reposito + namespace: agents + labels: + app: auto-retrieve-files-from-a-reposito + component: agent + generated-by: agent-factory +spec: + replicas: 1 + selector: + matchLabels: + app: auto-retrieve-files-from-a-reposito + template: + metadata: + labels: + app: auto-retrieve-files-from-a-reposito + azure.workload.identity/use: "true" + annotations: + instrumentation.opentelemetry.io/inject-python: "monitoring/otel-instrumentation" + spec: + serviceAccountName: agents-sa + containers: + - name: auto-retrieve-files-from-a-reposito + image: bstagecjotdevacr.azurecr.io/auto-retrieve-files-from-a-reposito:latest + imagePullPolicy: Always + ports: + - name: http + containerPort: 8080 + protocol: TCP + envFrom: + - configMapRef: + name: auto-retrieve-files-from-a-reposito-config + env: + - name: AZURE_OPENAI_ENDPOINT + valueFrom: + secretKeyRef: + name: agents-kv-sync + key: azure-openai-endpoint + - name: AZURE_OPENAI_API_KEY + valueFrom: + secretKeyRef: + name: agents-kv-sync + key: azure-openai-api-key + - name: OTEL_SERVICE_NAME + value: "auto-retrieve-files-from-a-reposito" + - name: OTEL_RESOURCE_ATTRIBUTES + value: "service.namespace=agents,deployment.environment=production" + resources: + requests: + memory: "512Mi" + cpu: "250m" + limits: + memory: "1Gi" + cpu: "500m" + livenessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 30 + periodSeconds: 10 + timeoutSeconds: 5 + failureThreshold: 30 + readinessProbe: + httpGet: + path: /health + port: http + initialDelaySeconds: 10 + periodSeconds: 5 + volumeMounts: + - name: secrets-store + mountPath: "/mnt/secrets-store" + readOnly: true + volumes: + - name: secrets-store + csi: + driver: secrets-store.csi.k8s.io + readOnly: true + volumeAttributes: + secretProviderClass: agents-kv-spc diff --git a/k8s/service.yaml b/k8s/service.yaml new file mode 100644 index 0000000..ea44f27 --- /dev/null +++ b/k8s/service.yaml @@ -0,0 +1,16 @@ +apiVersion: v1 +kind: Service +metadata: + name: auto-retrieve-files-from-a-reposito + namespace: agents + labels: + app: auto-retrieve-files-from-a-reposito +spec: + type: ClusterIP + ports: + - port: 80 + targetPort: http + protocol: TCP + name: http + selector: + app: auto-retrieve-files-from-a-reposito diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..64ac29f --- /dev/null +++ b/requirements.txt @@ -0,0 +1,18 @@ +# Auto-generated by Agent Factory +uvicorn[standard]==0.32.1 +starlette>=0.28.0 +pydantic>=2.11.3 +python-dotenv==1.0.1 +httpx>=0.28.1 +click>=8.1.8 + +# A2A Protocol +a2a-sdk[http-server]>=0.3.0 + +# LangChain & LangGraph +langchain>=1.2.10 +langchain-openai>=0.2.12 +langchain-core>=1.2.11 +langgraph>=0.2.59 +langgraph-checkpoint>=2.0.6 +openai>=1.109.1 diff --git a/scripts/deploy.sh b/scripts/deploy.sh new file mode 100644 index 0000000..d9cfb06 --- /dev/null +++ b/scripts/deploy.sh @@ -0,0 +1,54 @@ +#!/bin/bash +# deploy.sh — Build auto-retrieve-files-from-a-reposito image in ACR and deploy to AKS +set -e + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +ACR_NAME="bstagecjotdevacr" +APP_NAME="auto-retrieve-files-from-a-reposito" +NAMESPACE="agents" +VERSION_FILE="${SCRIPT_DIR}/../.image-version" +TAG="" + +while [[ "$#" -gt 0 ]]; do + case $1 in + --tag) TAG="$2"; shift ;; + esac + shift +done + +if [ -z "$TAG" ]; then + if [ -f "$VERSION_FILE" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + else + CURRENT_VERSION="1.0.0" + fi + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + PATCH=$((PATCH + 1)) + TAG="${MAJOR}.${MINOR}.${PATCH}" + echo "$TAG" > "$VERSION_FILE" +fi + +IMAGE="${ACR_NAME}.azurecr.io/${APP_NAME}:${TAG}" + +echo "======================================================================" +echo " auto-retrieve-files-from-a-reposito — Build & Deploy" +echo " Image: ${IMAGE}" +echo "======================================================================" + +az acr build --registry ${ACR_NAME} \ + --image ${APP_NAME}:${TAG} \ + --image ${APP_NAME}:latest \ + "${SCRIPT_DIR}/.." + +kubectl apply -f "${SCRIPT_DIR}/../k8s/configmap.yaml" 2>/dev/null || true +kubectl apply -f "${SCRIPT_DIR}/../k8s/deployment.yaml" +kubectl apply -f "${SCRIPT_DIR}/../k8s/service.yaml" 2>/dev/null || true + +kubectl set image deployment/${APP_NAME} \ + ${APP_NAME}=${IMAGE} \ + -n ${NAMESPACE} + +kubectl rollout status deployment/${APP_NAME} -n ${NAMESPACE} --timeout=120s + +echo "✓ Done. Image: ${IMAGE}" +echo " Port forward: kubectl port-forward -n ${NAMESPACE} deployment/${APP_NAME} 8080:8080" diff --git a/scripts/full-deploy.sh b/scripts/full-deploy.sh new file mode 100644 index 0000000..a3ee8dc --- /dev/null +++ b/scripts/full-deploy.sh @@ -0,0 +1,66 @@ +#!/bin/bash +# full-deploy.sh — Preflight + build + deploy auto-retrieve-files-from-a-reposito to AKS +set -e + +GREEN='\033[0;32m' +RED='\033[0;31m' +NC='\033[0m' + +SCRIPT_DIR=$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd) +ACR_NAME="bstagecjotdevacr" +APP_NAME="auto-retrieve-files-from-a-reposito" +NAMESPACE="agents" +VERSION_FILE="${SCRIPT_DIR}/../.image-version" +TAG="" + +while [[ "$#" -gt 0 ]]; do + case $1 in + --tag) TAG="$2"; shift ;; + esac + shift +done + +print_success() { echo -e "${GREEN}✓ $1${NC}"; } +print_error() { echo -e "${RED}✗ $1${NC}"; exit 1; } + +command -v kubectl &>/dev/null || print_error "kubectl not found." +command -v az &>/dev/null || print_error "Azure CLI not found." +az account show &>/dev/null || print_error "Not logged in to Azure." +kubectl cluster-info &>/dev/null || print_error "Cannot connect to cluster." +CLUSTER=$(kubectl config current-context) +print_success "Connected to cluster: ${CLUSTER}" + +if [ -z "$TAG" ]; then + if [ -f "$VERSION_FILE" ]; then + CURRENT_VERSION=$(cat "$VERSION_FILE") + else + CURRENT_VERSION="1.0.0" + fi + IFS='.' read -r MAJOR MINOR PATCH <<< "$CURRENT_VERSION" + PATCH=$((PATCH + 1)) + TAG="${MAJOR}.${MINOR}.${PATCH}" + echo "$TAG" > "$VERSION_FILE" +fi + +IMAGE="${ACR_NAME}.azurecr.io/${APP_NAME}:${TAG}" +print_success "Image tag: ${TAG}" + +az acr build --registry ${ACR_NAME} \ + --image ${APP_NAME}:${TAG} \ + --image ${APP_NAME}:latest \ + "${SCRIPT_DIR}/.." || print_error "ACR build failed." +print_success "Image built: ${IMAGE}" + +kubectl apply -f "${SCRIPT_DIR}/../k8s/configmap.yaml" 2>/dev/null || true +kubectl apply -f "${SCRIPT_DIR}/../k8s/deployment.yaml" || print_error "Failed to apply deployment." +kubectl apply -f "${SCRIPT_DIR}/../k8s/service.yaml" || print_error "Failed to apply service." +print_success "Manifests applied" + +kubectl set image deployment/${APP_NAME} ${APP_NAME}=${IMAGE} -n ${NAMESPACE} +kubectl rollout status deployment/${APP_NAME} -n ${NAMESPACE} --timeout=180s || \ + print_error "Rollout failed." +print_success "Rollout complete" + +echo "" +echo -e "${GREEN} auto-retrieve-files-from-a-reposito deployed: ${IMAGE}${NC}" +echo " Port forward: kubectl port-forward -n ${NAMESPACE} deployment/${APP_NAME} 8080:8080"