From 033dded8d757fe4e49ff9fe665e63fb9acbfab86 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Wed, 22 Jul 2026 12:30:40 +0000 Subject: [PATCH 1/6] Integrate Agentic ingress with WebUI --- deploy/tfs.sh | 8 ++++ manifests/agenticservice.yaml | 1 + src/agentic/Config.py | 13 ++++++ src/agentic/README.md | 61 +++++++++++++++++++++++++++ src/agentic/agent.py | 17 ++++++++ src/agentic/service/domain_api.py | 13 +++++- src/agentic/service/settings.py | 2 + src/agentic/tests/test_integration.py | 10 +++++ src/agentic/tests/test_unitary.py | 9 ++++ src/common/Settings.py | 16 +++++-- src/webui/service/__init__.py | 6 ++- src/webui/service/templates/base.html | 6 +++ 12 files changed, 156 insertions(+), 6 deletions(-) create mode 100644 src/agentic/agent.py diff --git a/deploy/tfs.sh b/deploy/tfs.sh index 887d73630..79b72142b 100755 --- a/deploy/tfs.sh +++ b/deploy/tfs.sh @@ -340,6 +340,14 @@ for COMPONENT in $TFS_COMPONENTS; do sed -E -i "s#image: $GITLAB_REPO_URL/$COMPONENT:${VERSION}#image: $IMAGE_URL#g" "$MANIFEST" fi + if [ "$COMPONENT" == "webui" ] && [[ "$TFS_COMPONENTS" == *"agentic"* ]]; then + sed -E -i '/value: "\/webui\/"/ a\ + - name: AGENTICSERVICE_SERVICE_HOST\ + value: "agenticservice"\ + - name: AGENTICSERVICE_SERVICE_PORT_HTTP\ + value: "8800"' "$MANIFEST" + fi + sed -E -i "s#imagePullPolicy: .*#imagePullPolicy: Always#g" "$MANIFEST" # TODO: harmonize names of the monitoring component diff --git a/manifests/agenticservice.yaml b/manifests/agenticservice.yaml index 006669f1c..dd9701601 100644 --- a/manifests/agenticservice.yaml +++ b/manifests/agenticservice.yaml @@ -19,6 +19,7 @@ metadata: data: ADK_MODEL: "openai/gpt-4.1-mini" ADK_AGENT_GRAPH: "single" + ADK_HTTP_ROOT_PATH: "/agentic" ADK_DOMAIN_ID: "A" ADK_DOMAIN_NAME: "domain-a" ADK_PEERS: "" diff --git a/src/agentic/Config.py b/src/agentic/Config.py index 818cfcc12..f17cfc7cb 100644 --- a/src/agentic/Config.py +++ b/src/agentic/Config.py @@ -25,6 +25,7 @@ DEFAULT_AGENTIC_SPECTRUM_DB_PATH = "/var/lib/tfs-agentic/adk_spectrum.sqlite" DEFAULT_AGENTIC_A2A_TIMEOUT_SECONDS = "180" DEFAULT_AGENTIC_DEFAULT_SLOT_WIDTH = "16" DEFAULT_AGENTIC_DOMAIN_API_PORT = "8800" +DEFAULT_AGENTIC_HTTP_ROOT_PATH = "/agentic" DEFAULT_AGENTIC_MCP_STARTUP_WARMUP_TIMEOUT_SECONDS = "10" DEFAULT_AGENTIC_MCP_TOOL_TIMEOUT_SECONDS = "30" DEFAULT_AGENTIC_RUN_TIMEOUT_SECONDS = "120" @@ -50,6 +51,7 @@ ENVVAR_ADK_A2A_PUBLIC_URL = "ADK_A2A_PUBLIC_URL" ENVVAR_ADK_A2A_TIMEOUT_SECONDS = "ADK_A2A_TIMEOUT_SECONDS" ENVVAR_ADK_DEFAULT_SLOT_WIDTH = "ADK_DEFAULT_SLOT_WIDTH" ENVVAR_ADK_DOMAIN_API_PORT = "ADK_DOMAIN_API_PORT" +ENVVAR_ADK_HTTP_ROOT_PATH = "ADK_HTTP_ROOT_PATH" ENVVAR_ADK_MODEL = "ADK_MODEL" ENVVAR_ADK_AGENT_GRAPH = "ADK_AGENT_GRAPH" ENVVAR_ADK_DOMAIN_ID = "ADK_DOMAIN_ID" @@ -201,6 +203,17 @@ def get_agentic_domain_api_port() -> int: ) +def get_agentic_http_root_path() -> str: + root_path = get_setting( + ENVVAR_ADK_HTTP_ROOT_PATH, + default=DEFAULT_AGENTIC_HTTP_ROOT_PATH, + ) + root_path = root_path.strip().rstrip("/") + if not root_path: + return "" + return root_path if root_path.startswith("/") else f"/{root_path}" + + def get_agentic_graph_normalized() -> str: return get_agentic_graph().strip().lower() or DEFAULT_AGENTIC_GRAPH diff --git a/src/agentic/README.md b/src/agentic/README.md index 208904ae6..1aa14cedf 100644 --- a/src/agentic/README.md +++ b/src/agentic/README.md @@ -26,6 +26,7 @@ Server components are available: ```bash cd ~/tfs-ctrl TFS_COMPONENTS="context device pathcomp opticalcontroller service nbi webui mcp_server agentic" \ +TFS_EXTRA_MANIFESTS="manifests/nginx_ingress_http.yaml" \ CRDB_DROP_DATABASE_IF_EXISTS=YES \ ./deploy/all.sh ``` @@ -38,6 +39,13 @@ The deployment manifest creates: - `agenticservice`: HTTP service on port `8800`. - `tfs-ingress-agentic`: ingress path `/agentic`. +When WebUI and Agentic are both selected in `TFS_COMPONENTS`, the TFS WebUI +navigation bar exposes an `Agentic` entry. The link opens the Agentic service +root through `/agentic`, which redirects to the generated FastAPI +documentation under `/agentic/docs`. +Expose the WebUI ingress with `manifests/nginx_ingress_http.yaml` to reach the +TFS WebUI through `/webui/`. + Do not commit real LLM API keys, MCP tokens, SSH keys, or controller credentials. Patch secrets at deployment time: @@ -62,6 +70,8 @@ Main settings: - `ADK_MODEL`: LLM model identifier. Default is `openai/gpt-4.1-mini`. - `ADK_AGENT_GRAPH`: agent graph to run. Default is `single`; `granular` is kept for debugging and fallback. +- `ADK_HTTP_ROOT_PATH`: HTTP prefix used when Agentic is exposed behind the + TFS ingress. Default is `/agentic`. - `ADK_DOMAIN_ID`: short domain identifier such as `A`. - `ADK_DOMAIN_NAME`: human-readable domain name such as `domain-a`. - `ADK_PEERS`: comma-separated peer map in the form @@ -131,6 +141,19 @@ The component source is split into: `GET /health` returns component health, local domain ID, peer summary, session database health, and startup warm-up results. +`GET /` redirects to the generated Agentic API documentation. Through the TFS +ingress this is reachable as: + +```text +http:///agentic +``` + +or directly as: + +```text +http:///agentic/docs +``` + `POST /agent/run` receives operator-facing natural-language requests: ```bash @@ -165,6 +188,44 @@ Diagnostic endpoints are also available for direct workflow tests: Prefer `/agent/run` for operator-facing validation. Use direct diagnostic endpoints only for integration tests and failure isolation. +## Google ADK WebUI + +Google ADK WebUI is intended for development and debugging of the agent graph. +The production TFS component runs the Agentic FastAPI service on port `8800`; +it does not start `adk web` as a second long-running process. + +The package exposes `agentic.agent:root_agent` so ADK Web can discover the +same root agent used by the service. From a development checkout with the +Agentic dependencies installed, start ADK Web from the directory containing the +`agentic` package: + +```bash +cd ~/tfs-ctrl/src +PYTHONPATH=. adk web --host 0.0.0.0 --port 8000 +``` + +Then open: + +```text +http://:8000 +``` + +and select the `agentic` app. Use the same environment variables documented +above, especially `ADK_MODEL`, `OPENAI_API_KEY`, `TFS_MCP_URL`, `ADK_DOMAIN_ID`, +`ADK_PEERS`, and the session/spectrum database paths. + +For Kubernetes debugging, run ADK Web in a temporary debug pod or side process +using the same image and environment as `agenticservice`, then expose or +port-forward its port: + +```bash +kubectl -n tfs port-forward deployment/agenticservice 8800:8800 +``` + +The command above exposes the production Agentic API locally as +`http://127.0.0.1:8800`. If a separate ADK Web debug process is started on port +`8000`, port-forward that process instead and open `http://127.0.0.1:8000`. + ## Smoke Tests After deployment, check health: diff --git a/src/agentic/agent.py b/src/agentic/agent.py new file mode 100644 index 000000000..0c28f509a --- /dev/null +++ b/src/agentic/agent.py @@ -0,0 +1,17 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +from agentic.service.agent import root_agent + +__all__ = ("root_agent",) diff --git a/src/agentic/service/domain_api.py b/src/agentic/service/domain_api.py index 3158d62ec..6ae9ece33 100644 --- a/src/agentic/service/domain_api.py +++ b/src/agentic/service/domain_api.py @@ -23,6 +23,7 @@ import uuid from typing import Any, Dict, List from fastapi import FastAPI +from fastapi.responses import RedirectResponse from google.adk.runners import Runner from google.adk.sessions import InMemorySessionService from google.genai import types @@ -46,6 +47,7 @@ from agentic.service.settings import ( DEFAULT_SLOT_WIDTH, DOMAIN_ID, DOMAIN_NAME, + HTTP_ROOT_PATH, IS_DUMMY_DETERMINISTIC_MODE, MCP_STARTUP_WARMUP_TIMEOUT_SECONDS, ) @@ -159,7 +161,11 @@ class AgentRunRequest(BaseModel): session_id: str = "" -app = FastAPI(title="TFS Agentic Domain API", version="0.1.0") +app = FastAPI( + title="TFS Agentic Domain API", + version="0.1.0", + root_path=HTTP_ROOT_PATH, +) install_a2a_routes(app) LOGGER = logging.getLogger("uvicorn.error") _agent_session_service = InMemorySessionService() @@ -172,6 +178,11 @@ _agent_sessions: set[tuple[str, str]] = set() _startup_warmup_results: list[dict[str, Any]] = [] +@app.get("/") +async def root() -> RedirectResponse: + return RedirectResponse(url=f"{HTTP_ROOT_PATH}/docs") + + @app.on_event("startup") async def startup() -> None: """Warm long-lived controller-facing sessions before the first request.""" diff --git a/src/agentic/service/settings.py b/src/agentic/service/settings.py index 3c3efd525..0e18204ca 100644 --- a/src/agentic/service/settings.py +++ b/src/agentic/service/settings.py @@ -25,6 +25,7 @@ from agentic.Config import ( get_agentic_domain_id, get_agentic_domain_name, get_agentic_graph_normalized, + get_agentic_http_root_path, get_agentic_mcp_auth_token, get_agentic_mcp_startup_warmup_timeout_seconds, get_agentic_mcp_tool_timeout_seconds, @@ -61,6 +62,7 @@ DEFAULT_SLOT_WIDTH = get_agentic_default_slot_width() DEFAULT_CONTEXT_UUID = get_tfs_default_context_uuid() TOOL_PROFILE = get_agentic_tool_profile() AGENT_GRAPH = get_agentic_graph_normalized() +HTTP_ROOT_PATH = get_agentic_http_root_path() AGENT_RUN_TIMEOUT_SECONDS = get_agentic_run_timeout_seconds() MCP_STARTUP_WARMUP_TIMEOUT_SECONDS = ( get_agentic_mcp_startup_warmup_timeout_seconds() diff --git a/src/agentic/tests/test_integration.py b/src/agentic/tests/test_integration.py index efb78720e..834ff3144 100644 --- a/src/agentic/tests/test_integration.py +++ b/src/agentic/tests/test_integration.py @@ -138,6 +138,16 @@ def test_inventory_workflow_reads_mocked_mcp(monkeypatch) -> None: assert recorded_events +def test_domain_api_root_redirects_to_docs() -> None: + domain_api = importlib.import_module("agentic.service.domain_api") + + reply = asyncio.run(domain_api.root()) + + assert reply.status_code == 307 + assert reply.headers["location"] == "/agentic/docs" + assert domain_api.app.root_path == "/agentic" + + def test_agent_run_uses_mocked_llm_runner(monkeypatch) -> None: domain_api = importlib.import_module("agentic.service.domain_api") diff --git a/src/agentic/tests/test_unitary.py b/src/agentic/tests/test_unitary.py index 92f41dd81..382e75ce5 100644 --- a/src/agentic/tests/test_unitary.py +++ b/src/agentic/tests/test_unitary.py @@ -29,6 +29,7 @@ from agentic.service.tools.service_workflow import ( _resolve_device_uuid_from_details, _resolve_endpoint_uuid_from_details, ) +from common.Settings import is_deployed_agentic def test_agentic_default_config(monkeypatch) -> None: @@ -61,6 +62,14 @@ def test_dummy_deterministic_model_does_not_require_api_key(monkeypatch) -> None validate_agentic_llm_configuration() +def test_agentic_deployment_detection_accepts_http_only(monkeypatch) -> None: + monkeypatch.setenv("AGENTICSERVICE_SERVICE_HOST", "agenticservice") + monkeypatch.setenv("AGENTICSERVICE_SERVICE_PORT_HTTP", "8800") + monkeypatch.delenv("AGENTICSERVICE_SERVICE_PORT_GRPC", raising=False) + + assert is_deployed_agentic() is True + + def test_parse_peers(monkeypatch) -> None: peer_spec = "B=http://b.example/a2a, C=http://c.example/a2a/" monkeypatch.setenv("ADK_PEERS", peer_spec) diff --git a/src/common/Settings.py b/src/common/Settings.py index 192e3bdc7..8cb102fec 100644 --- a/src/common/Settings.py +++ b/src/common/Settings.py @@ -116,9 +116,19 @@ def get_http_bind_address(): def is_microservice_deployed(service_name : ServiceNameEnum) -> bool: host_env_var_name = get_env_var_name(service_name, ENVVAR_SUFIX_SERVICE_HOST ) - port_env_var_name = get_env_var_name(service_name, ENVVAR_SUFIX_SERVICE_PORT_GRPC) - return (host_env_var_name in os.environ) and (port_env_var_name in os.environ) - + port_grpc_env_var_name = get_env_var_name( + service_name, ENVVAR_SUFIX_SERVICE_PORT_GRPC) + port_http_env_var_name = get_env_var_name( + service_name, ENVVAR_SUFIX_SERVICE_PORT_HTTP) + return ( + (host_env_var_name in os.environ) + and ( + (port_grpc_env_var_name in os.environ) + or (port_http_env_var_name in os.environ) + ) + ) + +def is_deployed_agentic () -> bool: return is_microservice_deployed(ServiceNameEnum.AGENTIC ) def is_deployed_bgpls () -> bool: return is_microservice_deployed(ServiceNameEnum.BGPLS ) def is_deployed_e2e_orch () -> bool: return is_microservice_deployed(ServiceNameEnum.E2EORCHESTRATOR ) def is_deployed_forecaster() -> bool: return is_microservice_deployed(ServiceNameEnum.FORECASTER ) diff --git a/src/webui/service/__init__.py b/src/webui/service/__init__.py index c5c211363..32c125199 100644 --- a/src/webui/service/__init__.py +++ b/src/webui/service/__init__.py @@ -20,8 +20,9 @@ from common.tools.grpc.Tools import grpc_message_to_json from context.client.ContextClient import ContextClient from device.client.DeviceClient import DeviceClient from common.Settings import ( - is_deployed_bgpls, is_deployed_load_gen, is_deployed_optical, - is_deployed_policy, is_deployed_qkd_app, is_deployed_slice + is_deployed_agentic, is_deployed_bgpls, is_deployed_load_gen, + is_deployed_optical, is_deployed_policy, is_deployed_qkd_app, + is_deployed_slice ) def get_working_context() -> str: @@ -129,6 +130,7 @@ def create_app(use_config=None, web_app_root=None): 'get_working_context' : get_working_context, 'get_working_topology': get_working_topology, + 'is_deployed_agentic' : is_deployed_agentic, 'is_deployed_bgpls' : is_deployed_bgpls, 'is_deployed_load_gen': is_deployed_load_gen, 'is_deployed_optical' : is_deployed_optical, diff --git a/src/webui/service/templates/base.html b/src/webui/service/templates/base.html index 41d8e86e6..8c44bd100 100644 --- a/src/webui/service/templates/base.html +++ b/src/webui/service/templates/base.html @@ -137,6 +137,12 @@ {% endif %} + {% if is_deployed_agentic() %} + + {% endif %} + -- GitLab From c5e5899d0fb784980f377d3604d9c15001c989dc Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Wed, 22 Jul 2026 14:33:42 +0000 Subject: [PATCH 2/6] Run Agentic through ADK Web A2A --- deploy/component.sh | 8 ++ deploy/tfs.sh | 10 +- manifests/agenticservice.yaml | 27 ++-- src/agentic/.gitlab-ci.yml | 2 +- src/agentic/Config.py | 2 +- src/agentic/Dockerfile | 13 +- src/agentic/README.md | 137 +++++++++----------- src/agentic/service/a2a_transport.py | 48 ++++--- src/agentic/service/adk_a2a_bridge.py | 136 +++++++++++++++++++ src/agentic/service/adk_web.py | 64 +++++++++ src/agentic/service/agents/granular_root.py | 2 + src/agentic/service/agents/root.py | 2 + src/agentic/service/agents/single.py | 2 + src/agentic/service/peer_client.py | 11 +- src/agentic/tests/test_integration.py | 4 +- src/agentic/tests/test_unitary.py | 30 ++++- src/common/Constants.py | 2 +- src/webui/service/templates/main/debug.html | 3 + src/webui/tests/test_unitary.py | 13 ++ 19 files changed, 400 insertions(+), 116 deletions(-) create mode 100644 src/agentic/service/adk_a2a_bridge.py create mode 100644 src/agentic/service/adk_web.py diff --git a/deploy/component.sh b/deploy/component.sh index 65b56197d..5d0ced89b 100755 --- a/deploy/component.sh +++ b/deploy/component.sh @@ -161,6 +161,14 @@ for COMPONENT in $TFS_COMPONENTS; do echo " Deploying '$COMPONENT' component to Kubernetes..." DEPLOY_LOG="$TMP_LOGS_FOLDER/deploy_${COMPONENT}.log" + if [ "$COMPONENT" == "agentic" ]; then + kubectl --namespace $TFS_K8S_NAMESPACE delete ingress \ + tfs-ingress-agentic --ignore-not-found >> "$DEPLOY_LOG" + kubectl --namespace $TFS_K8S_NAMESPACE delete ingress \ + tfs-ingress-agentic-api --ignore-not-found >> "$DEPLOY_LOG" + kubectl --namespace $TFS_K8S_NAMESPACE delete ingress \ + tfs-ingress-agentic-web --ignore-not-found >> "$DEPLOY_LOG" + fi kubectl --namespace $TFS_K8S_NAMESPACE delete -f "$MANIFEST" > "$DEPLOY_LOG" kubectl --namespace $TFS_K8S_NAMESPACE apply -f "$MANIFEST" > "$DEPLOY_LOG" COMPONENT_OBJNAME=$(echo "${COMPONENT}" | sed "s/\_/-/") diff --git a/deploy/tfs.sh b/deploy/tfs.sh index 79b72142b..0ebf6969e 100755 --- a/deploy/tfs.sh +++ b/deploy/tfs.sh @@ -345,7 +345,7 @@ for COMPONENT in $TFS_COMPONENTS; do - name: AGENTICSERVICE_SERVICE_HOST\ value: "agenticservice"\ - name: AGENTICSERVICE_SERVICE_PORT_HTTP\ - value: "8800"' "$MANIFEST" + value: "8000"' "$MANIFEST" fi sed -E -i "s#imagePullPolicy: .*#imagePullPolicy: Always#g" "$MANIFEST" @@ -354,6 +354,14 @@ for COMPONENT in $TFS_COMPONENTS; do echo " Deploying '$COMPONENT' component to Kubernetes..." DEPLOY_LOG="$TMP_LOGS_FOLDER/deploy_${COMPONENT}.log" + if [ "$COMPONENT" == "agentic" ]; then + kubectl --namespace $TFS_K8S_NAMESPACE delete ingress \ + tfs-ingress-agentic --ignore-not-found > "$DEPLOY_LOG" + kubectl --namespace $TFS_K8S_NAMESPACE delete ingress \ + tfs-ingress-agentic-api --ignore-not-found >> "$DEPLOY_LOG" + kubectl --namespace $TFS_K8S_NAMESPACE delete ingress \ + tfs-ingress-agentic-web --ignore-not-found >> "$DEPLOY_LOG" + fi kubectl --namespace $TFS_K8S_NAMESPACE apply -f "$MANIFEST" > "$DEPLOY_LOG" COMPONENT_OBJNAME=$(echo "${COMPONENT}" | sed "s/\_/-/g") #kubectl --namespace $TFS_K8S_NAMESPACE scale deployment --replicas=0 ${COMPONENT_OBJNAME}service >> "$DEPLOY_LOG" diff --git a/manifests/agenticservice.yaml b/manifests/agenticservice.yaml index dd9701601..0eeb95393 100644 --- a/manifests/agenticservice.yaml +++ b/manifests/agenticservice.yaml @@ -59,6 +59,8 @@ kind: Deployment metadata: name: agenticservice spec: + strategy: + type: Recreate selector: matchLabels: app: agenticservice @@ -73,8 +75,14 @@ spec: - name: server image: labs.etsi.org:5050/tfs/controller/agentic:latest imagePullPolicy: Always + workingDir: /var/teraflow/adk_apps + command: + - python + args: + - -m + - agentic.service.adk_web ports: - - containerPort: 8800 + - containerPort: 8000 env: - name: LOG_LEVEL value: "INFO" @@ -84,6 +92,8 @@ spec: value: "" - name: ADK_DISABLE_JSON_SCHEMA_FOR_FUNC_DECL value: "1" + - name: PYTHONPATH + value: "/var/teraflow" envFrom: - configMapRef: name: agentic-config @@ -94,15 +104,15 @@ spec: mountPath: /var/lib/tfs-agentic readinessProbe: httpGet: - path: /health - port: 8800 + path: /dev-ui/ + port: 8000 initialDelaySeconds: 10 periodSeconds: 10 failureThreshold: 12 livenessProbe: httpGet: - path: /health - port: 8800 + path: /dev-ui/ + port: 8000 initialDelaySeconds: 20 periodSeconds: 20 failureThreshold: 6 @@ -131,8 +141,8 @@ spec: ports: - name: http protocol: TCP - port: 8800 - targetPort: 8800 + port: 8000 + targetPort: 8000 --- apiVersion: networking.k8s.io/v1 kind: Ingress @@ -145,6 +155,7 @@ metadata: nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" nginx.ingress.kubernetes.io/rewrite-target: /$2 + nginx.ingress.kubernetes.io/use-regex: "true" spec: rules: - http: @@ -155,4 +166,4 @@ spec: service: name: agenticservice port: - number: 8800 + number: 8000 diff --git a/src/agentic/.gitlab-ci.yml b/src/agentic/.gitlab-ci.yml index a6a852f26..09db6ab10 100644 --- a/src/agentic/.gitlab-ci.yml +++ b/src/agentic/.gitlab-ci.yml @@ -72,7 +72,7 @@ unit_test agentic: script: - docker pull "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" - > - docker run --name $IMAGE_NAME -d -p 8800:8800 + docker run --name $IMAGE_NAME -d -p 8000:8000 --env LOG_LEVEL=INFO --env ADK_MODEL=dummy-deterministic --env ADK_AGENT_STARTUP_WARMUP_ENABLED=0 diff --git a/src/agentic/Config.py b/src/agentic/Config.py index f17cfc7cb..6a013dc74 100644 --- a/src/agentic/Config.py +++ b/src/agentic/Config.py @@ -24,7 +24,7 @@ DEFAULT_AGENTIC_SESSION_DB_PATH = "/var/lib/tfs-agentic/adk_sessions.sqlite" DEFAULT_AGENTIC_SPECTRUM_DB_PATH = "/var/lib/tfs-agentic/adk_spectrum.sqlite" DEFAULT_AGENTIC_A2A_TIMEOUT_SECONDS = "180" DEFAULT_AGENTIC_DEFAULT_SLOT_WIDTH = "16" -DEFAULT_AGENTIC_DOMAIN_API_PORT = "8800" +DEFAULT_AGENTIC_DOMAIN_API_PORT = "8000" DEFAULT_AGENTIC_HTTP_ROOT_PATH = "/agentic" DEFAULT_AGENTIC_MCP_STARTUP_WARMUP_TIMEOUT_SECONDS = "10" DEFAULT_AGENTIC_MCP_TOOL_TIMEOUT_SECONDS = "30" diff --git a/src/agentic/Dockerfile b/src/agentic/Dockerfile index 2153a9a4d..ba17e744a 100644 --- a/src/agentic/Dockerfile +++ b/src/agentic/Dockerfile @@ -34,14 +34,13 @@ RUN python3 -m pip install -r requirements.txt WORKDIR /var/teraflow COPY src/common/. common/ COPY src/agentic/. agentic/ +RUN mkdir -p adk_apps/agentic +COPY src/agentic/agent.py adk_apps/agentic/agent.py -EXPOSE 8800 +EXPOSE 8000 ENTRYPOINT [ \ - "uvicorn", \ - "agentic.service.domain_api:app", \ - "--host", \ - "0.0.0.0", \ - "--port", \ - "8800" \ + "python", \ + "-m", \ + "agentic.service.adk_web" \ ] diff --git a/src/agentic/README.md b/src/agentic/README.md index 1aa14cedf..9194e0e6f 100644 --- a/src/agentic/README.md +++ b/src/agentic/README.md @@ -36,13 +36,15 @@ The deployment manifest creates: - `agentic-config`: non-secret runtime configuration. - `agentic-secrets`: LLM and MCP secret material. - `agentic-data`: persistent volume claim for SQLite session state. -- `agenticservice`: HTTP service on port `8800`. -- `tfs-ingress-agentic`: ingress path `/agentic`. +- `agenticservice`: HTTP service exposing Google ADK Web and ADK A2A on + port `8000`. +- `tfs-ingress-agentic`: ingress path `/agentic` for Google ADK Web, ADK + API documentation, and ADK A2A endpoints. When WebUI and Agentic are both selected in `TFS_COMPONENTS`, the TFS WebUI -navigation bar exposes an `Agentic` entry. The link opens the Agentic service -root through `/agentic`, which redirects to the generated FastAPI -documentation under `/agentic/docs`. +navigation bar exposes an `Agentic` entry. The link opens Google ADK Web +through `/agentic`. The ADK API documentation remains available from the TFS +WebUI Debug page through `/agentic/docs`. Expose the WebUI ingress with `manifests/nginx_ingress_http.yaml` to reach the TFS WebUI through `/webui/`. @@ -75,7 +77,8 @@ Main settings: - `ADK_DOMAIN_ID`: short domain identifier such as `A`. - `ADK_DOMAIN_NAME`: human-readable domain name such as `domain-a`. - `ADK_PEERS`: comma-separated peer map in the form - `B=http://agentic-b:8800,C=http://agentic-c:8800`. + `B=http://peer-b/agentic,C=http://peer-c/agentic`. Agentic expands each + `/agentic` peer URL to the corresponding ADK A2A endpoint. - `TFS_MCP_URL`: local TFS MCP SSE endpoint. In Kubernetes this normally points to `http://mcp-serverservice:3002/mcp/sse`. - `ADK_SESSION_DB_PATH`: SQLite path for per-domain user-session state. @@ -86,7 +89,9 @@ Main settings: Kubernetes Secret. - `OLLAMA_API_BASE`: optional Ollama endpoint when using an Ollama LiteLLM model name. -- `ADK_A2A_PUBLIC_URL`: public URL advertised in the A2A Agent Card. +- `ADK_A2A_PUBLIC_URL`: public base URL advertised in the A2A Agent Card. + When Agentic is ingress-exposed at `/agentic`, use the URL ending in + `/agentic`; the runtime appends `/a2a/agentic`. - `ADK_AGENT_RUN_TIMEOUT_SECONDS`: request timeout for LLM-backed execution. - `ADK_AGENT_STARTUP_WARMUP_ENABLED`: enables safe non-mutating warm-up prompts to absorb cold-start latency. @@ -94,7 +99,7 @@ Main settings: ## LLM Configuration And Fail-Fast Checks -Agentic validates the LLM configuration during FastAPI startup. If the model is +Agentic validates the LLM configuration during ADK Web startup. If the model is OpenAI-backed, for example `openai/gpt-4.1-mini`, `OPENAI_API_KEY` must be present and non-empty. Missing credentials cause the pod to fail startup instead of running and failing later on the first user request. @@ -129,7 +134,7 @@ ADK_MODEL: "dummy-deterministic" The component source is split into: -- `service/`: runtime implementation, including FastAPI endpoints, A2A +- `service/`: runtime implementation, including the ADK Web launcher, A2A transport, MCP client helpers, deterministic workflows, and ADK agents. - `service/agents/`: ADK agent definitions. Each agent module keeps its own instruction constant next to the corresponding agent definition. @@ -138,61 +143,39 @@ The component source is split into: ## Exposed Endpoints -`GET /health` returns component health, local domain ID, peer summary, session -database health, and startup warm-up results. - -`GET /` redirects to the generated Agentic API documentation. Through the TFS -ingress this is reachable as: +Through the TFS ingress, `GET /agentic` opens Google ADK Web: ```text http:///agentic ``` -or directly as: +The generated ADK API documentation is available as a debug endpoint: ```text http:///agentic/docs ``` -`POST /agent/run` receives operator-facing natural-language requests: +The app-specific A2A Agent Card is exposed at: -```bash -curl -X POST http:///agentic/agent/run \ - -H 'Content-Type: application/json' \ - -d '{ - "prompt": "list existing devices", - "user_id": "demo", - "session_id": "demo-1" - }' +```text +http:///agentic/a2a/agentic/.well-known/agent-card.json ``` -`GET /.well-known/agent-card.json` exposes the A2A Agent Card. - -`POST /a2a` exposes the standardized A2A JSON-RPC endpoint used by peer -Agentic instances. - -Diagnostic endpoints are also available for direct workflow tests: +Peer Agentic instances send standardized A2A JSON-RPC requests to: -- `GET /inventory/devices` -- `GET /domains` -- `POST /inventory/domain` -- `POST /spectrum/candidates` -- `POST /spectrum/reserve` -- `POST /spectrum/update` -- `POST /services/optical` -- `POST /services/delete` -- `POST /services/delete-by-request` -- `POST /workflows/cross-domain-optical` -- `POST /workflows/cross-domain-optical/delete` +```text +http:///agentic/a2a/agentic +``` -Prefer `/agent/run` for operator-facing validation. Use direct diagnostic -endpoints only for integration tests and failure isolation. +Operator-facing natural-language requests should be issued from Google ADK Web. +Programmatic clients should use ADK API endpoints instead of the former +component-specific `/agent/run` route. ## Google ADK WebUI -Google ADK WebUI is intended for development and debugging of the agent graph. -The production TFS component runs the Agentic FastAPI service on port `8800`; -it does not start `adk web` as a second long-running process. +Google ADK Web is the runtime entrypoint for the component. The same ADK runner +serves operator interaction and app-scoped A2A requests, which avoids split +runner state and SQLite session collisions. The package exposes `agentic.agent:root_agent` so ADK Web can discover the same root agent used by the service. From a development checkout with the @@ -201,37 +184,54 @@ Agentic dependencies installed, start ADK Web from the directory containing the ```bash cd ~/tfs-ctrl/src -PYTHONPATH=. adk web --host 0.0.0.0 --port 8000 +mkdir -p /tmp/tfs-agentic-adk-apps/agentic +cp agentic/agent.py /tmp/tfs-agentic-adk-apps/agentic/agent.py +PYTHONPATH=. ADK_MODEL=dummy-deterministic python -m agentic.service.adk_web +``` + +The launcher writes the runtime Agent Card and executes: + +```bash +adk web \ + --a2a \ + --host 0.0.0.0 \ + --port 8000 \ + --url_prefix /agentic \ + --session_service_uri sqlite:/// \ + /tmp/tfs-agentic-adk-apps ``` Then open: ```text -http://:8000 +http://:8000/agentic ``` and select the `agentic` app. Use the same environment variables documented above, especially `ADK_MODEL`, `OPENAI_API_KEY`, `TFS_MCP_URL`, `ADK_DOMAIN_ID`, `ADK_PEERS`, and the session/spectrum database paths. -For Kubernetes debugging, run ADK Web in a temporary debug pod or side process -using the same image and environment as `agenticservice`, then expose or -port-forward its port: +In Kubernetes, the TFS WebUI `Agentic` navigation link opens ADK Web. The +WebUI Debug page links to the ADK API documentation. Direct port-forwarding can +also be used: ```bash -kubectl -n tfs port-forward deployment/agenticservice 8800:8800 +kubectl -n tfs port-forward deployment/agenticservice 8000:8000 ``` -The command above exposes the production Agentic API locally as -`http://127.0.0.1:8800`. If a separate ADK Web debug process is started on port -`8000`, port-forward that process instead and open `http://127.0.0.1:8000`. +Then open `http://127.0.0.1:8000/agentic`. + +Structured peer A2A requests use a compact JSON action envelope. The root agent +detects this envelope before any LLM call and dispatches it to deterministic +domain actions, while normal operator prompts continue through the configured +ADK agent graph. ## Smoke Tests After deployment, check health: ```bash -curl http:///agentic/health +curl http:///agentic/list-apps ``` Load an emulated scenario into the local controller: @@ -241,17 +241,8 @@ PYENV_VERSION=tfs ./src/tests/tools/load_scenario/run.sh \ src/tests/tools/load_scenario/example_descriptors.json ``` -Verify MCP-backed Agentic inventory through the LLM path: - -```bash -curl -X POST http:///agentic/agent/run \ - -H 'Content-Type: application/json' \ - -d '{ - "prompt": "list existing devices", - "user_id": "smoke", - "session_id": "smoke-1" - }' -``` +Verify MCP-backed Agentic inventory through the ADK Web UI by selecting the +`agentic` application and sending `list existing devices`. With the example descriptor, the expected answer reports seven local devices: `R1`, `R2`, `R3`, `R4`, `R5`, `R6`, and `R7`. @@ -272,14 +263,10 @@ controller-facing references: After loading the optical topology, request an optical connectivity service with explicit sizing: -```bash -curl -X POST http:///agentic/agent/run \ - -H 'Content-Type: application/json' \ - -d '{ - "prompt": "create a 50 GHz optical service from DC1-TP1 to DC2-TP1", - "user_id": "smoke", - "session_id": "smoke-2" - }' +Use the ADK Web UI to request: + +```text +create a 50 GHz optical service from DC1-TP1 to DC2-TP1 ``` A successful mutating workflow must report the created TFS service UUIDs and diff --git a/src/agentic/service/a2a_transport.py b/src/agentic/service/a2a_transport.py index 5ccfec282..280d8cb58 100644 --- a/src/agentic/service/a2a_transport.py +++ b/src/agentic/service/a2a_transport.py @@ -83,10 +83,11 @@ def _domain_api_base_url() -> str: return f"http://localhost:{port}" -def build_agent_card() -> AgentCard: +def build_agent_card(a2a_path: str = "/a2a") -> AgentCard: """Build the standards-compliant A2A Agent Card for one domain.""" base_url = _domain_api_base_url() + a2a_path = "/" + a2a_path.strip("/") return AgentCard( name=f"tfs-agentic-domain-{DOMAIN_ID.lower()}", description=( @@ -94,7 +95,7 @@ def build_agent_card() -> AgentCard: "negotiation, optical service provisioning, and service teardown " "through the A2A protocol." ), - url=f"{base_url}/a2a", + url=f"{base_url}{a2a_path}", version="0.1.0", protocolVersion="0.3.0", preferredTransport=TransportProtocol.jsonrpc, @@ -334,13 +335,16 @@ async def call_a2a_action( messageId=str(uuid.uuid4()), parts=[ Part( - root=DataPart( - data={ - "action": action, - "payload": payload or {}, - "protocol": "tfs-agentic-a2a-v1", - "requesting_domain": DOMAIN_ID, - } + root=TextPart( + text=json.dumps( + { + "action": action, + "payload": payload or {}, + "protocol": "tfs-agentic-a2a-v1", + "requesting_domain": DOMAIN_ID, + }, + sort_keys=True, + ) ) ) ], @@ -391,6 +395,8 @@ async def _agent_card(domain_base_url: str) -> AgentCard: return cached resolver = A2ACardResolver(await _shared_http_client(), base_url) card = await resolver.get_agent_card() + if str(card.url).rstrip("/") != base_url: + card = card.model_copy(update={"url": base_url}) _A2A_AGENT_CARD_CACHE[base_url] = card LOGGER.info("Cached A2A Agent Card for %s", base_url) return card @@ -405,6 +411,10 @@ def _extract_response_payload(event: Any) -> Dict[str, Any] | None: for part in artifact.parts or []: if isinstance(part.root, DataPart): return dict(part.root.data or {}) + if isinstance(part.root, TextPart): + parsed = _parse_json_text(part.root.text) + if parsed is not None: + return parsed status = getattr(task, "status", None) message = getattr(status, "message", None) if message is not None: @@ -418,12 +428,20 @@ def _extract_message_data(message: Message) -> Dict[str, Any] | None: if isinstance(root, DataPart): return dict(root.data or {}) if isinstance(root, TextPart): - try: - parsed = json.loads(root.text) - if isinstance(parsed, dict): - return parsed - except json.JSONDecodeError: - return {"ok": True, "result": {"text": root.text}} + parsed = _parse_json_text(root.text) + if parsed is not None: + return parsed + return {"ok": True, "result": {"text": root.text}} + return None + + +def _parse_json_text(text: str) -> Dict[str, Any] | None: + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return None + if isinstance(parsed, dict): + return parsed return None diff --git a/src/agentic/service/adk_a2a_bridge.py b/src/agentic/service/adk_a2a_bridge.py new file mode 100644 index 000000000..ec36804a0 --- /dev/null +++ b/src/agentic/service/adk_a2a_bridge.py @@ -0,0 +1,136 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""ADK callback bridge for deterministic A2A action handling.""" + +from __future__ import annotations + +import base64 +import json +import logging +from typing import Any + +from google.genai import types + +from agentic.service.a2a_transport import dispatch_a2a_action +from agentic.service.settings import DOMAIN_ID, DOMAIN_NAME + +LOGGER = logging.getLogger(__name__) +_A2A_DATA_PART_START_TAG = b"" +_A2A_DATA_PART_END_TAG = b"" + + +async def handle_a2a_action_message(callback_context) -> types.Content | None: + """Handle structured A2A action requests before an LLM is called. + + ADK Web with ``--a2a`` converts incoming A2A parts into normal ADK user + content. Peer MD-NAF calls send a JSON text payload with an ``action`` and + a ``payload``. This callback recognizes that contract and dispatches it to + the deterministic domain-action implementation, avoiding an LLM call for + peer protocol operations. + """ + + request = _extract_a2a_action(callback_context.user_content) + if request is None: + return None + + action = str(request.get("action", "")).strip() + payload = request.get("payload", {}) + if not isinstance(payload, dict): + payload = {"value": payload} + + try: + result = await dispatch_a2a_action(action, payload) + response = { + "ok": ( + bool(result.get("ok", True)) + if isinstance(result, dict) + else True + ), + "protocol": "tfs-agentic-a2a-v1", + "domain_id": DOMAIN_ID, + "domain_name": DOMAIN_NAME, + "action": action, + "result": result, + } + except Exception as exc: # pylint: disable=broad-exception-caught + LOGGER.exception("ADK A2A action failed action=%s", action) + response = { + "ok": False, + "protocol": "tfs-agentic-a2a-v1", + "domain_id": DOMAIN_ID, + "domain_name": DOMAIN_NAME, + "action": action, + "error": "a2a_action_failed", + "message": str(exc), + } + + return types.Content( + role="model", + parts=[types.Part(text=json.dumps(response, sort_keys=True))], + ) + + +def _extract_a2a_action(content: types.Content | None) -> dict[str, Any] | None: + if content is None: + return None + for part in content.parts or []: + if part.text: + parsed = _parse_json_action(part.text) + if parsed is not None: + return parsed + if part.inline_data and part.inline_data.data: + parsed = _parse_inline_json_action(part.inline_data.data) + if parsed is not None: + return parsed + return None + + +def _parse_json_action(text: str) -> dict[str, Any] | None: + try: + parsed = json.loads(text) + except json.JSONDecodeError: + return None + if not isinstance(parsed, dict): + return None + if parsed.get("protocol") != "tfs-agentic-a2a-v1": + return None + if not parsed.get("action"): + return None + return parsed + + +def _parse_inline_json_action(data: bytes) -> dict[str, Any] | None: + if not ( + data.startswith(_A2A_DATA_PART_START_TAG) + and data.endswith(_A2A_DATA_PART_END_TAG) + ): + return None + raw_payload = data[ + len(_A2A_DATA_PART_START_TAG) : -len(_A2A_DATA_PART_END_TAG) + ] + try: + decoded = base64.b64decode(raw_payload, validate=True) + except Exception: # pylint: disable=broad-exception-caught + decoded = raw_payload + try: + data_part = json.loads(decoded.decode("utf-8")) + except (UnicodeDecodeError, json.JSONDecodeError): + return None + if not isinstance(data_part, dict): + return None + data_field = data_part.get("data", {}) + if isinstance(data_field, dict): + return _parse_json_action(json.dumps(data_field)) + return None diff --git a/src/agentic/service/adk_web.py b/src/agentic/service/adk_web.py new file mode 100644 index 000000000..9a7abc1af --- /dev/null +++ b/src/agentic/service/adk_web.py @@ -0,0 +1,64 @@ +# Copyright 2022-2026 ETSI SDG TeraFlowSDN (TFS) (https://tfs.etsi.org/) +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Launcher for Google ADK Web as the Agentic component runtime.""" + +from __future__ import annotations + +import json +import os +from pathlib import Path + +from agentic.Config import validate_agentic_llm_configuration +from agentic.service.a2a_transport import build_agent_card +from agentic.service.settings import HTTP_ROOT_PATH, SESSION_DB_PATH + + +ADK_APPS_DIR = Path("/var/teraflow/adk_apps") +ADK_APP_DIR = ADK_APPS_DIR.joinpath("agentic") +ADK_APP_CARD = ADK_APP_DIR.joinpath("agent.json") + + +def main() -> None: + """Write the runtime Agent Card and replace this process with ADK Web.""" + + validate_agentic_llm_configuration() + ADK_APP_DIR.mkdir(parents=True, exist_ok=True) + card = build_agent_card(a2a_path="/a2a/agentic") + ADK_APP_CARD.write_text( + json.dumps(card.model_dump(mode="json", by_alias=True), indent=2), + encoding="utf-8", + ) + + argv = [ + "adk", + "web", + "--a2a", + "--host", + "0.0.0.0", + "--port", + "8000", + "--url_prefix", + HTTP_ROOT_PATH or "/agentic", + "--session_service_uri", + f"sqlite:///{SESSION_DB_PATH}", + ".", + ] + os.chdir(str(ADK_APPS_DIR)) + os.environ["PYTHONPATH"] = "/var/teraflow" + os.execvp("adk", argv) + + +if __name__ == "__main__": + main() diff --git a/src/agentic/service/agents/granular_root.py b/src/agentic/service/agents/granular_root.py index 2962e49b0..4be3c4d85 100644 --- a/src/agentic/service/agents/granular_root.py +++ b/src/agentic/service/agents/granular_root.py @@ -21,6 +21,7 @@ from google.adk.models.lite_llm import LiteLlm from agentic.service.agents.mutation import mutation_router_agent from agentic.service.agents.retrieval import retrieval_router_agent +from agentic.service.adk_a2a_bridge import handle_a2a_action_message from agentic.service.llm import build_litellm @@ -47,4 +48,5 @@ root_agent = Agent( description="Minimal TFS Agentic root router for specialist agents.", instruction=GRANULAR_ROOT_ROUTER_INSTRUCTION, sub_agents=[retrieval_router_agent, mutation_router_agent], + before_agent_callback=handle_a2a_action_message, ) diff --git a/src/agentic/service/agents/root.py b/src/agentic/service/agents/root.py index 71fdd4c40..21560cf98 100644 --- a/src/agentic/service/agents/root.py +++ b/src/agentic/service/agents/root.py @@ -23,6 +23,7 @@ from __future__ import annotations from google.adk.agents.llm_agent import Agent from google.adk.models.lite_llm import LiteLlm +from agentic.service.adk_a2a_bridge import handle_a2a_action_message from agentic.service.llm import build_litellm @@ -70,4 +71,5 @@ inventory_service_root_agent = Agent( ), instruction=ROOT_AGENT_INSTRUCTION, sub_agents=[inventory_agent, service_agent], + before_agent_callback=handle_a2a_action_message, ) diff --git a/src/agentic/service/agents/single.py b/src/agentic/service/agents/single.py index e663bf698..cadb9e474 100644 --- a/src/agentic/service/agents/single.py +++ b/src/agentic/service/agents/single.py @@ -18,6 +18,7 @@ from __future__ import annotations from google.adk.agents.llm_agent import Agent +from agentic.service.adk_a2a_bridge import handle_a2a_action_message from agentic.service.llm import build_litellm from agentic.service.tools.granular import ( CROSS_DOMAIN_CONNECTION_RETRIEVAL_TOOLS, @@ -103,4 +104,5 @@ single_root_agent = Agent( CROSS_DOMAIN_CONNECTION_RETRIEVAL_TOOLS, SERVICE_MUTATION_TOOLS, ), + before_agent_callback=handle_a2a_action_message, ) diff --git a/src/agentic/service/peer_client.py b/src/agentic/service/peer_client.py index 94c04f04c..5ea52f8eb 100644 --- a/src/agentic/service/peer_client.py +++ b/src/agentic/service/peer_client.py @@ -45,10 +45,19 @@ def parse_peers() -> Dict[str, str]: if "=" not in item: continue domain_id, url = item.split("=", 1) - peers[domain_id.strip().upper()] = url.strip().rstrip("/") + peers[domain_id.strip().upper()] = _normalize_peer_url(url) return peers +def _normalize_peer_url(url: str) -> str: + peer_url = url.strip().rstrip("/") + if peer_url.endswith("/a2a/agentic"): + return peer_url + if peer_url.endswith("/agentic"): + return f"{peer_url}/a2a/agentic" + return peer_url + + async def peer_get(domain_id: str, path: str) -> Dict[str, Any]: peers = parse_peers() base = peers[domain_id.upper()] diff --git a/src/agentic/tests/test_integration.py b/src/agentic/tests/test_integration.py index 834ff3144..49445d154 100644 --- a/src/agentic/tests/test_integration.py +++ b/src/agentic/tests/test_integration.py @@ -32,9 +32,9 @@ def test_a2a_agent_card_uses_configured_domain_url(monkeypatch) -> None: "http://agentic-a.example/agentic") transport = importlib.import_module("agentic.service.a2a_transport") - card = transport.build_agent_card() + card = transport.build_agent_card(a2a_path="/a2a/agentic") - assert str(card.url) == "http://agentic-a.example/agentic/a2a" + assert str(card.url) == "http://agentic-a.example/agentic/a2a/agentic" assert card.name diff --git a/src/agentic/tests/test_unitary.py b/src/agentic/tests/test_unitary.py index 382e75ce5..e04c81438 100644 --- a/src/agentic/tests/test_unitary.py +++ b/src/agentic/tests/test_unitary.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from pathlib import Path + from agentic.Config import ( DEFAULT_AGENTIC_GRAPH, DEFAULT_AGENTIC_MCP_URL, @@ -32,12 +34,15 @@ from agentic.service.tools.service_workflow import ( from common.Settings import is_deployed_agentic +REPO_ROOT = Path(__file__).resolve().parents[3] + + def test_agentic_default_config(monkeypatch) -> None: monkeypatch.delenv("ADK_MODEL", raising=False) monkeypatch.delenv("ADK_AGENT_GRAPH", raising=False) monkeypatch.delenv("TFS_MCP_URL", raising=False) - assert get_agentic_port() == 8800 + assert get_agentic_port() == 8000 assert get_agentic_model() == DEFAULT_AGENTIC_MODEL assert get_agentic_graph() == DEFAULT_AGENTIC_GRAPH assert get_agentic_mcp_url() == DEFAULT_AGENTIC_MCP_URL @@ -64,18 +69,35 @@ def test_dummy_deterministic_model_does_not_require_api_key(monkeypatch) -> None def test_agentic_deployment_detection_accepts_http_only(monkeypatch) -> None: monkeypatch.setenv("AGENTICSERVICE_SERVICE_HOST", "agenticservice") - monkeypatch.setenv("AGENTICSERVICE_SERVICE_PORT_HTTP", "8800") + monkeypatch.setenv("AGENTICSERVICE_SERVICE_PORT_HTTP", "8000") monkeypatch.delenv("AGENTICSERVICE_SERVICE_PORT_GRPC", raising=False) assert is_deployed_agentic() is True +def test_agentic_manifest_exposes_adk_web_a2a_runtime() -> None: + manifest = REPO_ROOT.joinpath( + "manifests", "agenticservice.yaml" + ).read_text(encoding="utf-8") + + assert "- name: server" in manifest + assert "workingDir: /var/teraflow/adk_apps" in manifest + assert "- agentic.service.adk_web" in manifest + assert 'ADK_HTTP_ROOT_PATH: "/agentic"' in manifest + assert 'value: "/var/teraflow"' in manifest + assert "number: 8000" in manifest + assert "number: 8800" not in manifest + assert "tfs-ingress-agentic-api" not in manifest + assert "tfs-ingress-agentic-web" not in manifest + assert "tfs-ingress-agentic" in manifest + + def test_parse_peers(monkeypatch) -> None: - peer_spec = "B=http://b.example/a2a, C=http://c.example/a2a/" + peer_spec = "B=http://b.example/agentic, C=http://c.example/a2a/" monkeypatch.setenv("ADK_PEERS", peer_spec) monkeypatch.setattr(peer_client, "PEER_SPEC", peer_spec) assert peer_client.parse_peers() == { - "B": "http://b.example/a2a", + "B": "http://b.example/agentic/a2a/agentic", "C": "http://c.example/a2a", } diff --git a/src/common/Constants.py b/src/common/Constants.py index 2bcd49bfe..5cead1cef 100644 --- a/src/common/Constants.py +++ b/src/common/Constants.py @@ -129,7 +129,7 @@ DEFAULT_SERVICE_GRPC_PORTS = { # Default HTTP/REST-API service ports DEFAULT_SERVICE_HTTP_PORTS = { - ServiceNameEnum.AGENTIC.value : 8800, + ServiceNameEnum.AGENTIC.value : 8000, ServiceNameEnum.MCP_SERVER.value : 3002, ServiceNameEnum.NBI .value : 8080, ServiceNameEnum.WEBUI.value : 8004, diff --git a/src/webui/service/templates/main/debug.html b/src/webui/service/templates/main/debug.html index 7189f2630..643c96ca3 100644 --- a/src/webui/service/templates/main/debug.html +++ b/src/webui/service/templates/main/debug.html @@ -24,6 +24,9 @@
  • Dummy Contexts
  • Devices
  • Links
  • + {% if is_deployed_agentic() %} +
  • Agentic ADK API Docs
  • + {% endif %} {% endblock %} diff --git a/src/webui/tests/test_unitary.py b/src/webui/tests/test_unitary.py index 39eca772c..ed02c4cf8 100644 --- a/src/webui/tests/test_unitary.py +++ b/src/webui/tests/test_unitary.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +import os + # import pytest from flask_unittest import ClientTestCase from unittest import mock @@ -82,6 +84,17 @@ class TestWebUI(ClientTestCase): url_for('js.topology_js') url_for('js.site_js') + def test_debug_page_shows_agentic_docs_when_deployed(self, client) -> None: + agentic_env = { + 'AGENTICSERVICE_SERVICE_HOST': 'agenticservice', + 'AGENTICSERVICE_SERVICE_PORT_HTTP': '8000', + } + with mock.patch.dict(os.environ, agentic_env): + rv = client.get('/debug') + + self.assertInResponse(b'Agentic ADK API Docs', rv) + self.assertInResponse(b'/agentic/docs', rv) + def test_device_add_action_success(self, client) -> None: with client.session_transaction() as sess: sess['context_uuid'] = 'admin' -- GitLab From 48d60da94d713bde6e1be6dd9170897e828265d8 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Thu, 23 Jul 2026 08:52:43 +0000 Subject: [PATCH 3/6] Fix path-wide Agentic service result --- src/agentic/service/tools/service_workflow.py | 12 ------------ 1 file changed, 12 deletions(-) diff --git a/src/agentic/service/tools/service_workflow.py b/src/agentic/service/tools/service_workflow.py index 94fc82fd1..ba22eed77 100644 --- a/src/agentic/service/tools/service_workflow.py +++ b/src/agentic/service/tools/service_workflow.py @@ -2503,18 +2503,6 @@ async def _create_path_wide_cross_domain_optical_service( local_service = _service_uuid_for_domain(DOMAIN_ID, service_name) peer_domain = domain_path[1] peer_service = _service_uuid_for_domain(peer_domain, service_name) - peer_segment_services = ( - peer_create.get("segment_service_uuids", []) - if isinstance(peer_create, dict) - and peer_create.get("segment_service_uuids") - else [ - peer_create.get("local_service_uuid"), - peer_create.get("peer_service_uuid"), - ] - if isinstance(peer_create, dict) - and peer_create.get("local_service_uuid") - else [peer_service] - ) result = { "ok": True, "phase": "active", -- GitLab From 7896f65123c45ff2c79c5dc918f10c3e42b62271 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Thu, 23 Jul 2026 09:39:59 +0000 Subject: [PATCH 4/6] Fix Agentic ADK Web ingress prefix --- manifests/agenticservice.yaml | 5 ++-- src/agentic/service/adk_web.py | 50 ++++++++++++++++++++----------- src/agentic/tests/test_unitary.py | 26 ++++++++++++++++ 3 files changed, 60 insertions(+), 21 deletions(-) diff --git a/manifests/agenticservice.yaml b/manifests/agenticservice.yaml index 0eeb95393..5f4c8cce0 100644 --- a/manifests/agenticservice.yaml +++ b/manifests/agenticservice.yaml @@ -104,14 +104,14 @@ spec: mountPath: /var/lib/tfs-agentic readinessProbe: httpGet: - path: /dev-ui/ + path: /agentic/dev-ui/ port: 8000 initialDelaySeconds: 10 periodSeconds: 10 failureThreshold: 12 livenessProbe: httpGet: - path: /dev-ui/ + path: /agentic/dev-ui/ port: 8000 initialDelaySeconds: 20 periodSeconds: 20 @@ -154,7 +154,6 @@ metadata: nginx.ingress.kubernetes.io/proxy-connect-timeout: "60" nginx.ingress.kubernetes.io/proxy-send-timeout: "3600" nginx.ingress.kubernetes.io/proxy-read-timeout: "3600" - nginx.ingress.kubernetes.io/rewrite-target: /$2 nginx.ingress.kubernetes.io/use-regex: "true" spec: rules: diff --git a/src/agentic/service/adk_web.py b/src/agentic/service/adk_web.py index 9a7abc1af..bcc1118a8 100644 --- a/src/agentic/service/adk_web.py +++ b/src/agentic/service/adk_web.py @@ -20,6 +20,9 @@ import json import os from pathlib import Path +import uvicorn +from google.adk.cli.fast_api import get_fast_api_app + from agentic.Config import validate_agentic_llm_configuration from agentic.service.a2a_transport import build_agent_card from agentic.service.settings import HTTP_ROOT_PATH, SESSION_DB_PATH @@ -28,12 +31,13 @@ from agentic.service.settings import HTTP_ROOT_PATH, SESSION_DB_PATH ADK_APPS_DIR = Path("/var/teraflow/adk_apps") ADK_APP_DIR = ADK_APPS_DIR.joinpath("agentic") ADK_APP_CARD = ADK_APP_DIR.joinpath("agent.json") +ADK_HOST = "0.0.0.0" +ADK_PORT = 8000 -def main() -> None: - """Write the runtime Agent Card and replace this process with ADK Web.""" +def _write_agent_card() -> None: + """Write the runtime Agent Card consumed by ADK A2A exposure.""" - validate_agentic_llm_configuration() ADK_APP_DIR.mkdir(parents=True, exist_ok=True) card = build_agent_card(a2a_path="/a2a/agentic") ADK_APP_CARD.write_text( @@ -41,23 +45,33 @@ def main() -> None: encoding="utf-8", ) - argv = [ - "adk", - "web", - "--a2a", - "--host", - "0.0.0.0", - "--port", - "8000", - "--url_prefix", - HTTP_ROOT_PATH or "/agentic", - "--session_service_uri", - f"sqlite:///{SESSION_DB_PATH}", - ".", - ] + +def _build_adk_app(): + """Build the ADK Web application with a reverse-proxy root path.""" + + root_path = HTTP_ROOT_PATH or "/agentic" + app = get_fast_api_app( + agents_dir=".", + web=True, + a2a=True, + host=ADK_HOST, + port=ADK_PORT, + url_prefix=root_path, + session_service_uri=f"sqlite:///{SESSION_DB_PATH}", + use_local_storage=False, + ) + app.root_path = root_path + return app + + +def main() -> None: + """Write the runtime Agent Card and run ADK Web.""" + + validate_agentic_llm_configuration() + _write_agent_card() os.chdir(str(ADK_APPS_DIR)) os.environ["PYTHONPATH"] = "/var/teraflow" - os.execvp("adk", argv) + uvicorn.run(_build_adk_app(), host=ADK_HOST, port=ADK_PORT) if __name__ == "__main__": diff --git a/src/agentic/tests/test_unitary.py b/src/agentic/tests/test_unitary.py index e04c81438..d27099263 100644 --- a/src/agentic/tests/test_unitary.py +++ b/src/agentic/tests/test_unitary.py @@ -13,6 +13,7 @@ # limitations under the License. from pathlib import Path +from types import SimpleNamespace from agentic.Config import ( DEFAULT_AGENTIC_GRAPH, @@ -90,6 +91,31 @@ def test_agentic_manifest_exposes_adk_web_a2a_runtime() -> None: assert "tfs-ingress-agentic-api" not in manifest assert "tfs-ingress-agentic-web" not in manifest assert "tfs-ingress-agentic" in manifest + assert "rewrite-target" not in manifest + assert "path: /agentic/dev-ui/" in manifest + + +def test_adk_web_sets_prefixed_fastapi_root_path(monkeypatch) -> None: + from agentic.service import adk_web + + fake_app = SimpleNamespace(root_path="") + factory_kwargs = {} + + def fake_get_fast_api_app(**kwargs): + factory_kwargs.update(kwargs) + return fake_app + + monkeypatch.setattr(adk_web, "HTTP_ROOT_PATH", "/agentic") + monkeypatch.setattr(adk_web, "SESSION_DB_PATH", "/tmp/adk.sqlite") + monkeypatch.setattr(adk_web, "get_fast_api_app", fake_get_fast_api_app) + + app = adk_web._build_adk_app() + + assert app.root_path == "/agentic" + assert factory_kwargs["url_prefix"] == "/agentic" + assert factory_kwargs["a2a"] is True + assert factory_kwargs["web"] is True + assert factory_kwargs["session_service_uri"] == "sqlite:////tmp/adk.sqlite" def test_parse_peers(monkeypatch) -> None: -- GitLab From 89f1d5de399e9927544ed208899b12aac75efbbb Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Thu, 23 Jul 2026 09:57:56 +0000 Subject: [PATCH 5/6] Remove Agentic manifest file check from unit tests --- src/agentic/tests/test_unitary.py | 23 ----------------------- 1 file changed, 23 deletions(-) diff --git a/src/agentic/tests/test_unitary.py b/src/agentic/tests/test_unitary.py index d27099263..1e54019cd 100644 --- a/src/agentic/tests/test_unitary.py +++ b/src/agentic/tests/test_unitary.py @@ -12,7 +12,6 @@ # See the License for the specific language governing permissions and # limitations under the License. -from pathlib import Path from types import SimpleNamespace from agentic.Config import ( @@ -35,9 +34,6 @@ from agentic.service.tools.service_workflow import ( from common.Settings import is_deployed_agentic -REPO_ROOT = Path(__file__).resolve().parents[3] - - def test_agentic_default_config(monkeypatch) -> None: monkeypatch.delenv("ADK_MODEL", raising=False) monkeypatch.delenv("ADK_AGENT_GRAPH", raising=False) @@ -76,25 +72,6 @@ def test_agentic_deployment_detection_accepts_http_only(monkeypatch) -> None: assert is_deployed_agentic() is True -def test_agentic_manifest_exposes_adk_web_a2a_runtime() -> None: - manifest = REPO_ROOT.joinpath( - "manifests", "agenticservice.yaml" - ).read_text(encoding="utf-8") - - assert "- name: server" in manifest - assert "workingDir: /var/teraflow/adk_apps" in manifest - assert "- agentic.service.adk_web" in manifest - assert 'ADK_HTTP_ROOT_PATH: "/agentic"' in manifest - assert 'value: "/var/teraflow"' in manifest - assert "number: 8000" in manifest - assert "number: 8800" not in manifest - assert "tfs-ingress-agentic-api" not in manifest - assert "tfs-ingress-agentic-web" not in manifest - assert "tfs-ingress-agentic" in manifest - assert "rewrite-target" not in manifest - assert "path: /agentic/dev-ui/" in manifest - - def test_adk_web_sets_prefixed_fastapi_root_path(monkeypatch) -> None: from agentic.service import adk_web -- GitLab From fc94e6046e4712b1ce49dd8d7c62b365f1b1fc6a Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Thu, 23 Jul 2026 12:09:33 +0000 Subject: [PATCH 6/6] Document Agentic ADK Web ingress behavior --- src/agentic/README.md | 48 +++++++++++++++++++++++++++++-------------- 1 file changed, 33 insertions(+), 15 deletions(-) diff --git a/src/agentic/README.md b/src/agentic/README.md index 9194e0e6f..40c6fe0a1 100644 --- a/src/agentic/README.md +++ b/src/agentic/README.md @@ -39,7 +39,9 @@ The deployment manifest creates: - `agenticservice`: HTTP service exposing Google ADK Web and ADK A2A on port `8000`. - `tfs-ingress-agentic`: ingress path `/agentic` for Google ADK Web, ADK - API documentation, and ADK A2A endpoints. + API documentation, and ADK A2A endpoints. The ingress preserves the + `/agentic` prefix; it must not rewrite the path away because the ADK + application is started with the same external root path. When WebUI and Agentic are both selected in `TFS_COMPONENTS`, the TFS WebUI navigation bar exposes an `Agentic` entry. The link opens Google ADK Web @@ -155,6 +157,12 @@ The generated ADK API documentation is available as a debug endpoint: http:///agentic/docs ``` +The Swagger page loads its OpenAPI definition from the prefixed endpoint: + +```text +http:///agentic/openapi.json +``` + The app-specific A2A Agent Card is exposed at: ```text @@ -178,9 +186,15 @@ serves operator interaction and app-scoped A2A requests, which avoids split runner state and SQLite session collisions. The package exposes `agentic.agent:root_agent` so ADK Web can discover the -same root agent used by the service. From a development checkout with the -Agentic dependencies installed, start ADK Web from the directory containing the -`agentic` package: +same root agent used by the service. The component launcher builds the ADK Web +FastAPI application programmatically, enables ADK A2A support, sets +`url_prefix` and FastAPI `root_path` to `ADK_HTTP_ROOT_PATH`, and runs Uvicorn +on port `8000`. This ensures that Swagger, ADK Web redirects, static assets, +and A2A routes all use the same external `/agentic` prefix when the component +is exposed behind the TFS ingress. + +From a development checkout with the Agentic dependencies installed, start ADK +Web from the directory containing the `agentic` package: ```bash cd ~/tfs-ctrl/src @@ -189,17 +203,9 @@ cp agentic/agent.py /tmp/tfs-agentic-adk-apps/agentic/agent.py PYTHONPATH=. ADK_MODEL=dummy-deterministic python -m agentic.service.adk_web ``` -The launcher writes the runtime Agent Card and executes: - -```bash -adk web \ - --a2a \ - --host 0.0.0.0 \ - --port 8000 \ - --url_prefix /agentic \ - --session_service_uri sqlite:/// \ - /tmp/tfs-agentic-adk-apps -``` +The launcher writes the runtime Agent Card under the ADK app directory, builds +the ADK Web application with `web=True` and `a2a=True`, and uses +`ADK_SESSION_DB_PATH` for SQLite-backed ADK session persistence. Then open: @@ -221,6 +227,18 @@ kubectl -n tfs port-forward deployment/agenticservice 8000:8000 Then open `http://127.0.0.1:8000/agentic`. +Useful route checks are: + +```bash +curl http:///agentic/list-apps +curl http:///agentic/docs +curl http:///agentic/openapi.json +curl http:///agentic/a2a/agentic/.well-known/agent-card.json +``` + +The Kubernetes readiness and liveness probes use `/agentic/dev-ui/`, matching +the prefixed ADK Web route. + Structured peer A2A requests use a compact JSON action envelope. The root agent detects this envelope before any LLM call and dispatches it to deterministic domain actions, while normal operator prompts continue through the configured -- GitLab