From db01257173fbfdcd4c53921ca0274d0d47fb1e43 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 21 Jul 2026 14:13:48 +0000 Subject: [PATCH 1/4] Agentic component: - Initial version --- .gitlab-ci.yml | 1 + deploy/all.sh | 6 +- manifests/agenticservice.yaml | 157 + my_deploy.sh | 6 +- scripts/show_logs_agentic.sh | 19 + src/agentic/.gitlab-ci.yml | 114 + src/agentic/Config.py | 297 ++ src/agentic/Dockerfile | 47 + src/agentic/README.md | 56 + src/agentic/__init__.py | 15 + src/agentic/requirements.in | 24 + src/agentic/service/__init__.py | 15 + src/agentic/service/a2a_transport.py | 694 +++ src/agentic/service/agent.py | 26 + src/agentic/service/agents/__init__.py | 212 + src/agentic/service/agents/granular_root.py | 50 + src/agentic/service/agents/inventory.py | 58 + .../service/agents/inventory_granular.py | 205 + src/agentic/service/agents/mutation.py | 193 + src/agentic/service/agents/optical_create.py | 67 + src/agentic/service/agents/optical_delete.py | 62 + src/agentic/service/agents/retrieval.py | 293 ++ src/agentic/service/agents/root.py | 73 + src/agentic/service/agents/service.py | 143 + src/agentic/service/agents/single.py | 106 + src/agentic/service/domain_api.py | 747 ++++ src/agentic/service/graphs/__init__.py | 30 + src/agentic/service/graphs/cross_domain.py | 124 + src/agentic/service/graphs/inventory.py | 56 + src/agentic/service/graphs/optical.py | 158 + src/agentic/service/graphs/registry.py | 42 + src/agentic/service/graphs/types.py | 33 + src/agentic/service/llm.py | 27 + src/agentic/service/ollama_proxy.py | 107 + src/agentic/service/peer_client.py | 211 + src/agentic/service/session_store.py | 181 + src/agentic/service/settings.py | 72 + src/agentic/service/spectrum.py | 407 ++ src/agentic/service/static_graphs.py | 27 + src/agentic/service/tools/__init__.py | 34 + src/agentic/service/tools/granular.py | 609 +++ src/agentic/service/tools/mcp.py | 70 + src/agentic/service/tools/mcp_client.py | 188 + src/agentic/service/tools/service.py | 379 ++ src/agentic/service/tools/service_roles.py | 133 + src/agentic/service/tools/service_workflow.py | 3732 +++++++++++++++++ src/agentic/tests/__init__.py | 13 + src/agentic/tests/conftest.py | 46 + src/agentic/tests/test_integration.py | 179 + src/agentic/tests/test_unitary.py | 58 + src/common/Constants.py | 3 + 51 files changed, 10603 insertions(+), 2 deletions(-) create mode 100644 manifests/agenticservice.yaml create mode 100755 scripts/show_logs_agentic.sh create mode 100644 src/agentic/.gitlab-ci.yml create mode 100644 src/agentic/Config.py create mode 100644 src/agentic/Dockerfile create mode 100644 src/agentic/README.md create mode 100644 src/agentic/__init__.py create mode 100644 src/agentic/requirements.in create mode 100644 src/agentic/service/__init__.py create mode 100644 src/agentic/service/a2a_transport.py create mode 100644 src/agentic/service/agent.py create mode 100644 src/agentic/service/agents/__init__.py create mode 100644 src/agentic/service/agents/granular_root.py create mode 100644 src/agentic/service/agents/inventory.py create mode 100644 src/agentic/service/agents/inventory_granular.py create mode 100644 src/agentic/service/agents/mutation.py create mode 100644 src/agentic/service/agents/optical_create.py create mode 100644 src/agentic/service/agents/optical_delete.py create mode 100644 src/agentic/service/agents/retrieval.py create mode 100644 src/agentic/service/agents/root.py create mode 100644 src/agentic/service/agents/service.py create mode 100644 src/agentic/service/agents/single.py create mode 100644 src/agentic/service/domain_api.py create mode 100644 src/agentic/service/graphs/__init__.py create mode 100644 src/agentic/service/graphs/cross_domain.py create mode 100644 src/agentic/service/graphs/inventory.py create mode 100644 src/agentic/service/graphs/optical.py create mode 100644 src/agentic/service/graphs/registry.py create mode 100644 src/agentic/service/graphs/types.py create mode 100644 src/agentic/service/llm.py create mode 100644 src/agentic/service/ollama_proxy.py create mode 100644 src/agentic/service/peer_client.py create mode 100644 src/agentic/service/session_store.py create mode 100644 src/agentic/service/settings.py create mode 100644 src/agentic/service/spectrum.py create mode 100644 src/agentic/service/static_graphs.py create mode 100644 src/agentic/service/tools/__init__.py create mode 100644 src/agentic/service/tools/granular.py create mode 100644 src/agentic/service/tools/mcp.py create mode 100644 src/agentic/service/tools/mcp_client.py create mode 100644 src/agentic/service/tools/service.py create mode 100644 src/agentic/service/tools/service_roles.py create mode 100644 src/agentic/service/tools/service_workflow.py create mode 100644 src/agentic/tests/__init__.py create mode 100644 src/agentic/tests/conftest.py create mode 100644 src/agentic/tests/test_integration.py create mode 100644 src/agentic/tests/test_unitary.py diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1b225a0d2..93f1185e2 100644 --- a/.gitlab-ci.yml +++ b/.gitlab-ci.yml @@ -38,6 +38,7 @@ include: - local: '/src/monitoring/.gitlab-ci.yml' - local: '/src/nbi/.gitlab-ci.yml' - local: '/src/mcp_server/.gitlab-ci.yml' + - local: '/src/agentic/.gitlab-ci.yml' - local: '/src/context/.gitlab-ci.yml' - local: '/src/device/.gitlab-ci.yml' - local: '/src/service/.gitlab-ci.yml' diff --git a/deploy/all.sh b/deploy/all.sh index 8ce597ce3..f32ad4966 100755 --- a/deploy/all.sh +++ b/deploy/all.sh @@ -73,9 +73,13 @@ export TFS_COMPONENTS=${TFS_COMPONENTS:-"context device pathcomp service slice n #export TFS_COMPONENTS="${TFS_COMPONENTS} pluggables" # Uncomment to activate MCP Server. -# Keep it last so it is deployed after optional components it can expose. +# Keep it near the end so it is deployed after optional components it can expose. #export TFS_COMPONENTS="${TFS_COMPONENTS} mcp_server" +# Uncomment to activate Agentic Module. +# Keep it after MCP Server so it can discover the local TFS MCP tool catalogue. +#export TFS_COMPONENTS="${TFS_COMPONENTS} agentic" + # If not already set, set the tag you want to use for your images. export TFS_IMAGE_TAG=${TFS_IMAGE_TAG:-"dev"} diff --git a/manifests/agenticservice.yaml b/manifests/agenticservice.yaml new file mode 100644 index 000000000..006669f1c --- /dev/null +++ b/manifests/agenticservice.yaml @@ -0,0 +1,157 @@ +# 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. + +apiVersion: v1 +kind: ConfigMap +metadata: + name: agentic-config +data: + ADK_MODEL: "openai/gpt-4.1-mini" + ADK_AGENT_GRAPH: "single" + ADK_DOMAIN_ID: "A" + ADK_DOMAIN_NAME: "domain-a" + ADK_PEERS: "" + ADK_SESSION_DB_PATH: "/var/lib/tfs-agentic/adk_sessions.sqlite" + ADK_SPECTRUM_DB_PATH: "/var/lib/tfs-agentic/adk_spectrum.sqlite" + ADK_AGENT_RUN_TIMEOUT_SECONDS: "120" + ADK_MCP_STARTUP_WARMUP_TIMEOUT_SECONDS: "10" + ADK_MCP_TOOL_TIMEOUT_SECONDS: "30" + ADK_AGENT_STARTUP_WARMUP_ENABLED: "1" + ADK_AGENT_STARTUP_WARMUP_TIMEOUT_SECONDS: "20" + ADK_AGENT_STARTUP_WARMUP_PROMPTS: "list existing devices" + TFS_MCP_URL: "http://mcp-serverservice:3002/mcp/sse" + TFS_DEFAULT_CONTEXT_UUID: "admin" +--- +apiVersion: v1 +kind: Secret +metadata: + name: agentic-secrets +type: Opaque +stringData: + OPENAI_API_KEY: "" + TFS_MCP_AUTH_TOKEN: "" +--- +apiVersion: v1 +kind: PersistentVolumeClaim +metadata: + name: agentic-data +spec: + accessModes: + - ReadWriteOnce + resources: + requests: + storage: 1Gi +--- +apiVersion: apps/v1 +kind: Deployment +metadata: + name: agenticservice +spec: + selector: + matchLabels: + app: agenticservice + replicas: 1 + template: + metadata: + labels: + app: agenticservice + spec: + terminationGracePeriodSeconds: 5 + containers: + - name: server + image: labs.etsi.org:5050/tfs/controller/agentic:latest + imagePullPolicy: Always + ports: + - containerPort: 8800 + env: + - name: LOG_LEVEL + value: "INFO" + - name: LITELLM_LOCAL_MODEL_COST_MAP + value: "true" + - name: LITELLM_MODEL_COST_MAP_URL + value: "" + - name: ADK_DISABLE_JSON_SCHEMA_FOR_FUNC_DECL + value: "1" + envFrom: + - configMapRef: + name: agentic-config + - secretRef: + name: agentic-secrets + volumeMounts: + - name: agentic-data + mountPath: /var/lib/tfs-agentic + readinessProbe: + httpGet: + path: /health + port: 8800 + initialDelaySeconds: 10 + periodSeconds: 10 + failureThreshold: 12 + livenessProbe: + httpGet: + path: /health + port: 8800 + initialDelaySeconds: 20 + periodSeconds: 20 + failureThreshold: 6 + resources: + requests: + cpu: 250m + memory: 512Mi + limits: + cpu: 2000m + memory: 2Gi + volumes: + - name: agentic-data + persistentVolumeClaim: + claimName: agentic-data +--- +apiVersion: v1 +kind: Service +metadata: + name: agenticservice + labels: + app: agenticservice +spec: + type: ClusterIP + selector: + app: agenticservice + ports: + - name: http + protocol: TCP + port: 8800 + targetPort: 8800 +--- +apiVersion: networking.k8s.io/v1 +kind: Ingress +metadata: + name: tfs-ingress-agentic + annotations: + nginx.ingress.kubernetes.io/limit-rps: "20" + nginx.ingress.kubernetes.io/limit-connections: "20" + 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 +spec: + rules: + - http: + paths: + - path: /agentic(/|$)(.*) + pathType: ImplementationSpecific + backend: + service: + name: agenticservice + port: + number: 8800 diff --git a/my_deploy.sh b/my_deploy.sh index 5cca9a394..cff5b8a12 100644 --- a/my_deploy.sh +++ b/my_deploy.sh @@ -104,9 +104,13 @@ export TFS_COMPONENTS="context device pathcomp service nbi webui" #export TFS_COMPONENTS="${TFS_COMPONENTS} pluggables" # Uncomment to activate MCP Server. -# Keep it last so it is deployed after optional components it can expose. +# Keep it near the end so it is deployed after optional components it can expose. #export TFS_COMPONENTS="${TFS_COMPONENTS} mcp_server" +# Uncomment to activate Agentic Module. +# Keep it after MCP Server so it can discover the local TFS MCP tool catalogue. +#export TFS_COMPONENTS="${TFS_COMPONENTS} agentic" + # Set the tag you want to use for your images. export TFS_IMAGE_TAG="dev" diff --git a/scripts/show_logs_agentic.sh b/scripts/show_logs_agentic.sh new file mode 100755 index 000000000..882e376b7 --- /dev/null +++ b/scripts/show_logs_agentic.sh @@ -0,0 +1,19 @@ +#!/bin/bash +# 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. + +# If not already set, set the name of the Kubernetes namespace to deploy to. +export TFS_K8S_NAMESPACE=${TFS_K8S_NAMESPACE:-"tfs"} + +kubectl --namespace $TFS_K8S_NAMESPACE logs deployment/agenticservice -c server diff --git a/src/agentic/.gitlab-ci.yml b/src/agentic/.gitlab-ci.yml new file mode 100644 index 000000000..a663578a3 --- /dev/null +++ b/src/agentic/.gitlab-ci.yml @@ -0,0 +1,114 @@ +# 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. + +build agentic: + variables: + IMAGE_NAME: 'agentic' + IMAGE_TAG: "candidate-${CI_COMMIT_SHORT_SHA}" + stage: build + before_script: + - docker image prune --force + - bash scripts/dockerhub_login.sh + - > + docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" + $CI_REGISTRY + script: + - > + docker buildx build -t "$IMAGE_NAME:$IMAGE_TAG" + -f ./src/$IMAGE_NAME/Dockerfile . + - > + docker tag "$IMAGE_NAME:$IMAGE_TAG" + "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + - docker push "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + after_script: + - docker image prune --force + rules: + - if: > + $CI_PIPELINE_SOURCE == "merge_request_event" && + ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || + $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master" || + $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH) + - changes: + - src/common/**/*.py + - src/$IMAGE_NAME/**/*.{py,in,yml} + - src/$IMAGE_NAME/Dockerfile + - src/$IMAGE_NAME/tests/*.py + - manifests/${IMAGE_NAME}service.yaml + - .gitlab-ci.yml + +unit_test agentic: + variables: + IMAGE_NAME: 'agentic' + IMAGE_TAG: "candidate-${CI_COMMIT_SHORT_SHA}" + stage: unit_test + needs: + - build agentic + before_script: + - bash scripts/dockerhub_login.sh + - > + docker login -u "$CI_REGISTRY_USER" -p "$CI_REGISTRY_PASSWORD" + $CI_REGISTRY + - > + if docker network list | grep teraflowbridge; then + echo "teraflowbridge is already created"; + else docker network create -d bridge teraflowbridge; + fi + - > + if docker container ls -a | grep $IMAGE_NAME; then + docker rm -f $IMAGE_NAME; + else echo "$IMAGE_NAME container is not in the system"; + fi + script: + - docker pull "$CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG" + - > + docker run --name $IMAGE_NAME -d -p 8800:8800 + --env LOG_LEVEL=INFO + --env ADK_AGENT_STARTUP_WARMUP_ENABLED=0 + --env TFS_MCP_URL=http://127.0.0.1:3002/mcp/sse + --volume "$PWD/src/$IMAGE_NAME/tests:/opt/results" + --network=teraflowbridge + $CI_REGISTRY_IMAGE/$IMAGE_NAME:$IMAGE_TAG + - sleep 5 + - docker ps -a + - docker logs $IMAGE_NAME + - > + docker exec --user root -i $IMAGE_NAME sh -c + "coverage run -m pytest --log-level=INFO --verbose + ${IMAGE_NAME}/tests/test_*.py + --junitxml=/opt/results/${IMAGE_NAME}_report.xml" + - > + docker exec --user root -i $IMAGE_NAME sh -c + "coverage report --include='${IMAGE_NAME}/*' --show-missing" + coverage: '/TOTAL\s+\d+\s+\d+\s+(\d+%)/' + after_script: + - docker logs $IMAGE_NAME + - docker rm -f $IMAGE_NAME + - docker network rm teraflowbridge + rules: + - if: > + $CI_PIPELINE_SOURCE == "merge_request_event" && + ($CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "develop" || + $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == "master" || + $CI_MERGE_REQUEST_TARGET_BRANCH_NAME == $CI_DEFAULT_BRANCH) + - changes: + - src/common/**/*.py + - src/$IMAGE_NAME/**/*.{py,in,yml} + - src/$IMAGE_NAME/Dockerfile + - src/$IMAGE_NAME/tests/*.py + - manifests/${IMAGE_NAME}service.yaml + - .gitlab-ci.yml + artifacts: + when: always + reports: + junit: src/$IMAGE_NAME/tests/${IMAGE_NAME}_report.xml diff --git a/src/agentic/Config.py b/src/agentic/Config.py new file mode 100644 index 000000000..c1651eccf --- /dev/null +++ b/src/agentic/Config.py @@ -0,0 +1,297 @@ +# 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 common.Constants import ServiceNameEnum +from common.Settings import get_service_port_http, get_setting + +DEFAULT_AGENTIC_MODEL = "openai/gpt-4.1-mini" +DEFAULT_AGENTIC_GRAPH = "single" +DEFAULT_AGENTIC_DOMAIN_ID = "A" +DEFAULT_AGENTIC_DOMAIN_NAME = "domain-a" +DEFAULT_AGENTIC_MCP_URL = "http://mcp-serverservice:3002/mcp/sse" +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_MCP_STARTUP_WARMUP_TIMEOUT_SECONDS = "10" +DEFAULT_AGENTIC_MCP_TOOL_TIMEOUT_SECONDS = "30" +DEFAULT_AGENTIC_RUN_TIMEOUT_SECONDS = "120" +DEFAULT_AGENTIC_SPECTRUM_BASE = "0-383" +DEFAULT_AGENTIC_STARTUP_WARMUP_ENABLED = "1" +DEFAULT_AGENTIC_STARTUP_WARMUP_PROMPTS = "list existing devices" +DEFAULT_AGENTIC_STARTUP_WARMUP_TIMEOUT_SECONDS = "20" +DEFAULT_AGENTIC_TOOL_PROFILE = "full" +DEFAULT_OLLAMA_PROXY_LISTEN_HOST = "0.0.0.0" +DEFAULT_OLLAMA_PROXY_LISTEN_PORT = "11434" +DEFAULT_OLLAMA_PROXY_TARGET_PORT = "11434" +DEFAULT_TFS_CONTEXT_UUID = "admin" +DEFAULT_TFS_TOPOLOGY_UUID = "admin" + +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_MODEL = "ADK_MODEL" +ENVVAR_ADK_AGENT_GRAPH = "ADK_AGENT_GRAPH" +ENVVAR_ADK_DOMAIN_ID = "ADK_DOMAIN_ID" +ENVVAR_ADK_DOMAIN_NAME = "ADK_DOMAIN_NAME" +ENVVAR_ADK_MCP_STARTUP_WARMUP_TIMEOUT_SECONDS = ( + "ADK_MCP_STARTUP_WARMUP_TIMEOUT_SECONDS" +) +ENVVAR_ADK_MCP_TOOL_TIMEOUT_SECONDS = "ADK_MCP_TOOL_TIMEOUT_SECONDS" +ENVVAR_ADK_OLLAMA_API_BASE = "ADK_OLLAMA_API_BASE" +ENVVAR_ADK_PEERS = "ADK_PEERS" +ENVVAR_ADK_PEER_INVENTORY_CACHE_TTL_SECONDS = ( + "ADK_PEER_INVENTORY_CACHE_TTL_SECONDS" +) +ENVVAR_ADK_AGENT_RUN_TIMEOUT_SECONDS = "ADK_AGENT_RUN_TIMEOUT_SECONDS" +ENVVAR_ADK_SESSION_DB_PATH = "ADK_SESSION_DB_PATH" +ENVVAR_ADK_SPECTRUM_BASE = "ADK_SPECTRUM_BASE" +ENVVAR_ADK_SPECTRUM_BLOCKED = "ADK_SPECTRUM_BLOCKED" +ENVVAR_ADK_SPECTRUM_DB_PATH = "ADK_SPECTRUM_DB_PATH" +ENVVAR_ADK_AGENT_STARTUP_WARMUP_ENABLED = "ADK_AGENT_STARTUP_WARMUP_ENABLED" +ENVVAR_ADK_AGENT_STARTUP_WARMUP_PROMPTS = "ADK_AGENT_STARTUP_WARMUP_PROMPTS" +ENVVAR_ADK_AGENT_STARTUP_WARMUP_TIMEOUT_SECONDS = ( + "ADK_AGENT_STARTUP_WARMUP_TIMEOUT_SECONDS" +) +ENVVAR_ADK_TOOL_PROFILE = "ADK_TOOL_PROFILE" +ENVVAR_OLLAMA_API_BASE = "OLLAMA_API_BASE" +ENVVAR_OLLAMA_PROXY_LISTEN_HOST = "OLLAMA_PROXY_LISTEN_HOST" +ENVVAR_OLLAMA_PROXY_LISTEN_PORT = "OLLAMA_PROXY_LISTEN_PORT" +ENVVAR_OLLAMA_PROXY_TARGET_HOST = "OLLAMA_PROXY_TARGET_HOST" +ENVVAR_OLLAMA_PROXY_TARGET_PORT = "OLLAMA_PROXY_TARGET_PORT" +ENVVAR_OPENAI_API_KEY = "OPENAI_API_KEY" +ENVVAR_TFS_DEFAULT_CONTEXT_UUID = "TFS_DEFAULT_CONTEXT_UUID" +ENVVAR_TFS_DEFAULT_TOPOLOGY_UUID = "TFS_DEFAULT_TOPOLOGY_UUID" +ENVVAR_TFS_DOMAIN_ID = "TFS_DOMAIN_ID" +ENVVAR_TFS_MCP_URL = "TFS_MCP_URL" +ENVVAR_TFS_MCP_AUTH_TOKEN = "TFS_MCP_AUTH_TOKEN" + + +def _get_float_setting(name: str, default: str) -> float: + return float(get_setting(name, default=default)) + + +def _get_int_setting(name: str, default: str) -> int: + return int(get_setting(name, default=default)) + + +def _get_bool_setting(name: str, default: str) -> bool: + value = str(get_setting(name, default=default)).strip().lower() + return value not in {"0", "false", "no"} + + +def get_agentic_port() -> int: + return get_service_port_http(ServiceNameEnum.AGENTIC) + + +def get_agentic_model() -> str: + return get_setting(ENVVAR_ADK_MODEL, default=DEFAULT_AGENTIC_MODEL) + + +def get_agentic_graph() -> str: + return get_setting(ENVVAR_ADK_AGENT_GRAPH, default=DEFAULT_AGENTIC_GRAPH) + + +def get_agentic_domain_id() -> str: + fallback_domain_id = get_setting( + ENVVAR_TFS_DOMAIN_ID, default=DEFAULT_AGENTIC_DOMAIN_ID + ) + domain_id = get_setting(ENVVAR_ADK_DOMAIN_ID, default=fallback_domain_id) + return domain_id.strip() or DEFAULT_AGENTIC_DOMAIN_ID + + +def get_agentic_domain_name() -> str: + default_domain_name = "domain-{:s}".format(get_agentic_domain_id().lower()) + domain_name = get_setting( + ENVVAR_ADK_DOMAIN_NAME, default=default_domain_name + ) + return domain_name.strip() or DEFAULT_AGENTIC_DOMAIN_NAME + + +def get_agentic_peers() -> str: + return get_setting(ENVVAR_ADK_PEERS, default="").strip() + + +def get_agentic_mcp_url() -> str: + return get_setting( + ENVVAR_TFS_MCP_URL, default=DEFAULT_AGENTIC_MCP_URL + ).strip() + + +def get_agentic_session_db_path() -> str: + return get_setting( + ENVVAR_ADK_SESSION_DB_PATH, + default=DEFAULT_AGENTIC_SESSION_DB_PATH, + ) + + +def get_agentic_spectrum_db_path() -> str: + return get_setting( + ENVVAR_ADK_SPECTRUM_DB_PATH, + default=DEFAULT_AGENTIC_SPECTRUM_DB_PATH, + ) + + +def get_agentic_a2a_public_url() -> str: + return get_setting( + ENVVAR_ADK_A2A_PUBLIC_URL, default="" + ).strip().rstrip("/") + + +def get_agentic_a2a_timeout_seconds() -> float: + return _get_float_setting( + ENVVAR_ADK_A2A_TIMEOUT_SECONDS, + DEFAULT_AGENTIC_A2A_TIMEOUT_SECONDS, + ) + + +def get_agentic_default_slot_width() -> int: + return _get_int_setting( + ENVVAR_ADK_DEFAULT_SLOT_WIDTH, + DEFAULT_AGENTIC_DEFAULT_SLOT_WIDTH, + ) + + +def get_agentic_domain_api_port() -> int: + return _get_int_setting( + ENVVAR_ADK_DOMAIN_API_PORT, + DEFAULT_AGENTIC_DOMAIN_API_PORT, + ) + + +def get_agentic_graph_normalized() -> str: + return get_agentic_graph().strip().lower() or DEFAULT_AGENTIC_GRAPH + + +def get_agentic_mcp_auth_token() -> str: + return get_setting(ENVVAR_TFS_MCP_AUTH_TOKEN, default="").strip() + + +def get_agentic_mcp_startup_warmup_timeout_seconds() -> float: + return _get_float_setting( + ENVVAR_ADK_MCP_STARTUP_WARMUP_TIMEOUT_SECONDS, + DEFAULT_AGENTIC_MCP_STARTUP_WARMUP_TIMEOUT_SECONDS, + ) + + +def get_agentic_mcp_tool_timeout_seconds() -> float: + return _get_float_setting( + ENVVAR_ADK_MCP_TOOL_TIMEOUT_SECONDS, + DEFAULT_AGENTIC_MCP_TOOL_TIMEOUT_SECONDS, + ) + + +def get_agentic_ollama_api_base() -> str: + legacy_value = get_setting(ENVVAR_ADK_OLLAMA_API_BASE, default="") + return get_setting(ENVVAR_OLLAMA_API_BASE, default=legacy_value).strip() + + +def get_agentic_peer_inventory_cache_ttl_seconds() -> float: + return _get_float_setting( + ENVVAR_ADK_PEER_INVENTORY_CACHE_TTL_SECONDS, + "60", + ) + + +def get_agentic_run_timeout_seconds() -> float: + return _get_float_setting( + ENVVAR_ADK_AGENT_RUN_TIMEOUT_SECONDS, + DEFAULT_AGENTIC_RUN_TIMEOUT_SECONDS, + ) + + +def get_agentic_spectrum_base() -> str: + return get_setting( + ENVVAR_ADK_SPECTRUM_BASE, + default=DEFAULT_AGENTIC_SPECTRUM_BASE, + ) + + +def get_agentic_spectrum_blocked() -> str: + return get_setting(ENVVAR_ADK_SPECTRUM_BLOCKED, default="").strip() + + +def get_agentic_startup_warmup_enabled() -> bool: + return _get_bool_setting( + ENVVAR_ADK_AGENT_STARTUP_WARMUP_ENABLED, + DEFAULT_AGENTIC_STARTUP_WARMUP_ENABLED, + ) + + +def get_agentic_startup_warmup_prompts() -> list[str]: + raw_prompts = get_setting( + ENVVAR_ADK_AGENT_STARTUP_WARMUP_PROMPTS, + default=DEFAULT_AGENTIC_STARTUP_WARMUP_PROMPTS, + ) + return [ + prompt.strip() + for prompt in raw_prompts.split("||") + if prompt.strip() + ] + + +def get_agentic_startup_warmup_timeout_seconds() -> float: + return _get_float_setting( + ENVVAR_ADK_AGENT_STARTUP_WARMUP_TIMEOUT_SECONDS, + DEFAULT_AGENTIC_STARTUP_WARMUP_TIMEOUT_SECONDS, + ) + + +def get_agentic_tool_profile() -> str: + tool_profile = get_setting( + ENVVAR_ADK_TOOL_PROFILE, + default=DEFAULT_AGENTIC_TOOL_PROFILE, + ) + return tool_profile.strip().lower() or "full" + + +def get_ollama_proxy_listen_host() -> str: + return get_setting( + ENVVAR_OLLAMA_PROXY_LISTEN_HOST, + default=DEFAULT_OLLAMA_PROXY_LISTEN_HOST, + ) + + +def get_ollama_proxy_listen_port() -> int: + return _get_int_setting( + ENVVAR_OLLAMA_PROXY_LISTEN_PORT, + DEFAULT_OLLAMA_PROXY_LISTEN_PORT, + ) + + +def get_ollama_proxy_target_host() -> str: + return get_setting(ENVVAR_OLLAMA_PROXY_TARGET_HOST, default="").strip() + + +def get_ollama_proxy_target_port() -> int: + return _get_int_setting( + ENVVAR_OLLAMA_PROXY_TARGET_PORT, + DEFAULT_OLLAMA_PROXY_TARGET_PORT, + ) + + +def get_tfs_default_context_uuid() -> str: + return get_setting( + ENVVAR_TFS_DEFAULT_CONTEXT_UUID, + default=DEFAULT_TFS_CONTEXT_UUID, + ) + + +def get_tfs_default_topology_uuid() -> str: + return get_setting( + ENVVAR_TFS_DEFAULT_TOPOLOGY_UUID, + default=DEFAULT_TFS_TOPOLOGY_UUID, + ) diff --git a/src/agentic/Dockerfile b/src/agentic/Dockerfile new file mode 100644 index 000000000..2153a9a4d --- /dev/null +++ b/src/agentic/Dockerfile @@ -0,0 +1,47 @@ +# 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 python:3.11-slim + +ENV PYTHONDONTWRITEBYTECODE=1 \ + PYTHONUNBUFFERED=1 \ + PIP_NO_CACHE_DIR=1 + +RUN apt-get update \ + && apt-get install -y --no-install-recommends curl \ + && rm -rf /var/lib/apt/lists/* + +RUN python3 -m pip install --upgrade 'pip==25.2' +RUN python3 -m pip install --upgrade 'setuptools==79.0.0' 'wheel==0.45.1' +RUN python3 -m pip install --upgrade 'pip-tools==7.3.0' + +WORKDIR /var/teraflow/agentic +COPY src/agentic/requirements.in requirements.in +RUN pip-compile --quiet --output-file=requirements.txt requirements.in +RUN python3 -m pip install -r requirements.txt + +WORKDIR /var/teraflow +COPY src/common/. common/ +COPY src/agentic/. agentic/ + +EXPOSE 8800 + +ENTRYPOINT [ \ + "uvicorn", \ + "agentic.service.domain_api:app", \ + "--host", \ + "0.0.0.0", \ + "--port", \ + "8800" \ +] diff --git a/src/agentic/README.md b/src/agentic/README.md new file mode 100644 index 000000000..af8da584e --- /dev/null +++ b/src/agentic/README.md @@ -0,0 +1,56 @@ +# TFS Agentic Component + +The `agentic` component exposes a per-domain MD-NAF Agentic Module as a +TeraFlowSDN service. It runs the Google ADK based runtime, accesses the local +TeraFlowSDN controller through the `mcp_server` component, and exposes HTTP and +A2A endpoints for operator requests and peer-domain coordination. + +## Runtime Configuration + +Configuration is provided through the Kubernetes manifest. Do not bake private +addresses, credentials, SSH keys, or API keys into the image. + +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_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`. +- `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. +- `ADK_SPECTRUM_DB_PATH`: SQLite path for transient agentic spectrum workflow + state. +- `OPENAI_API_KEY`: LLM provider secret, supplied through Kubernetes Secret. +- `TFS_MCP_AUTH_TOKEN`: optional MCP authentication token, supplied through + Kubernetes Secret. + +## Source Layout + +The component source is split into: + +- `service/`: runtime implementation, including FastAPI endpoints, 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. +- `service/tools/`: deterministic MCP-backed tools and optical workflow logic. +- `tests/`: unit and integration tests. + +## Exposed Endpoints + +- `GET /health`: component health and configured peer summary. +- `POST /agent/run`: operator-facing natural-language request endpoint. +- `GET /.well-known/agent-card.json`: A2A Agent Card. +- `POST /a2a`: A2A JSON-RPC endpoint for peer MD-NAF modules. +- Inventory, spectrum, and optical service workflow helper endpoints are + exposed for diagnostics and integration testing. + +## Tests + +Unit tests validate configuration parsing, peer parsing, and deterministic +spectrum sizing. Integration tests use an in-process mocked MCP session, +mocked MCP workflow replies, and a mocked ADK runner so CI can validate the +Agentic component without a live controller or LLM API key. diff --git a/src/agentic/__init__.py b/src/agentic/__init__.py new file mode 100644 index 000000000..8f29e6575 --- /dev/null +++ b/src/agentic/__init__.py @@ -0,0 +1,15 @@ +# 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. + +"""Agentic TeraFlowSDN component package.""" diff --git a/src/agentic/requirements.in b/src/agentic/requirements.in new file mode 100644 index 000000000..a93243374 --- /dev/null +++ b/src/agentic/requirements.in @@ -0,0 +1,24 @@ +# 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. + +google-adk[a2a]>=2.0.0 +a2a-sdk>=0.3.4,<0.4 +mcp>=1.23.0 +litellm>=1.82.9 +python-dotenv>=1.0.1 +fastapi>=0.124.0 +uvicorn[standard]>=0.38.0 +httpx>=0.28.0 +coverage>=7.6.0 +pytest>=8.0.0 diff --git a/src/agentic/service/__init__.py b/src/agentic/service/__init__.py new file mode 100644 index 000000000..f1d455819 --- /dev/null +++ b/src/agentic/service/__init__.py @@ -0,0 +1,15 @@ +# 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. + +__all__ = ["agent"] diff --git a/src/agentic/service/a2a_transport.py b/src/agentic/service/a2a_transport.py new file mode 100644 index 000000000..9f4568f15 --- /dev/null +++ b/src/agentic/service/a2a_transport.py @@ -0,0 +1,694 @@ +# 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. + +"""Standards-based A2A transport for MD-NAF ADK domain workflows.""" + +from __future__ import annotations + +import json +import logging +import asyncio +import uuid +from typing import Any, Dict +from urllib.parse import parse_qs, urlparse + +import httpx +from a2a.client.card_resolver import A2ACardResolver +from a2a.client.client import ClientConfig +from a2a.client.client_factory import ClientFactory +from a2a.server.agent_execution import AgentExecutor, RequestContext +from a2a.server.apps import A2AStarletteApplication +from a2a.server.events import EventQueue +from a2a.server.request_handlers import DefaultRequestHandler +from a2a.server.tasks import InMemoryTaskStore +from a2a.types import ( + AgentCapabilities, + AgentCard, + AgentSkill, + DataPart, + Message, + Part, + Role, + TextPart, + TransportProtocol, +) +from a2a.utils import new_agent_parts_message + +from agentic.Config import ( + get_agentic_a2a_public_url, + get_agentic_a2a_timeout_seconds, + get_agentic_domain_api_port, +) +from agentic.service import spectrum +from agentic.service.settings import DEFAULT_SLOT_WIDTH, DOMAIN_ID, DOMAIN_NAME + +LOGGER = logging.getLogger(__name__) +DEFAULT_TIMEOUT = get_agentic_a2a_timeout_seconds() + +_A2A_HTTP_CLIENT: ( + tuple[asyncio.AbstractEventLoop, httpx.AsyncClient] | None +) = None +_A2A_HTTP_CLIENT_LOCK: asyncio.Lock | None = None +_A2A_AGENT_CARD_CACHE: Dict[str, AgentCard] = {} +_A2A_AGENT_CARD_LOCKS: Dict[str, asyncio.Lock] = {} + + +def clear_a2a_client_cache() -> None: + """Clear cached A2A Agent Cards. + + This is mainly useful for tests and redeploy-sensitive diagnostics. The + shared HTTP client is intentionally left open for process-lifetime reuse. + """ + + _A2A_AGENT_CARD_CACHE.clear() + _A2A_AGENT_CARD_LOCKS.clear() + + +def _domain_api_base_url() -> str: + configured = get_agentic_a2a_public_url() + if configured: + return configured + port = get_agentic_domain_api_port() + return f"http://localhost:{port}" + + +def build_agent_card() -> AgentCard: + """Build the standards-compliant A2A Agent Card for one MD-NAF domain.""" + + base_url = _domain_api_base_url() + return AgentCard( + name=f"md-naf-domain-{DOMAIN_ID.lower()}", + description=( + "MD-NAF domain agent exposing TFS inventory, flex-grid spectrum " + "negotiation, optical service provisioning, and service teardown " + "through the A2A protocol." + ), + url=f"{base_url}/a2a", + version="0.1.0", + protocolVersion="0.3.0", + preferredTransport=TransportProtocol.jsonrpc, + capabilities=AgentCapabilities( + streaming=False, + stateTransitionHistory=True, + ), + defaultInputModes=["application/json", "text/plain"], + defaultOutputModes=["application/json"], + skills=[ + AgentSkill( + id="mdnaf.inventory", + name="Remote domain inventory interrogation", + description=( + "List devices, endpoints, links, optical links, services, " + "and connections in this domain." + ), + tags=["md-naf", "inventory", "tfs", "mcp"], + inputModes=["application/json"], + outputModes=["application/json"], + ), + AgentSkill( + id="mdnaf.spectrum", + name="Flex-grid spectrum negotiation", + description=( + "Compute candidates, reserve spectrum, and update " + "spectrum reservation state." + ), + tags=["md-naf", "optical", "spectrum", "flex-grid"], + inputModes=["application/json"], + outputModes=["application/json"], + ), + AgentSkill( + id="mdnaf.optical-service", + name="Optical connectivity service lifecycle", + description=( + "Create and delete TFS optical connectivity service " + "segments in this domain." + ), + tags=["md-naf", "optical", "service", "tfs"], + inputModes=["application/json"], + outputModes=["application/json"], + ), + AgentSkill( + id="mdnaf.cross-domain-workflow", + name="Cross-domain optical workflow delegation", + description=( + "Execute or continue a cross-domain optical service " + "workflow through this domain." + ), + tags=["md-naf", "a2a", "cross-domain", "workflow"], + inputModes=["application/json"], + outputModes=["application/json"], + ), + ], + supportsAuthenticatedExtendedCard=False, + ) + + +def install_a2a_routes(app: Any) -> None: + """Mount A2A SDK routes onto an existing FastAPI/Starlette app.""" + + request_handler = DefaultRequestHandler( + agent_executor=MdNafDomainA2AExecutor(), + task_store=InMemoryTaskStore(), + ) + a2a_app = A2AStarletteApplication( + agent_card=build_agent_card(), + http_handler=request_handler, + ) + a2a_app.add_routes_to_app( + app, + agent_card_url="/.well-known/agent-card.json", + rpc_url="/a2a", + ) + + +def _message_payload(message: Message | None) -> Dict[str, Any]: + if message is None: + return {} + for part in message.parts or []: + root = part.root + if isinstance(root, DataPart): + return dict(root.data or {}) + if isinstance(root, TextPart): + text = root.text.strip() + if text: + try: + parsed = json.loads(text) + if isinstance(parsed, dict): + return parsed + except json.JSONDecodeError: + return {"action": "agent_run", "payload": {"prompt": text}} + return {} + + +def _response_message(data: Dict[str, Any], context: RequestContext) -> Message: + return new_agent_parts_message( + [Part(root=DataPart(data=data))], + context_id=context.context_id, + task_id=context.task_id, + ) + + +def _bool_query(value: str | None, default: bool = False) -> bool: + if value is None or value == "": + return default + return value.strip().lower() in {"1", "true", "yes", "on"} + + +def _query_dict(path: str) -> Dict[str, str]: + parsed = urlparse(path) + return { + key: values[-1] + for key, values in parse_qs(parsed.query).items() + if values + } + + +def action_from_legacy_get_path(path: str) -> Dict[str, Any]: + """Translate old peer GET paths into explicit A2A action payloads.""" + + parsed = urlparse(path) + query = _query_dict(path) + if parsed.path == "/inventory/devices": + return {"action": "inventory.devices", "payload": {}} + if parsed.path == "/inventory/optical-links": + return {"action": "inventory.optical_links", "payload": {}} + if parsed.path == "/inventory/resources": + return { + "action": "inventory.resources", + "payload": { + "scope": query.get("scope", "local"), + "resource_kind": query.get("resource_kind", "devices"), + "device_filter": query.get("device_filter", ""), + "detail_level": query.get("detail_level", "summary"), + "include_config_rules": _bool_query( + query.get("include_config_rules"), False + ), + "include_ids": _bool_query(query.get("include_ids"), False), + "include_optical_links": _bool_query( + query.get("include_optical_links"), False + ), + }, + } + if parsed.path == "/devices/locate": + return { + "action": "devices.locate", + "payload": {"device_name": query.get("device_name", "")}, + } + if parsed.path == "/devices/connections": + return { + "action": "devices.connections", + "payload": { + "device_name": query.get("device_name", ""), + "include_packet_links": _bool_query( + query.get("include_packet_links"), True + ), + "include_optical_links": _bool_query( + query.get("include_optical_links"), True + ), + }, + } + if parsed.path == "/domains": + return { + "action": "domains.list", + "payload": { + "include_devices": _bool_query( + query.get("include_devices"), True + ) + }, + } + if parsed.path == "/spectrum/candidates": + return { + "action": "spectrum.candidates", + "payload": { + "required_slots": int( + query.get("required_slots", DEFAULT_SLOT_WIDTH) + ), + "source_device": query.get("source_device", ""), + "destination_device": query.get("destination_device", ""), + "channel_width_ghz": query.get("channel_width_ghz"), + "capacity_gbps": query.get("capacity_gbps"), + "modulation_format": query.get("modulation_format", ""), + "preferred_band": query.get("preferred_band", "c_slots"), + }, + } + return {"action": "legacy.get", "payload": {"path": path}} + + +def action_from_legacy_post_path( + path: str, payload: Dict[str, Any] +) -> Dict[str, Any]: + """Translate old peer POST paths into explicit A2A action payloads.""" + + parsed = urlparse(path) + mapping = { + "/spectrum/reserve": "spectrum.reserve", + "/spectrum/update": "spectrum.update", + "/spectrum/candidates": "spectrum.candidates", + "/services/optical": "services.optical.create", + "/services/delete": "services.delete", + "/services/delete-by-request": "services.delete_by_request", + "/workflows/cross-domain-optical": ( + "workflows.cross_domain_optical.create" + ), + "/workflows/cross-domain-optical/delete": ( + "workflows.cross_domain_optical.delete" + ), + } + return { + "action": mapping.get(parsed.path, "legacy.post"), + "payload": dict(payload or {}), + } + + +async def call_a2a_action( + domain_base_url: str, + action: str, + payload: Dict[str, Any], +) -> Dict[str, Any]: + """Call a peer through the A2A SDK and return its DataPart artifact.""" + + httpx_client = await _shared_http_client() + agent_card = await _agent_card(domain_base_url) + client = await ClientFactory.connect( + agent_card, + client_config=ClientConfig( + streaming=False, + polling=False, + httpx_client=httpx_client, + supported_transports=[TransportProtocol.jsonrpc], + ), + ) + message = Message( + role=Role.user, + messageId=str(uuid.uuid4()), + parts=[ + Part( + root=DataPart( + data={ + "action": action, + "payload": payload or {}, + "protocol": "md-naf-a2a-v1", + "requesting_domain": DOMAIN_ID, + } + ) + ) + ], + ) + async for event in client.send_message(message): + parsed = _extract_response_payload(event) + if parsed is not None: + if parsed.get("ok") is False and "result" not in parsed: + return parsed + return parsed.get("result", parsed) + return {"ok": False, "error": "a2a_no_response", "action": action} + + +async def _shared_http_client() -> httpx.AsyncClient: + global _A2A_HTTP_CLIENT # pylint: disable=global-statement + global _A2A_HTTP_CLIENT_LOCK # pylint: disable=global-statement + + loop = asyncio.get_running_loop() + if ( + _A2A_HTTP_CLIENT is not None + and _A2A_HTTP_CLIENT[0] is loop + and not _A2A_HTTP_CLIENT[1].is_closed + ): + return _A2A_HTTP_CLIENT[1] + if _A2A_HTTP_CLIENT_LOCK is None: + _A2A_HTTP_CLIENT_LOCK = asyncio.Lock() + async with _A2A_HTTP_CLIENT_LOCK: + if ( + _A2A_HTTP_CLIENT is not None + and _A2A_HTTP_CLIENT[0] is loop + and not _A2A_HTTP_CLIENT[1].is_closed + ): + return _A2A_HTTP_CLIENT[1] + _A2A_HTTP_CLIENT = (loop, httpx.AsyncClient(timeout=DEFAULT_TIMEOUT)) + return _A2A_HTTP_CLIENT[1] + + +async def _agent_card(domain_base_url: str) -> AgentCard: + base_url = domain_base_url.rstrip("/") + cached = _A2A_AGENT_CARD_CACHE.get(base_url) + if cached is not None: + return cached + + lock = _A2A_AGENT_CARD_LOCKS.setdefault(base_url, asyncio.Lock()) + async with lock: + cached = _A2A_AGENT_CARD_CACHE.get(base_url) + if cached is not None: + return cached + resolver = A2ACardResolver(await _shared_http_client(), base_url) + card = await resolver.get_agent_card() + _A2A_AGENT_CARD_CACHE[base_url] = card + LOGGER.info("Cached A2A Agent Card for %s", base_url) + return card + + +def _extract_response_payload(event: Any) -> Dict[str, Any] | None: + if isinstance(event, Message): + return _extract_message_data(event) + if isinstance(event, tuple) and event: + task = event[0] + for artifact in getattr(task, "artifacts", None) or []: + for part in artifact.parts or []: + if isinstance(part.root, DataPart): + return dict(part.root.data or {}) + status = getattr(task, "status", None) + message = getattr(status, "message", None) + if message is not None: + return _extract_message_data(message) + return None + + +def _extract_message_data(message: Message) -> Dict[str, Any] | None: + for part in message.parts or []: + root = part.root + 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}} + return None + + +class MdNafDomainA2AExecutor(AgentExecutor): + """A2A SDK executor exposing deterministic MD-NAF domain skills.""" + + async def execute( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: + request = _message_payload(context.message) + 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": "md-naf-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( + "A2A action failed action=%s domain=%s", + action, + DOMAIN_ID, + ) + response = { + "ok": False, + "protocol": "md-naf-a2a-v1", + "domain_id": DOMAIN_ID, + "domain_name": DOMAIN_NAME, + "action": action, + "error": "a2a_action_failed", + "message": str(exc), + } + await event_queue.enqueue_event(_response_message(response, context)) + + async def cancel( + self, + context: RequestContext, + event_queue: EventQueue, + ) -> None: + await event_queue.enqueue_event( + _response_message( + { + "ok": False, + "protocol": "md-naf-a2a-v1", + "domain_id": DOMAIN_ID, + "error": "cancel_not_supported", + }, + context, + ) + ) + + +async def dispatch_a2a_action( + action: str, payload: Dict[str, Any] +) -> Dict[str, Any]: + """Dispatch one standardized A2A action to local deterministic tools.""" + + # Import workflow functions lazily to avoid circular imports: + # service_workflow imports peer_client, which imports this module. + # pylint: disable=import-outside-toplevel + from agentic.service.tools.service_workflow import ( + _local_device_connections, + consume_tfs_spectrum_reservation, + compute_tfs_optical_connectivity_candidates, + create_cross_domain_optical_service, + create_optical_connectivity_service, + create_tfs_spectrum_reservation, + delete_cross_domain_optical_service, + delete_local_services_for_request, + delete_service, + list_domain_inventory, + list_mdnaf_domains, + locate_device, + release_tfs_spectrum_reservation, + ) + # pylint: disable=import-outside-toplevel + from agentic.service.tools.mcp_client import call_mcp_tool + + if action == "inventory.devices": + result = await call_mcp_tool("tfs_list_devices", {}) + return { + "ok": True, + "domain_id": DOMAIN_ID, + "devices": ( + result.get("devices", []) if isinstance(result, dict) else [] + ), + } + if action == "inventory.optical_links": + result = await call_mcp_tool("tfs_list_optical_links", {}) + return { + "ok": True, + "domain_id": DOMAIN_ID, + "optical_links": ( + result.get("optical_links", []) + if isinstance(result, dict) + else [] + ), + } + if action == "inventory.resources": + return await list_domain_inventory( + scope="local", + resource_kind=str(payload.get("resource_kind", "devices")), + device_filter=str(payload.get("device_filter", "")), + detail_level=str(payload.get("detail_level", "summary")), + include_config_rules=bool( + payload.get("include_config_rules", False) + ), + include_ids=bool(payload.get("include_ids", False)), + include_optical_links=bool( + payload.get("include_optical_links", False) + ), + user_id=str(payload.get("user_id", "a2a")), + session_id=str(payload.get("session_id", "a2a")), + ) + if action == "devices.locate": + return await locate_device( + str(payload.get("device_name", "")), + user_id=str(payload.get("user_id", "a2a")), + session_id=str(payload.get("session_id", "a2a")), + ) + if action == "devices.connections": + return await _local_device_connections( + str(payload.get("device_name", "")), + bool(payload.get("include_packet_links", True)), + bool(payload.get("include_optical_links", True)), + ) + if action == "domains.list": + return await list_mdnaf_domains( + include_devices=bool(payload.get("include_devices", True)), + user_id=str(payload.get("user_id", "a2a")), + session_id=str(payload.get("session_id", "a2a")), + ) + if action == "spectrum.candidates": + if payload.get("source_device") and payload.get("destination_device"): + return await compute_tfs_optical_connectivity_candidates( + str(payload.get("source_device", "")), + str(payload.get("destination_device", "")), + channel_width_ghz=( + float(payload["channel_width_ghz"]) + if payload.get("channel_width_ghz") not in (None, "") + else None + ), + capacity_gbps=( + float(payload["capacity_gbps"]) + if payload.get("capacity_gbps") not in (None, "") + else None + ), + modulation_format=str(payload.get("modulation_format", "")), + preferred_band=str(payload.get("preferred_band", "c_slots")), + ) + return spectrum.candidates( + int(payload.get("required_slots", DEFAULT_SLOT_WIDTH)) + ) + if action == "spectrum.reserve": + candidate_bundle = dict(payload.get("candidate_bundle", {})) + if candidate_bundle: + return await create_tfs_spectrum_reservation( + str(payload.get("service_id", "")), + dict(payload.get("selected_range", {})), + candidate_bundle, + dict(payload.get("metadata", {})), + ) + return { + "ok": False, + "error": ( + "candidate_bundle_required_for_controller_authoritative_" + "reservation" + ), + "service_id": str(payload.get("service_id", "")), + } + if action == "spectrum.update": + status = str(payload.get("status", "")).strip().lower() + if status in {"committed", "consumed", "consume"}: + return await consume_tfs_spectrum_reservation( + str(payload.get("reservation_id", "")), + str( + payload.get("service_id", "") + or payload.get("reservation_id", "") + ), + str(payload.get("context_uuid", "") or "admin"), + ) + if status in {"rolled_back", "released", "release"}: + return await release_tfs_spectrum_reservation( + str(payload.get("reservation_id", "")), + str(payload.get("context_uuid", "") or "admin"), + ) + return { + "ok": False, + "error": "unsupported_controller_reservation_status", + "reservation_id": str(payload.get("reservation_id", "")), + "status": str(payload.get("status", "")), + } + if action == "services.optical.create": + return await create_optical_connectivity_service( + str(payload.get("source_device", "")), + str(payload.get("destination_device", "")), + service_name=str(payload.get("service_name", "")), + source_endpoint=str(payload.get("source_endpoint", "")), + destination_endpoint=str(payload.get("destination_endpoint", "")), + preferred_band=str(payload.get("preferred_band", "")), + capacity_gbps=payload.get("capacity_gbps"), + modulation_format=str(payload.get("modulation_format", "")), + channel_width_ghz=payload.get("channel_width_ghz"), + required_slots_override=payload.get("required_slots_override"), + slot_width_ghz_override=payload.get("slot_width_ghz_override"), + ) + if action == "services.delete": + candidate_service_ids = payload.get("candidate_service_ids", []) + if not isinstance(candidate_service_ids, list): + candidate_service_ids = [] + result = await delete_service( + str(payload.get("service_uuid", "")), + correlation_id=str(payload.get("correlation_id", "")), + candidate_service_ids=[str(item) + for item in candidate_service_ids], + ) + result["spectrum_release"] = spectrum.release_by_service( + str(payload.get("service_uuid", "")) + ) + return result + if action == "services.delete_by_request": + candidate_service_ids = payload.get("candidate_service_ids", []) + if not isinstance(candidate_service_ids, list): + candidate_service_ids = [] + return await delete_local_services_for_request( + str(payload.get("request_id", "")), + candidate_service_ids=[str(item) + for item in candidate_service_ids], + ) + if action == "workflows.cross_domain_optical.create": + return await create_cross_domain_optical_service( + str(payload.get("source_device", "")), + str(payload.get("destination_device", "")), + service_name=str(payload.get("service_name", "")), + minimum_slot=payload.get("minimum_slot"), + preferred_band=str(payload.get("preferred_band", "")), + capacity_gbps=payload.get("capacity_gbps"), + modulation_format=str(payload.get("modulation_format", "")), + channel_width_ghz=payload.get("channel_width_ghz"), + user_id=str(payload.get("user_id", "a2a")), + session_id=str(payload.get("session_id", "a2a")), + ) + if action == "workflows.cross_domain_optical.delete": + return await delete_cross_domain_optical_service( + str(payload.get("request_id", "")), + peer_domain=str(payload.get("peer_domain", "")), + user_id=str(payload.get("user_id", "a2a")), + session_id=str(payload.get("session_id", "a2a")), + ) + return { + "ok": False, + "error": "unsupported_a2a_action", + "action": action, + "payload": payload, + } diff --git a/src/agentic/service/agent.py b/src/agentic/service/agent.py new file mode 100644 index 000000000..7a40ab0dc --- /dev/null +++ b/src/agentic/service/agent.py @@ -0,0 +1,26 @@ +# 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 entrypoint for the isolated MD-NAF runtime.""" + +from agentic.service.settings import AGENT_GRAPH + +if AGENT_GRAPH in {"legacy", "inventory_service"}: + from .agents import inventory_service_root_agent as root_agent +elif AGENT_GRAPH == "single": + from .agents.single import single_root_agent as root_agent +else: + from .agents import root_agent + +__all__ = ["root_agent"] diff --git a/src/agentic/service/agents/__init__.py b/src/agentic/service/agents/__init__.py new file mode 100644 index 000000000..c46447d7d --- /dev/null +++ b/src/agentic/service/agents/__init__.py @@ -0,0 +1,212 @@ +# 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. + +"""Agent package exports with lazy loading.""" + +__all__ = [ + "connection_header_query_agent", + "cross_domain_connection_retrieval_agent", + "cross_domain_device_retrieval_agent", + "cross_domain_optical_link_retrieval_agent", + "cross_domain_optical_create_agent", + "cross_domain_optical_delete_agent", + "cross_domain_packet_link_retrieval_agent", + "cross_domain_retrieval_agent", + "cross_domain_service_retrieval_agent", + "detailed_connection_query_agent", + "detailed_device_query_agent", + "detailed_optical_link_query_agent", + "detailed_packet_link_query_agent", + "detailed_service_query_agent", + "device_mutation_agent", + "device_header_query_agent", + "inventory_agent", + "inventory_service_root_agent", + "local_optical_create_agent", + "local_optical_delete_agent", + "local_connection_retrieval_agent", + "local_device_retrieval_agent", + "local_optical_link_retrieval_agent", + "local_packet_link_retrieval_agent", + "local_retrieval_agent", + "local_service_retrieval_agent", + "l2vpn_service_mutation_agent", + "l3vpn_service_mutation_agent", + "link_mutation_agent", + "mutation_router_agent", + "optical_service_mutation_agent", + "optical_link_header_query_agent", + "packet_link_header_query_agent", + "retrieval_router_agent", + "root_agent", + "service_agent", + "service_header_query_agent", + "service_mutation_agent", +] + + +def __getattr__(name): + if name == "root_agent": + from .granular_root import root_agent + + return root_agent + if name == "inventory_service_root_agent": + from .root import inventory_service_root_agent + + return inventory_service_root_agent + if name == "inventory_agent": + from .inventory import inventory_agent + + return inventory_agent + if name == "service_agent": + from .service import service_agent + + return service_agent + if name == "retrieval_router_agent": + from .retrieval import retrieval_router_agent + + return retrieval_router_agent + if name == "local_retrieval_agent": + from .retrieval import local_retrieval_agent + + return local_retrieval_agent + if name == "cross_domain_retrieval_agent": + from .retrieval import cross_domain_retrieval_agent + + return cross_domain_retrieval_agent + if name == "local_device_retrieval_agent": + from .retrieval import local_device_retrieval_agent + + return local_device_retrieval_agent + if name == "cross_domain_device_retrieval_agent": + from .retrieval import cross_domain_device_retrieval_agent + + return cross_domain_device_retrieval_agent + if name == "local_packet_link_retrieval_agent": + from .retrieval import local_packet_link_retrieval_agent + + return local_packet_link_retrieval_agent + if name == "cross_domain_packet_link_retrieval_agent": + from .retrieval import cross_domain_packet_link_retrieval_agent + + return cross_domain_packet_link_retrieval_agent + if name == "local_optical_link_retrieval_agent": + from .retrieval import local_optical_link_retrieval_agent + + return local_optical_link_retrieval_agent + if name == "cross_domain_optical_link_retrieval_agent": + from .retrieval import cross_domain_optical_link_retrieval_agent + + return cross_domain_optical_link_retrieval_agent + if name == "local_service_retrieval_agent": + from .retrieval import local_service_retrieval_agent + + return local_service_retrieval_agent + if name == "cross_domain_service_retrieval_agent": + from .retrieval import cross_domain_service_retrieval_agent + + return cross_domain_service_retrieval_agent + if name == "local_connection_retrieval_agent": + from .retrieval import local_connection_retrieval_agent + + return local_connection_retrieval_agent + if name == "cross_domain_connection_retrieval_agent": + from .retrieval import cross_domain_connection_retrieval_agent + + return cross_domain_connection_retrieval_agent + if name == "mutation_router_agent": + from .mutation import mutation_router_agent + + return mutation_router_agent + if name == "service_mutation_agent": + from .mutation import service_mutation_agent + + return service_mutation_agent + if name == "optical_service_mutation_agent": + from .mutation import optical_service_mutation_agent + + return optical_service_mutation_agent + if name == "l2vpn_service_mutation_agent": + from .mutation import l2vpn_service_mutation_agent + + return l2vpn_service_mutation_agent + if name == "l3vpn_service_mutation_agent": + from .mutation import l3vpn_service_mutation_agent + + return l3vpn_service_mutation_agent + if name == "device_mutation_agent": + from .mutation import device_mutation_agent + + return device_mutation_agent + if name == "link_mutation_agent": + from .mutation import link_mutation_agent + + return link_mutation_agent + if name == "local_optical_create_agent": + from .optical_create import local_optical_create_agent + + return local_optical_create_agent + if name == "cross_domain_optical_create_agent": + from .optical_create import cross_domain_optical_create_agent + + return cross_domain_optical_create_agent + if name == "local_optical_delete_agent": + from .optical_delete import local_optical_delete_agent + + return local_optical_delete_agent + if name == "cross_domain_optical_delete_agent": + from .optical_delete import cross_domain_optical_delete_agent + + return cross_domain_optical_delete_agent + if name == "device_header_query_agent": + from .inventory_granular import device_header_query_agent + + return device_header_query_agent + if name == "detailed_device_query_agent": + from .inventory_granular import detailed_device_query_agent + + return detailed_device_query_agent + if name == "packet_link_header_query_agent": + from .inventory_granular import packet_link_header_query_agent + + return packet_link_header_query_agent + if name == "detailed_packet_link_query_agent": + from .inventory_granular import detailed_packet_link_query_agent + + return detailed_packet_link_query_agent + if name == "optical_link_header_query_agent": + from .inventory_granular import optical_link_header_query_agent + + return optical_link_header_query_agent + if name == "detailed_optical_link_query_agent": + from .inventory_granular import detailed_optical_link_query_agent + + return detailed_optical_link_query_agent + if name == "service_header_query_agent": + from .inventory_granular import service_header_query_agent + + return service_header_query_agent + if name == "detailed_service_query_agent": + from .inventory_granular import detailed_service_query_agent + + return detailed_service_query_agent + if name == "connection_header_query_agent": + from .inventory_granular import connection_header_query_agent + + return connection_header_query_agent + if name == "detailed_connection_query_agent": + from .inventory_granular import detailed_connection_query_agent + + return detailed_connection_query_agent + raise AttributeError(name) diff --git a/src/agentic/service/agents/granular_root.py b/src/agentic/service/agents/granular_root.py new file mode 100644 index 000000000..633b73a10 --- /dev/null +++ b/src/agentic/service/agents/granular_root.py @@ -0,0 +1,50 @@ +# 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. + +"""Granular root router agent.""" + +from __future__ import annotations + +from google.adk.agents.llm_agent import Agent +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.llm import build_litellm + + +GRANULAR_ROOT_ROUTER_INSTRUCTION = """ +You are the first MD-NAF request router. + +Choose only between: +- retrieval_router_agent for read-only retrieval, listing, inspection, + location, details, links, services, connections, topology, or inventory. +- mutation_router_agent for create, delete, remove, provision, teardown, + reserve, update, or configure requests. + +Do not answer directly and do not call tools. Preserve the user's names, +request IDs, bandwidth, and spectrum wording when delegating. +""" + +LiteLlm.model_fields["llm_client"].exclude = True +LiteLlm.model_rebuild(force=True) + + +root_agent = Agent( + model=build_litellm(), + name="md_naf_granular_root_router", + description="Minimal MD-NAF root router for granular specialist agents.", + instruction=GRANULAR_ROOT_ROUTER_INSTRUCTION, + sub_agents=[retrieval_router_agent, mutation_router_agent], +) diff --git a/src/agentic/service/agents/inventory.py b/src/agentic/service/agents/inventory.py new file mode 100644 index 000000000..51effcffd --- /dev/null +++ b/src/agentic/service/agents/inventory.py @@ -0,0 +1,58 @@ +# 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. + +"""Inventory specialist agent.""" + +from __future__ import annotations + +from google.adk.agents.llm_agent import Agent + +from agentic.service.llm import build_litellm +from agentic.service.tools.mcp import build_tfs_mcp_toolset + + +from agentic.service.graphs import graph_summary + + +INVENTORY_AGENT_INSTRUCTION = f""" +You are the MD-NAF TFS inventory specialist. + +Use only the exposed MCP tools to answer controller inventory questions. +The controller is the source of truth. +Default missing context_uuid to "admin". +Default missing topology_uuid to "admin". +Select tools by resource family: +- devices or nodes: call tfs_list_devices with context_uuid="admin" unless the + user explicitly asks for a specific device, in which case call tfs_get_device. +- packet links: use link tools. +- optical links: use optical link tools. +- services or optical services: use service tools. +- connections: use connection tools. +Do not call topology tools before device tools for a device/node listing. +Do not confuse links with services. +Do not invent UUIDs, endpoints, topology contents, services, or state. +If a required identifier is missing for a get tool, use the matching list tool +first. + +Static graph contracts available to this runtime: +{graph_summary()} +""" + +inventory_agent = Agent( + model=build_litellm(), + name="inventory_agent", + description="Reads TFS controller inventory through MCP tools.", + instruction=INVENTORY_AGENT_INSTRUCTION, + tools=[build_tfs_mcp_toolset()], +) diff --git a/src/agentic/service/agents/inventory_granular.py b/src/agentic/service/agents/inventory_granular.py new file mode 100644 index 000000000..a066aacff --- /dev/null +++ b/src/agentic/service/agents/inventory_granular.py @@ -0,0 +1,205 @@ +# 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. + +"""Granular inventory query agents.""" + +from __future__ import annotations + +from google.adk.agents.llm_agent import Agent + +from agentic.service.llm import build_litellm +from agentic.service.tools.granular import ( + CONNECTION_INVENTORY_QUERY_TOOLS, + DETAILED_CONNECTION_INVENTORY_QUERY_TOOLS, + DETAILED_DEVICE_ENDPOINT_QUERY_TOOLS, + DETAILED_OPTICAL_LINK_QUERY_TOOLS, + DETAILED_PACKET_LINK_QUERY_TOOLS, + DETAILED_SERVICE_INVENTORY_QUERY_TOOLS, + DEVICE_ENDPOINT_QUERY_TOOLS, + OPTICAL_LINK_QUERY_TOOLS, + PACKET_LINK_QUERY_TOOLS, + SERVICE_INVENTORY_QUERY_TOOLS, +) + + +DEVICE_HEADER_QUERY_INSTRUCTION = """ +You answer compact device and endpoint inventory questions. + +Use query_device_location for where/is-located questions. +Use query_device_connections for neighbor or connected-to questions. +Use query_devices_or_endpoints for device, node, endpoint, port, datacenter, +transponder, ROADM, or router listings. Choose scope local, remote, or all from +the user wording. The tool returns header-level structured data; compose a +concise answer from counts and names. +""" + +DETAILED_DEVICE_QUERY_INSTRUCTION = """ +You answer detailed device and endpoint inventory questions. + +Use query_detailed_devices_or_endpoints when the user asks for details, raw +payloads, UUIDs, endpoint IDs, config rules, or debug output. Preserve relevant +details in the answer but keep it readable. +""" + +PACKET_LINK_HEADER_QUERY_INSTRUCTION = """ +You answer compact packet-link questions only. + +Use query_packet_links. Choose scope local, remote, or all from user wording. +Do not answer optical-link or service questions. The tool returns compact link +headers; compose a concise answer. +""" + +DETAILED_PACKET_LINK_QUERY_INSTRUCTION = """ +You answer detailed packet-link questions only. + +Use query_detailed_packet_links when the user asks for raw, full, detailed, ID, +or debug packet-link output. +""" + +OPTICAL_LINK_HEADER_QUERY_INSTRUCTION = """ +You answer compact optical-link and fiber-link questions only. + +Use query_optical_links. Choose scope local, remote, or all from user wording. +Do not answer packet-link or service questions. The tool returns compact link +headers; compose a concise answer. +""" + +DETAILED_OPTICAL_LINK_QUERY_INSTRUCTION = """ +You answer detailed optical-link and fiber-link questions only. + +Use query_detailed_optical_links when the user asks for raw, full, detailed, +ID, or debug optical-link output. +""" + +SERVICE_HEADER_QUERY_INSTRUCTION = """ +You answer compact service inventory questions only. + +Use query_services. Choose scope local, remote, or all from user wording. +Do not confuse services with links or connections. The tool returns compact +service headers; compose a concise answer. +""" + +DETAILED_SERVICE_QUERY_INSTRUCTION = """ +You answer detailed service inventory questions only. + +Use query_detailed_services when the user asks for raw, full, detailed, ID, +config-rule, or debug service output. +""" + +CONNECTION_HEADER_QUERY_INSTRUCTION = """ +You answer compact TFS connection inventory questions only. + +Use query_connections. Choose scope local, remote, or all from user wording. +Do not confuse connections with services or links. The tool returns compact +connection headers; compose a concise answer. +""" + +DETAILED_CONNECTION_QUERY_INSTRUCTION = """ +You answer detailed TFS connection inventory questions only. + +Use query_detailed_connections when the user asks for raw, full, detailed, ID, +or debug connection output. +""" + +device_header_query_agent = Agent( + model=build_litellm(), + name="device_header_query_agent", + description=( + "Answers compact device, endpoint, port, datacenter, transponder, " + "ROADM, router, and neighbor questions." + ), + instruction=DEVICE_HEADER_QUERY_INSTRUCTION, + tools=DEVICE_ENDPOINT_QUERY_TOOLS, +) + + +detailed_device_query_agent = Agent( + model=build_litellm(), + name="detailed_device_query_agent", + description="Answers detailed/raw device and endpoint questions.", + instruction=DETAILED_DEVICE_QUERY_INSTRUCTION, + tools=DETAILED_DEVICE_ENDPOINT_QUERY_TOOLS, +) + + +packet_link_header_query_agent = Agent( + model=build_litellm(), + name="packet_link_header_query_agent", + description="Answers compact packet-link inventory questions.", + instruction=PACKET_LINK_HEADER_QUERY_INSTRUCTION, + tools=PACKET_LINK_QUERY_TOOLS, +) + + +detailed_packet_link_query_agent = Agent( + model=build_litellm(), + name="detailed_packet_link_query_agent", + description="Answers detailed/raw packet-link inventory questions.", + instruction=DETAILED_PACKET_LINK_QUERY_INSTRUCTION, + tools=DETAILED_PACKET_LINK_QUERY_TOOLS, +) + + +optical_link_header_query_agent = Agent( + model=build_litellm(), + name="optical_link_header_query_agent", + description="Answers compact optical-link inventory questions.", + instruction=OPTICAL_LINK_HEADER_QUERY_INSTRUCTION, + tools=OPTICAL_LINK_QUERY_TOOLS, +) + + +detailed_optical_link_query_agent = Agent( + model=build_litellm(), + name="detailed_optical_link_query_agent", + description="Answers detailed/raw optical-link inventory questions.", + instruction=DETAILED_OPTICAL_LINK_QUERY_INSTRUCTION, + tools=DETAILED_OPTICAL_LINK_QUERY_TOOLS, +) + + +service_header_query_agent = Agent( + model=build_litellm(), + name="service_header_query_agent", + description="Answers compact service inventory questions.", + instruction=SERVICE_HEADER_QUERY_INSTRUCTION, + tools=SERVICE_INVENTORY_QUERY_TOOLS, +) + + +detailed_service_query_agent = Agent( + model=build_litellm(), + name="detailed_service_query_agent", + description="Answers detailed/raw service inventory questions.", + instruction=DETAILED_SERVICE_QUERY_INSTRUCTION, + tools=DETAILED_SERVICE_INVENTORY_QUERY_TOOLS, +) + + +connection_header_query_agent = Agent( + model=build_litellm(), + name="connection_header_query_agent", + description="Answers compact TFS connection inventory questions.", + instruction=CONNECTION_HEADER_QUERY_INSTRUCTION, + tools=CONNECTION_INVENTORY_QUERY_TOOLS, +) + + +detailed_connection_query_agent = Agent( + model=build_litellm(), + name="detailed_connection_query_agent", + description="Answers detailed/raw TFS connection inventory questions.", + instruction=DETAILED_CONNECTION_QUERY_INSTRUCTION, + tools=DETAILED_CONNECTION_INVENTORY_QUERY_TOOLS, +) diff --git a/src/agentic/service/agents/mutation.py b/src/agentic/service/agents/mutation.py new file mode 100644 index 000000000..9cd9cd0e1 --- /dev/null +++ b/src/agentic/service/agents/mutation.py @@ -0,0 +1,193 @@ +# 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. + +"""Mutation router and mutation executor agents.""" + +from __future__ import annotations + +from google.adk.agents.llm_agent import Agent + +from agentic.service.llm import build_litellm +from agentic.service.agents.optical_create import ( + cross_domain_optical_create_agent, + local_optical_create_agent, +) +from agentic.service.agents.optical_delete import ( + cross_domain_optical_delete_agent, + local_optical_delete_agent, +) + + +MUTATION_ROUTER_INSTRUCTION = """ +You route mutation requests. + +Delegate by mutated resource: +- service_mutation_agent for service creation, service deletion, optical + connectivity provisioning, L2VPN, or L3VPN requests. +- device_mutation_agent for device enable, disable, activation, deactivation, + administrative state, or manual device configuration-rule changes. +- link_mutation_agent for link enable, disable, activation, deactivation, or + administrative state changes. + +Connection mutation is intentionally not a separate branch. Connections are +managed through their parent service workflows. +Do not call tools yourself. +""" + +SERVICE_MUTATION_INSTRUCTION = """ +You route service mutations by service family. + +Delegate by service family: +- optical_service_mutation_agent for optical connectivity, lightpath, + spectrum, flex-grid, transponder, or ROADM service mutations. +- l2vpn_service_mutation_agent for L2VPN, L2, Ethernet, VLAN, or E-Line + service mutations. +- l3vpn_service_mutation_agent for L3VPN, IP VPN, VRF, routing, or routed + service mutations. + +If service family is ambiguous, ask one concise clarification question. +Do not call tools yourself. +""" + +OPTICAL_SERVICE_MUTATION_INSTRUCTION = """ +You route optical service mutations. + +For creation: +- Delegate to local_optical_create_agent only when the user explicitly asks for + a local-only optical service. +- Delegate to cross_domain_optical_create_agent for cross-domain, remote, peer, + all-domain, or ambiguous optical services. If the user does not say local, + treat the request as cross-domain capable. + +For deletion: +- Delegate to local_optical_delete_agent for an explicitly local service + UUID/name. +- Delegate to cross_domain_optical_delete_agent for xdom request IDs or + ambiguous optical service removal. If the user does not say local, treat + deletion as cross-domain capable. + +Do not call tools yourself. Do not retrieve unrelated inventory. +""" + +L2VPN_SERVICE_MUTATION_INSTRUCTION = """ +You are a placeholder for L2VPN service mutations. + +State that L2VPN creation/deletion is not executable in this prototype branch +yet. Mention that future implementation should validate packet endpoints, +VLAN/Ethernet constraints, and the TFS two-step service lifecycle. +Do not call tools. +""" + +L3VPN_SERVICE_MUTATION_INSTRUCTION = """ +You are a placeholder for L3VPN service mutations. + +State that L3VPN creation/deletion is not executable in this prototype branch +yet. Mention that future implementation should validate packet endpoints, VRF +or routing parameters, and the TFS two-step service lifecycle. +Do not call tools. +""" + +DEVICE_MUTATION_INSTRUCTION = """ +You are a placeholder for device mutations. + +State that device enable/disable and manual device config-rule mutation are not +executable in this prototype branch yet. Mention that future implementation +must validate the target device through MCP, build deterministic payloads, ask +for confirmation for disruptive actions, and verify post-state from TFS. +Do not call tools. +""" + +LINK_MUTATION_INSTRUCTION = """ +You are a placeholder for link mutations. + +State that link enable/disable mutation is not executable in this prototype +branch yet. Mention that future implementation must validate the target link +through MCP, build deterministic payloads, ask for confirmation for disruptive +actions, and verify post-state from TFS. +Do not call tools. +""" + +optical_service_mutation_agent = Agent( + model=build_litellm(), + name="optical_service_mutation_agent", + description="Routes optical service creation and removal mutations.", + instruction=OPTICAL_SERVICE_MUTATION_INSTRUCTION, + sub_agents=[ + local_optical_create_agent, + cross_domain_optical_create_agent, + local_optical_delete_agent, + cross_domain_optical_delete_agent, + ], +) + + +l2vpn_service_mutation_agent = Agent( + model=build_litellm(), + name="l2vpn_service_mutation_agent", + description="Placeholder for future L2VPN service mutations.", + instruction=L2VPN_SERVICE_MUTATION_INSTRUCTION, +) + + +l3vpn_service_mutation_agent = Agent( + model=build_litellm(), + name="l3vpn_service_mutation_agent", + description="Placeholder for future L3VPN service mutations.", + instruction=L3VPN_SERVICE_MUTATION_INSTRUCTION, +) + + +device_mutation_agent = Agent( + model=build_litellm(), + name="device_mutation_agent", + description=( + "Placeholder for future device enable, disable, and config-rule " + "mutations." + ), + instruction=DEVICE_MUTATION_INSTRUCTION, +) + + +link_mutation_agent = Agent( + model=build_litellm(), + name="link_mutation_agent", + description="Placeholder for future link enable and disable mutations.", + instruction=LINK_MUTATION_INSTRUCTION, +) + + +service_mutation_agent = Agent( + model=build_litellm(), + name="service_mutation_agent", + description="Routes service mutations by service family.", + instruction=SERVICE_MUTATION_INSTRUCTION, + sub_agents=[ + optical_service_mutation_agent, + l2vpn_service_mutation_agent, + l3vpn_service_mutation_agent, + ], +) + + +mutation_router_agent = Agent( + model=build_litellm(), + name="mutation_router_agent", + description="Routes mutation requests by target resource.", + instruction=MUTATION_ROUTER_INSTRUCTION, + sub_agents=[ + service_mutation_agent, + device_mutation_agent, + link_mutation_agent, + ], +) diff --git a/src/agentic/service/agents/optical_create.py b/src/agentic/service/agents/optical_create.py new file mode 100644 index 000000000..0f19d516b --- /dev/null +++ b/src/agentic/service/agents/optical_create.py @@ -0,0 +1,67 @@ +# 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. + +"""Granular optical service creation agents.""" + +from __future__ import annotations + +from google.adk.agents.llm_agent import Agent + +from agentic.service.llm import build_litellm +from agentic.service.tools.granular import ( + CROSS_DOMAIN_OPTICAL_CREATE_TOOLS, + LOCAL_OPTICAL_CREATE_TOOLS, +) + + +LOCAL_OPTICAL_CREATE_INSTRUCTION = """ +You create local optical services only. + +Extract source_device, destination_device, optional service_name, and optical +sizing. Sizing must be explicit channel_width_ghz, or ask for clarification. +Call create_local_optical_service exactly once when the required fields exist. +Report success only if the tool reports ok=true and ACTIVE status. +""" + +CROSS_DOMAIN_OPTICAL_CREATE_INSTRUCTION = """ +You create cross-domain optical services only. + +Extract source_device, destination_device, optional service_name, optional +preferred_band slot range, optional minimum_slot, and channel_width_ghz. +If sizing is missing, ask for it. If the user says 50 GHz, pass +channel_width_ghz=50. If the user gives slots like 20-35, pass preferred_band. +If the user says slot 20+ or beyond slot 20, pass minimum_slot=20. +Call create_cross_domain_optical_service_tool exactly once when required fields +exist. Report request_id, domain_path, segment service IDs, preferred_band, and +ACTIVE state when successful. +""" + +local_optical_create_agent = Agent( + model=build_litellm(), + name="local_optical_create_agent", + description="Creates local optical services with one narrow workflow tool.", + instruction=LOCAL_OPTICAL_CREATE_INSTRUCTION, + tools=LOCAL_OPTICAL_CREATE_TOOLS, +) + + +cross_domain_optical_create_agent = Agent( + model=build_litellm(), + name="cross_domain_optical_create_agent", + description=( + "Creates cross-domain optical services with path-wide negotiation." + ), + instruction=CROSS_DOMAIN_OPTICAL_CREATE_INSTRUCTION, + tools=CROSS_DOMAIN_OPTICAL_CREATE_TOOLS, +) diff --git a/src/agentic/service/agents/optical_delete.py b/src/agentic/service/agents/optical_delete.py new file mode 100644 index 000000000..cef9ca8f6 --- /dev/null +++ b/src/agentic/service/agents/optical_delete.py @@ -0,0 +1,62 @@ +# 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. + +"""Granular optical service deletion agents.""" + +from __future__ import annotations + +from google.adk.agents.llm_agent import Agent + +from agentic.service.llm import build_litellm +from agentic.service.tools.granular import ( + CROSS_DOMAIN_OPTICAL_DELETE_TOOLS, + LOCAL_OPTICAL_DELETE_TOOLS, +) + + +LOCAL_OPTICAL_DELETE_INSTRUCTION = """ +You remove one local optical service. + +Use remove_local_optical_service when the user provides an exact service UUID +or service name. Ask for the service UUID/name if missing. +Report success only when the tool returns ok=true. +""" + +CROSS_DOMAIN_OPTICAL_DELETE_INSTRUCTION = """ +You remove a cross-domain optical service. + +Use remove_cross_domain_optical_service when the user provides an xdom request +ID or equivalent cross-domain request ID. Do not ask for peer domain; the tool +searches relevant domains. Ask only if the request ID is missing. +Report all affected domains and whether deletion converged. +""" + +local_optical_delete_agent = Agent( + model=build_litellm(), + name="local_optical_delete_agent", + description="Deletes one local optical service by UUID or name.", + instruction=LOCAL_OPTICAL_DELETE_INSTRUCTION, + tools=LOCAL_OPTICAL_DELETE_TOOLS, +) + + +cross_domain_optical_delete_agent = Agent( + model=build_litellm(), + name="cross_domain_optical_delete_agent", + description=( + "Deletes all optical service segments for a cross-domain request ID." + ), + instruction=CROSS_DOMAIN_OPTICAL_DELETE_INSTRUCTION, + tools=CROSS_DOMAIN_OPTICAL_DELETE_TOOLS, +) diff --git a/src/agentic/service/agents/retrieval.py b/src/agentic/service/agents/retrieval.py new file mode 100644 index 000000000..7049157d6 --- /dev/null +++ b/src/agentic/service/agents/retrieval.py @@ -0,0 +1,293 @@ +# 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. + +"""Retrieval router and retrieval executor agents.""" + +from __future__ import annotations + +from google.adk.agents.llm_agent import Agent + +from agentic.service.llm import build_litellm +from agentic.service.tools.granular import ( + CROSS_DOMAIN_CONNECTION_RETRIEVAL_TOOLS, + CROSS_DOMAIN_DEVICE_RETRIEVAL_TOOLS, + CROSS_DOMAIN_OPTICAL_LINK_RETRIEVAL_TOOLS, + CROSS_DOMAIN_PACKET_LINK_RETRIEVAL_TOOLS, + CROSS_DOMAIN_SERVICE_RETRIEVAL_TOOLS, + LOCAL_CONNECTION_RETRIEVAL_TOOLS, + LOCAL_DEVICE_RETRIEVAL_TOOLS, + LOCAL_OPTICAL_LINK_RETRIEVAL_TOOLS, + LOCAL_PACKET_LINK_RETRIEVAL_TOOLS, + LOCAL_SERVICE_RETRIEVAL_TOOLS, +) + + +RETRIEVAL_ROUTER_INSTRUCTION = """ +You route retrieval requests. + +Decide only: +- local_retrieval_agent for explicitly local controller/domain retrieval. +- cross_domain_retrieval_agent for remote, peer, other, every, or all-domain + retrieval. + +Also preserve whether the user asks for compact headers or detailed/raw/config +data; the selected retrieval agent will choose the appropriate tool. +If scope is unspecified, choose local_retrieval_agent. +Do not call tools yourself. +""" + +LOCAL_RETRIEVAL_INSTRUCTION = """ +You route local retrieval only. + +Delegate by resource family: +- local_device_retrieval_agent for devices, nodes, endpoints, ports, + datacenters, transponders, ROADMs, routers, locations, and neighbors. +- local_packet_link_retrieval_agent for packet links. +- local_optical_link_retrieval_agent for optical links or fiber links. +- local_service_retrieval_agent for services. +- local_connection_retrieval_agent for connections. + +Do not call tools yourself. +""" + +CROSS_DOMAIN_RETRIEVAL_INSTRUCTION = """ +You route cross-domain retrieval only. + +Delegate by resource family: +- cross_domain_device_retrieval_agent for devices, nodes, endpoints, ports, + datacenters, transponders, ROADMs, routers, locations, and neighbors. +- cross_domain_packet_link_retrieval_agent for packet links. +- cross_domain_optical_link_retrieval_agent for optical links or fiber links. +- cross_domain_service_retrieval_agent for services. +- cross_domain_connection_retrieval_agent for connections. + +Do not call tools yourself. +""" + +LOCAL_DEVICE_RETRIEVAL_INSTRUCTION = """ +You execute local device/endpoint retrieval. + +Use query_local_device_headers for normal list/count/summary questions. +Use query_local_detailed_devices only for raw, full, UUID, endpoint ID, +config-rule, or debug requests. Use query_device_location for where questions. +Use query_device_connections for neighbor or connected-to questions. +Render the final answer with the requested level of detail. +""" + +CROSS_DOMAIN_DEVICE_RETRIEVAL_INSTRUCTION = """ +You execute cross-domain device/endpoint retrieval. + +Use query_cross_domain_device_headers for normal list/count/summary questions. +Use query_cross_domain_detailed_devices only for raw, full, UUID, endpoint ID, +config-rule, or debug requests. Use query_device_location for where questions. +Use query_device_connections for neighbor or connected-to questions. +Render the final answer grouped by MD-NAF domain. +""" + +LOCAL_PACKET_LINK_RETRIEVAL_INSTRUCTION = """ +You execute local packet-link retrieval. + +Use query_local_packet_link_headers for normal list/count/summary questions. +Use query_local_detailed_packet_links only for raw, full, UUID, or debug +requests. Do not answer optical-link or service questions. +""" + +CROSS_DOMAIN_PACKET_LINK_RETRIEVAL_INSTRUCTION = """ +You execute cross-domain packet-link retrieval. + +Use query_cross_domain_packet_link_headers for normal list/count/summary +questions. Use query_cross_domain_detailed_packet_links only for raw, full, +UUID, or debug requests. Do not answer optical-link or service questions. +""" + +LOCAL_OPTICAL_LINK_RETRIEVAL_INSTRUCTION = """ +You execute local optical-link retrieval. + +Use query_local_optical_link_headers for normal list/count/summary questions. +Use query_local_detailed_optical_links only for raw, full, UUID, or debug +requests. Do not answer packet-link or service questions. +""" + +CROSS_DOMAIN_OPTICAL_LINK_RETRIEVAL_INSTRUCTION = """ +You execute cross-domain optical-link retrieval. + +Use query_cross_domain_optical_link_headers for normal list/count/summary +questions. Use query_cross_domain_detailed_optical_links only for raw, full, +UUID, or debug requests. Do not answer packet-link or service questions. +""" + +LOCAL_SERVICE_RETRIEVAL_INSTRUCTION = """ +You execute local service retrieval. + +Use query_local_service_headers for normal list/count/summary questions. +Use query_local_detailed_services only for raw, full, UUID, config-rule, or +debug requests. Do not confuse services with links or connections. +""" + +CROSS_DOMAIN_SERVICE_RETRIEVAL_INSTRUCTION = """ +You execute cross-domain service retrieval. + +Use query_cross_domain_service_headers for normal list/count/summary questions. +Use query_cross_domain_detailed_services only for raw, full, UUID, config-rule, +or debug requests. Do not confuse services with links or connections. +""" + +LOCAL_CONNECTION_RETRIEVAL_INSTRUCTION = """ +You execute local connection retrieval. + +Use query_local_connection_headers for normal list/count/summary questions. +Use query_local_detailed_connections only for raw, full, UUID, or debug +requests. Do not confuse connections with services or links. +""" + +CROSS_DOMAIN_CONNECTION_RETRIEVAL_INSTRUCTION = """ +You execute cross-domain connection retrieval. + +Use query_cross_domain_connection_headers for normal list/count/summary +questions. Use query_cross_domain_detailed_connections only for raw, full, +UUID, or debug requests. Do not confuse connections with services or links. +""" + +local_device_retrieval_agent = Agent( + model=build_litellm(), + name="local_device_retrieval_agent", + description=( + "Retrieves local devices, endpoints, ports, and device neighbors." + ), + instruction=LOCAL_DEVICE_RETRIEVAL_INSTRUCTION, + tools=LOCAL_DEVICE_RETRIEVAL_TOOLS, +) + + +local_packet_link_retrieval_agent = Agent( + model=build_litellm(), + name="local_packet_link_retrieval_agent", + description="Retrieves local packet links.", + instruction=LOCAL_PACKET_LINK_RETRIEVAL_INSTRUCTION, + tools=LOCAL_PACKET_LINK_RETRIEVAL_TOOLS, +) + + +local_optical_link_retrieval_agent = Agent( + model=build_litellm(), + name="local_optical_link_retrieval_agent", + description="Retrieves local optical links.", + instruction=LOCAL_OPTICAL_LINK_RETRIEVAL_INSTRUCTION, + tools=LOCAL_OPTICAL_LINK_RETRIEVAL_TOOLS, +) + + +local_service_retrieval_agent = Agent( + model=build_litellm(), + name="local_service_retrieval_agent", + description="Retrieves local services.", + instruction=LOCAL_SERVICE_RETRIEVAL_INSTRUCTION, + tools=LOCAL_SERVICE_RETRIEVAL_TOOLS, +) + + +local_connection_retrieval_agent = Agent( + model=build_litellm(), + name="local_connection_retrieval_agent", + description="Retrieves local connections.", + instruction=LOCAL_CONNECTION_RETRIEVAL_INSTRUCTION, + tools=LOCAL_CONNECTION_RETRIEVAL_TOOLS, +) + + +cross_domain_device_retrieval_agent = Agent( + model=build_litellm(), + name="cross_domain_device_retrieval_agent", + description=( + "Retrieves cross-domain devices, endpoints, ports, and device " + "neighbors." + ), + instruction=CROSS_DOMAIN_DEVICE_RETRIEVAL_INSTRUCTION, + tools=CROSS_DOMAIN_DEVICE_RETRIEVAL_TOOLS, +) + + +cross_domain_packet_link_retrieval_agent = Agent( + model=build_litellm(), + name="cross_domain_packet_link_retrieval_agent", + description="Retrieves cross-domain packet links.", + instruction=CROSS_DOMAIN_PACKET_LINK_RETRIEVAL_INSTRUCTION, + tools=CROSS_DOMAIN_PACKET_LINK_RETRIEVAL_TOOLS, +) + + +cross_domain_optical_link_retrieval_agent = Agent( + model=build_litellm(), + name="cross_domain_optical_link_retrieval_agent", + description="Retrieves cross-domain optical links.", + instruction=CROSS_DOMAIN_OPTICAL_LINK_RETRIEVAL_INSTRUCTION, + tools=CROSS_DOMAIN_OPTICAL_LINK_RETRIEVAL_TOOLS, +) + + +cross_domain_service_retrieval_agent = Agent( + model=build_litellm(), + name="cross_domain_service_retrieval_agent", + description="Retrieves cross-domain services.", + instruction=CROSS_DOMAIN_SERVICE_RETRIEVAL_INSTRUCTION, + tools=CROSS_DOMAIN_SERVICE_RETRIEVAL_TOOLS, +) + + +cross_domain_connection_retrieval_agent = Agent( + model=build_litellm(), + name="cross_domain_connection_retrieval_agent", + description="Retrieves cross-domain connections.", + instruction=CROSS_DOMAIN_CONNECTION_RETRIEVAL_INSTRUCTION, + tools=CROSS_DOMAIN_CONNECTION_RETRIEVAL_TOOLS, +) + + +local_retrieval_agent = Agent( + model=build_litellm(), + name="local_retrieval_agent", + description="Routes local retrieval requests by resource family.", + instruction=LOCAL_RETRIEVAL_INSTRUCTION, + sub_agents=[ + local_device_retrieval_agent, + local_packet_link_retrieval_agent, + local_optical_link_retrieval_agent, + local_service_retrieval_agent, + local_connection_retrieval_agent, + ], +) + + +cross_domain_retrieval_agent = Agent( + model=build_litellm(), + name="cross_domain_retrieval_agent", + description="Routes cross-domain retrieval requests by resource family.", + instruction=CROSS_DOMAIN_RETRIEVAL_INSTRUCTION, + sub_agents=[ + cross_domain_device_retrieval_agent, + cross_domain_packet_link_retrieval_agent, + cross_domain_optical_link_retrieval_agent, + cross_domain_service_retrieval_agent, + cross_domain_connection_retrieval_agent, + ], +) + + +retrieval_router_agent = Agent( + model=build_litellm(), + name="retrieval_router_agent", + description="Routes retrieval requests by scope and detail level.", + instruction=RETRIEVAL_ROUTER_INSTRUCTION, + sub_agents=[local_retrieval_agent, cross_domain_retrieval_agent], +) diff --git a/src/agentic/service/agents/root.py b/src/agentic/service/agents/root.py new file mode 100644 index 000000000..c7e9678a4 --- /dev/null +++ b/src/agentic/service/agents/root.py @@ -0,0 +1,73 @@ +# 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. + +"""Inventory/service coordinator root agent. + +This graph is intentionally kept but not used as the default runtime root. +The default root is defined in granular_root.py. +""" + +from __future__ import annotations + +from google.adk.agents.llm_agent import Agent +from google.adk.models.lite_llm import LiteLlm + +from agentic.service.llm import build_litellm + + +ROOT_AGENT_INSTRUCTION = """ +You are the root coordinator for the MD-NAF TFS-Agentic ADK runtime. + +Current executable scope: +- Read-only TFS controller inventory through the inventory_agent and MCP. +- Optical service creation, deletion, and cross-domain negotiation through + service_agent. +- Session memory can be used for compact follow-up context only. + +For strictly local or unspecified read-only TFS controller inventory requests, +delegate to inventory_agent. +For any request that mentions domain, domains, cross-domain, peer domain, +neighbor domain, remote domain, other domain, all domains, every domain, +spectrum negotiation, optical service creation, optical service deletion, or +asks where a DC/transponder/device is located or what a device is connected to, +delegate to service_agent even if the request is read-only. +If the user asks for remote/all-domain datacenters, transponders, ROADMs, +routers, links, optical links, endpoints, services, or connections, delegate to +service_agent so it can use the parallel local-MCP plus peer-API inventory +workflow. Do not answer those requests with local-only MCP inventory. +In this project, "domain" normally means an MD-NAF controller domain such as +A, B, or C. It does not mean a TFS context unless the user explicitly asks for +TFS contexts. +Keep replies concise and controller-grounded. +""" + +# ADK Web's /build_graph endpoint serializes agent models with Pydantic. +# LiteLlm keeps a runtime client object that is not JSON-serializable. +LiteLlm.model_fields["llm_client"].exclude = True +LiteLlm.model_rebuild(force=True) + +from agentic.service.agents.inventory import inventory_agent +from agentic.service.agents.service import service_agent + + +inventory_service_root_agent = Agent( + model=build_litellm(), + name="md_naf_inventory_service_root_agent", + description=( + "MD-NAF TFS-Agentic ADK coordinator with bounded inventory and " + "service sub-agents." + ), + instruction=ROOT_AGENT_INSTRUCTION, + sub_agents=[inventory_agent, service_agent], +) diff --git a/src/agentic/service/agents/service.py b/src/agentic/service/agents/service.py new file mode 100644 index 000000000..c239d25cd --- /dev/null +++ b/src/agentic/service/agents/service.py @@ -0,0 +1,143 @@ +# 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. + +"""Service workflow specialist agent.""" + +from __future__ import annotations + +from google.adk.agents.llm_agent import Agent + +from agentic.service.llm import build_litellm +from agentic.service.tools.service import SERVICE_WORKFLOW_TOOLS + + +SERVICE_AGENT_INSTRUCTION = """ +You are the MD-NAF TFS service specialist. + +Use deterministic service workflow tools for service creation and removal. +Do not compose raw TFS service payloads in the LLM. +Do not call raw MCP mutating tools directly. + +For domain questions: +- Use list_mdnaf_domains. +- Explain that MD-NAF domains are controller/agent domains such as A, B, and C. +- Mention that TFS context `admin` is a controller-internal context and is not + the same thing as an MD-NAF domain. +- Do not answer domain questions from TFS contexts unless explicitly asked for + TFS contexts. + +For inventory questions that mention remote domains, peer domains, all domains, +other domains, every domain, or cross-domain visibility: +- Use list_domain_inventory, not raw MCP tools. +- Set scope=`remote` for remote/peer/other-domain wording. +- Set scope=`all` for all/every/cross-domain wording. +- Set scope=`local` only when the user explicitly asks for the local domain. +- Set resource_kind to the requested resource family or device role: + `devices`, `datacenters`, `transponders`, `roadms`, `routers`, + `remote_abstractions`, `endpoints`, `links`, `optical_links`, `services`, + `connections`, or `all`. +- Use detail_level=`summary` by default. Use detail_level=`full` only when the + user explicitly asks for raw objects, full details, debug output, UUID + structures, optical details, config rules, or complete controller payloads. +- Keep include_config_rules=false by default. Set include_config_rules=true only + when the user explicitly asks for config rules or raw/full configuration. +- Keep include_ids=false by default unless the user asks for UUIDs or raw IDs. +- If the requested resource is scoped to a device role, set device_filter too. + Examples: for "endpoints of remote transponders", use + resource_kind=`endpoints`, device_filter=`transponders`; for "ports on + ROADMs", use resource_kind=`endpoints`, device_filter=`roadms`. +- Report results grouped by MD-NAF domain ID. If a remote domain has no + matching resources, say that explicitly for that domain. +- The workflow fans out local MCP and peer API requests in parallel; do not + manually call local-only MCP first and stop there. + +For device-location questions: +- Use locate_device. +- Report the MD-NAF domain ID where the matching device is found. +- If both a packet DC device and transponder device match, list both matches. + +For direct device connection, neighbor, or link questions: +- Use list_device_connections. +- Report the domain ID, direct neighbor devices, and link names. +- Do not call raw local MCP link or device-detail tools for devices that may + belong to peer domains. + +For creation requests: +- Use request_optical_connectivity. It handles endpoint ownership discovery, + local versus cross-domain routing, spectrum negotiation, and multi-hop + delegation. +- Ask for or infer source_device and destination_device only from explicit user + names, such as DC1-TP1 and DC2-TP1. +- Optical sizing is mandatory. Ask for missing sizing unless the user provides + either an explicit channel width in GHz, or both capacity in Gbps and + modulation format. +- If the user says, for example, `100 Gbps QPSK`, pass capacity_gbps=100 and + modulation_format=`qpsk`. If the user says `50 GHz channel`, pass + channel_width_ghz=50. +- Never pass GHz values as preferred_band. Use preferred_band only for explicit + slot ranges such as `30-37`. +- The workflow tool performs inventory validation, TFS two-step create/update, + ACTIVE polling, and rollback if ACTIVE is not observed. +- Report success only when the tool returns ok=true and observed_status=ACTIVE. + +For removal requests: +- Use delete_service with the exact service UUID/name supplied by the user. +- Report success only when the tool returns ok=true. + +For cross-domain optical requests: +- Use list_domain_inventory for requests asking what devices, optical links, + services, connections, endpoints, ROADMs, routers, datacenters, + transponders, or peer-domain inventory are visible across domains. +- Use request_optical_connectivity when the source and destination endpoints + may belong to different domains. +- If the user names datacenters such as DC1 and DC3, pass those exact names to + request_optical_connectivity. The deterministic workflow resolves + datacenters to visible transponders such as DC1-TP1 and DC3-TP1 before + provisioning. +- Do not ask for confirmation only because a service name is missing. The + workflow generates a service name when service_name is omitted. +- Optical sizing is mandatory here too. Ask for missing sizing unless the user + provides either an explicit channel width in GHz, or both capacity in Gbps + and modulation format. +- If the user requests an exact spectrum interval such as `20-35`, pass it as + preferred_band. +- If the user says `50 GHz`, pass channel_width_ghz=50, not preferred_band. +- If the user requests slots at or above a slot index, for example `slot 20+`, + `beyond slot 20`, or `starting from slot 20`, pass minimum_slot=20. +- If the source endpoint belongs to a peer domain, the workflow delegates the + request to that source-owning peer so reversed direction requests can work. +- The workflow discovers the peer owner, intersects spectrum candidates, + reserves local and peer spectrum, creates peer and local optical segments, + verifies both services ACTIVE, and commits or rolls back reservations. +- Report the request_id, local_service_uuid, peer_service_uuid, peer_domain, + and preferred_band on success. + +For cross-domain removal: +- Use delete_cross_domain_optical_service when the user gives a cross-domain + request_id such as `xdom-...`. +- Pass request_id immediately. Do not ask for peer_domain; it is optional and + the deterministic workflow searches peer domains when peer_domain is empty. +- Ask a follow-up question only if the request_id is missing or ambiguous. +""" + +service_agent = Agent( + model=build_litellm(), + name="service_agent", + description=( + "Creates and removes TFS optical services through deterministic " + "MCP-backed workflows." + ), + instruction=SERVICE_AGENT_INSTRUCTION, + tools=SERVICE_WORKFLOW_TOOLS, +) diff --git a/src/agentic/service/agents/single.py b/src/agentic/service/agents/single.py new file mode 100644 index 000000000..d2ec44529 --- /dev/null +++ b/src/agentic/service/agents/single.py @@ -0,0 +1,106 @@ +# 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. + +"""Experimental single-agent graph for latency comparison.""" + +from __future__ import annotations + +from google.adk.agents.llm_agent import Agent + +from agentic.service.llm import build_litellm +from agentic.service.tools.granular import ( + CROSS_DOMAIN_CONNECTION_RETRIEVAL_TOOLS, + CROSS_DOMAIN_DEVICE_RETRIEVAL_TOOLS, + CROSS_DOMAIN_OPTICAL_LINK_RETRIEVAL_TOOLS, + CROSS_DOMAIN_PACKET_LINK_RETRIEVAL_TOOLS, + CROSS_DOMAIN_SERVICE_RETRIEVAL_TOOLS, + LOCAL_CONNECTION_RETRIEVAL_TOOLS, + LOCAL_DEVICE_RETRIEVAL_TOOLS, + LOCAL_OPTICAL_LINK_RETRIEVAL_TOOLS, + LOCAL_PACKET_LINK_RETRIEVAL_TOOLS, + LOCAL_SERVICE_RETRIEVAL_TOOLS, + SERVICE_MUTATION_TOOLS, +) + + +SINGLE_AGENT_INSTRUCTION = """ +You are the experimental single-agent MD-NAF controller assistant. + +Use exactly one tool call whenever a tool can satisfy the request. +Do not delegate to sub-agents. +Do not invent controller UUIDs, service names, endpoints, spectrum slots, or +service states. + +For optical service creation: +- Use create_cross_domain_optical_service_tool unless the user explicitly says + both endpoints are local to this domain. +- Copy source_device, destination_device, channel_width_ghz, minimum_slot, + preferred_band, and service_name from the user when present. +- If the user gives datacenter names such as DC1 or DC7, pass them as given; + the deterministic workflow resolves the transponders. +- If minimum_slot is absent, use 0. +- If service_name is absent, leave it empty. + +For optical service removal: +- Use remove_cross_domain_optical_service when the user provides a request ID, + service name prefix, or cross-domain service intent. +- Use remove_local_optical_service only for an explicit local service UUID. + +For read-only requests: +- Use compact tools by default. +- Use detailed tools only when the user explicitly asks for raw details, + identifiers, configuration rules, or full payloads. +- Use cross-domain tools when the user mentions remote, peer, all domains, + all locations, or does not clearly limit the request to the local domain. +- Use local tools only when the user explicitly asks for local information. + +After the tool returns, answer briefly with the key outcome, selected path or +domains when present, service/request IDs, selected spectrum when present, and +whether source-of-truth verification succeeded. +""" + + +def _dedupe_tools(*tool_groups): + tools = [] + seen = set() + for group in tool_groups: + for tool in group: + if tool.name in seen: + continue + tools.append(tool) + seen.add(tool.name) + return tools + + +single_root_agent = Agent( + model=build_litellm(), + name="md_naf_single_root_agent", + description=( + "Experiment-only single MD-NAF LLM agent for latency comparison." + ), + instruction=SINGLE_AGENT_INSTRUCTION, + tools=_dedupe_tools( + LOCAL_DEVICE_RETRIEVAL_TOOLS, + CROSS_DOMAIN_DEVICE_RETRIEVAL_TOOLS, + LOCAL_PACKET_LINK_RETRIEVAL_TOOLS, + CROSS_DOMAIN_PACKET_LINK_RETRIEVAL_TOOLS, + LOCAL_OPTICAL_LINK_RETRIEVAL_TOOLS, + CROSS_DOMAIN_OPTICAL_LINK_RETRIEVAL_TOOLS, + LOCAL_SERVICE_RETRIEVAL_TOOLS, + CROSS_DOMAIN_SERVICE_RETRIEVAL_TOOLS, + LOCAL_CONNECTION_RETRIEVAL_TOOLS, + CROSS_DOMAIN_CONNECTION_RETRIEVAL_TOOLS, + SERVICE_MUTATION_TOOLS, + ), +) diff --git a/src/agentic/service/domain_api.py b/src/agentic/service/domain_api.py new file mode 100644 index 000000000..12ceea0ed --- /dev/null +++ b/src/agentic/service/domain_api.py @@ -0,0 +1,747 @@ +# 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. + +"""Per-domain HTTP API for TFS-Agentic ADK runtime peer negotiation.""" + +from __future__ import annotations + +import asyncio +import logging +import time +import uuid +from typing import Any, Dict, List + +from fastapi import FastAPI +from google.adk.runners import Runner +from google.adk.sessions import InMemorySessionService +from google.genai import types +from pydantic import BaseModel, Field + +from agentic.service import spectrum +from agentic.service.a2a_transport import install_a2a_routes +from agentic.service.agent import root_agent +from agentic.service.peer_client import parse_peers +from agentic.service.session_store import ( + list_events, + record_event, + session_health, +) +from agentic.service.settings import ( + AGENT_STARTUP_WARMUP_ENABLED, + AGENT_STARTUP_WARMUP_PROMPTS, + AGENT_STARTUP_WARMUP_TIMEOUT_SECONDS, + AGENT_RUN_TIMEOUT_SECONDS, + DEFAULT_SLOT_WIDTH, + DOMAIN_ID, + DOMAIN_NAME, + MCP_STARTUP_WARMUP_TIMEOUT_SECONDS, +) +from agentic.service.tools.mcp_client import ( + call_mcp_tool, + close_mcp_session, + warm_mcp_session, +) +from agentic.service.tools.service_workflow import ( + DEFAULT_CONTEXT_UUID, + _local_device_connections, + consume_tfs_spectrum_reservation, + compute_tfs_optical_connectivity_candidates, + create_cross_domain_optical_service, + create_optical_connectivity_service, + create_tfs_spectrum_reservation, + delete_cross_domain_optical_service, + delete_local_services_for_request, + delete_service, + list_domain_inventory, + list_mdnaf_domains, + locate_device, + release_tfs_spectrum_reservation, +) + + +class SegmentRequest(BaseModel): + source_device: str + destination_device: str + service_name: str = "" + source_endpoint: str = "" + destination_endpoint: str = "" + preferred_band: str = "" + capacity_gbps: float | None = None + modulation_format: str = "" + channel_width_ghz: float | None = None + required_slots_override: int | None = None + slot_width_ghz_override: float | None = None + user_id: str = "peer" + session_id: str = "peer" + + +class DeleteRequest(BaseModel): + service_uuid: str + correlation_id: str = "" + candidate_service_ids: List[str] = Field(default_factory=list) + user_id: str = "peer" + session_id: str = "peer" + + +class DeleteByRequestRequest(BaseModel): + request_id: str + candidate_service_ids: List[str] = Field(default_factory=list) + user_id: str = "peer" + session_id: str = "peer" + + +class SpectrumCandidateRequest(BaseModel): + source_device: str + destination_device: str + preferred_band: str = "c_slots" + capacity_gbps: float | None = None + modulation_format: str = "" + channel_width_ghz: float | None = None + required_slots: int = DEFAULT_SLOT_WIDTH + user_id: str = "peer" + session_id: str = "spectrum" + + +class CrossDomainDeleteRequest(BaseModel): + request_id: str + peer_domain: str = "" + user_id: str = "peer" + session_id: str = "peer" + + +class ReserveRequest(BaseModel): + service_id: str + selected_range: Dict[str, int] + candidate_bundle: Dict[str, Any] = Field(default_factory=dict) + metadata: Dict[str, Any] = Field(default_factory=dict) + user_id: str = "peer" + session_id: str = "peer" + + +class ReservationUpdateRequest(BaseModel): + reservation_id: str + status: str + service_id: str = "" + context_uuid: str = DEFAULT_CONTEXT_UUID + user_id: str = "peer" + session_id: str = "peer" + + +class CrossDomainRequest(BaseModel): + source_device: str + destination_device: str + service_name: str = "" + minimum_slot: int | None = None + preferred_band: str = "" + capacity_gbps: float | None = None + modulation_format: str = "" + channel_width_ghz: float | None = None + user_id: str = "peer" + session_id: str = "peer" + + +class AgentRunRequest(BaseModel): + prompt: str + user_id: str = "api" + session_id: str = "" + + +app = FastAPI(title="MD-NAF ADK Domain API", version="0.1.0") +install_a2a_routes(app) +LOGGER = logging.getLogger("uvicorn.error") +_agent_session_service = InMemorySessionService() +_agent_runner = Runner( + agent=root_agent, + app_name="agentic.service", + session_service=_agent_session_service, +) +_agent_sessions: set[tuple[str, str]] = set() +_startup_warmup_results: list[dict[str, Any]] = [] + + +@app.on_event("startup") +async def startup() -> None: + """Warm long-lived controller-facing sessions before the first request.""" + + try: + started = time.perf_counter() + warmup_result = await asyncio.wait_for( + warm_mcp_session(), + timeout=MCP_STARTUP_WARMUP_TIMEOUT_SECONDS, + ) + LOGGER.info( + "MCP startup initialization completed domain=%s elapsed_ms=%.3f", + DOMAIN_ID, + (time.perf_counter() - started) * 1000.0, + ) + if isinstance(warmup_result, dict) and warmup_result.get("error"): + LOGGER.warning( + "MCP startup warmup tool returned error domain=%s result=%s", + DOMAIN_ID, + warmup_result, + ) + except Exception as exc: # pylint: disable=broad-exception-caught + LOGGER.warning( + "MCP startup initialization failed; first tool call retries " + "lazily: %s", + exc, + ) + await _warm_agent_runner_on_startup() + + +@app.on_event("shutdown") +async def shutdown() -> None: + """Close long-lived controller-facing sessions.""" + + await close_mcp_session() + LOGGER.info("MCP shutdown completed domain=%s", DOMAIN_ID) + + +async def _ensure_agent_session(user_id: str, session_id: str) -> None: + key = (user_id, session_id) + if key in _agent_sessions: + return + await _agent_session_service.create_session( + app_name="agentic.service", + user_id=user_id, + session_id=session_id, + ) + _agent_sessions.add(key) + + +async def _run_agent_internal( + *, + user_id: str, + session_id: str, + prompt: str, + timeout_seconds: float, + record: bool, + log_prefix: str = "agent_run", +) -> Dict[str, Any]: + started = time.perf_counter() + events = [] + final_text_parts = [] + LOGGER.info( + "%s start domain=%s user=%s session=%s timeout_s=%.1f prompt=%r", + log_prefix, + DOMAIN_ID, + user_id, + session_id, + timeout_seconds, + prompt[:200], + ) + try: + await _ensure_agent_session(user_id, session_id) + content = types.Content(role="user", parts=[types.Part(text=prompt)]) + + async def consume_events() -> None: + async for event in _agent_runner.run_async( + user_id=user_id, + session_id=session_id, + new_message=content, + ): + parts = [] + if event.content and event.content.parts: + parts = [ + _agent_part_summary(part) + for part in event.content.parts + ] + for part in parts: + if part.get("kind") == "text": + final_text_parts.append(part.get("text", "")) + event_payload = { + "elapsed_ms": round( + (time.perf_counter() - started) * 1000.0, 3 + ), + "author": getattr(event, "author", ""), + "model": getattr(event, "model_version", ""), + "parts": parts, + } + events.append(event_payload) + LOGGER.info( + "%s event domain=%s user=%s session=%s " + "elapsed_ms=%.3f parts=%s", + log_prefix, + DOMAIN_ID, + user_id, + session_id, + event_payload["elapsed_ms"], + [part.get("kind") for part in parts], + ) + + await asyncio.wait_for(consume_events(), timeout=timeout_seconds) + except (TimeoutError, asyncio.TimeoutError): + result = { + "ok": False, + "error": "agent_run_timeout", + "domain_id": DOMAIN_ID, + "user_id": user_id, + "session_id": session_id, + "elapsed_ms": round((time.perf_counter() - started) * 1000.0, 3), + "timeout_seconds": timeout_seconds, + "event_count": len(events), + "events": events, + "final_answer": "", + } + LOGGER.warning( + "%s timeout domain=%s user=%s session=%s elapsed_ms=%.3f events=%d", + log_prefix, + DOMAIN_ID, + user_id, + session_id, + result["elapsed_ms"], + len(events), + ) + if record: + record_event( + "agent_run", + result, + user_id=user_id, + session_id=session_id, + ) + return result + except Exception as exc: # pylint: disable=broad-exception-caught + result = { + "ok": False, + "error": "agent_run_failed", + "message": str(exc), + "domain_id": DOMAIN_ID, + "user_id": user_id, + "session_id": session_id, + "elapsed_ms": round((time.perf_counter() - started) * 1000.0, 3), + "event_count": len(events), + "events": events, + "final_answer": "", + } + LOGGER.exception( + "%s failed domain=%s user=%s session=%s", + log_prefix, + DOMAIN_ID, + user_id, + session_id, + ) + if record: + record_event( + "agent_run", + result, + user_id=user_id, + session_id=session_id, + ) + return result + result = { + "ok": True, + "domain_id": DOMAIN_ID, + "user_id": user_id, + "session_id": session_id, + "elapsed_ms": round((time.perf_counter() - started) * 1000.0, 3), + "event_count": len(events), + "events": events, + "final_answer": "".join(final_text_parts).strip(), + } + LOGGER.info( + "%s done domain=%s user=%s session=%s elapsed_ms=%.3f events=%d", + log_prefix, + DOMAIN_ID, + user_id, + session_id, + result["elapsed_ms"], + len(events), + ) + if record: + record_event( + "agent_run", + result, + user_id=user_id, + session_id=session_id, + ) + return result + + +async def _warm_agent_runner_on_startup() -> None: + """Run safe prompts through ADK to pay cold-start cost upfront.""" + + if not AGENT_STARTUP_WARMUP_ENABLED or not AGENT_STARTUP_WARMUP_PROMPTS: + LOGGER.info("ADK startup warmup disabled domain=%s", DOMAIN_ID) + return + _startup_warmup_results.clear() + for index, prompt in enumerate(AGENT_STARTUP_WARMUP_PROMPTS, start=1): + session_id = f"startup-warmup-{DOMAIN_ID.lower()}-{index}" + result = await _run_agent_internal( + user_id="startup-warmup", + session_id=session_id, + prompt=prompt, + timeout_seconds=AGENT_STARTUP_WARMUP_TIMEOUT_SECONDS, + record=False, + log_prefix="agent_startup_warmup", + ) + summary = { + "prompt": prompt, + "ok": result.get("ok", False), + "elapsed_ms": result.get("elapsed_ms", 0), + "event_count": result.get("event_count", 0), + "error": result.get("error", ""), + } + _startup_warmup_results.append(summary) + LOGGER.info( + "ADK startup warmup completed domain=%s result=%s", + DOMAIN_ID, + summary, + ) + + +def _agent_part_summary(part: Any) -> Dict[str, Any]: + function_call = getattr(part, "function_call", None) + function_response = getattr(part, "function_response", None) + text = getattr(part, "text", None) + if function_call: + return { + "kind": "function_call", + "name": function_call.name, + "args": dict(function_call.args or {}), + } + if function_response: + return { + "kind": "function_response", + "name": function_response.name, + "response": function_response.response, + } + if text: + return {"kind": "text", "text": text} + return {"kind": type(part).__name__} + + +@app.get("/health") +async def health() -> Dict[str, Any]: + return { + "ok": True, + "domain_id": DOMAIN_ID, + "domain_name": DOMAIN_NAME, + "peers": sorted(parse_peers()), + "sessions": session_health(), + "startup_warmup": _startup_warmup_results, + } + + +@app.post("/agent/run") +async def run_agent(request: AgentRunRequest) -> Dict[str, Any]: + user_id = request.user_id.strip() or "api" + session_id = request.session_id.strip() or f"api-{uuid.uuid4().hex[:10]}" + return await _run_agent_internal( + user_id=user_id, + session_id=session_id, + prompt=request.prompt, + timeout_seconds=AGENT_RUN_TIMEOUT_SECONDS, + record=True, + log_prefix="agent_run", + ) + + +@app.get("/inventory/devices") +async def inventory_devices() -> Dict[str, Any]: + payload = await call_mcp_tool("tfs_list_devices", {}) + record_event( + "inventory_devices", + payload, + user_id="api", + session_id="inventory", + ) + return { + "ok": True, + "domain_id": DOMAIN_ID, + "devices": ( + payload.get("devices", []) if isinstance(payload, dict) else [] + ), + } + + +@app.get("/domains") +async def domains(include_devices: bool = True) -> Dict[str, Any]: + return await list_mdnaf_domains( + include_devices=include_devices, + user_id="api", + session_id="domains", + ) + + +@app.get("/devices/locate") +async def locate(device_name: str) -> Dict[str, Any]: + return await locate_device(device_name, user_id="api", session_id="locate") + + +@app.get("/devices/connections") +async def device_connections( + device_name: str, + include_packet_links: bool = True, + include_optical_links: bool = True, +) -> Dict[str, Any]: + return await _local_device_connections( + device_name, + include_packet_links, + include_optical_links, + ) + + +@app.get("/inventory/optical-links") +async def inventory_optical_links() -> Dict[str, Any]: + payload = await call_mcp_tool("tfs_list_optical_links", {}) + record_event( + "inventory_optical_links", + payload, + user_id="api", + session_id="inventory", + ) + return { + "ok": True, + "domain_id": DOMAIN_ID, + "optical_links": ( + payload.get("optical_links", []) + if isinstance(payload, dict) + else [] + ), + } + + +@app.get("/inventory/resources") +async def inventory_resources( + scope: str = "local", + resource_kind: str = "devices", + device_filter: str = "", + detail_level: str = "summary", + include_config_rules: bool = False, + include_ids: bool = False, + include_optical_links: bool = False, +) -> Dict[str, Any]: + return await list_domain_inventory( + scope=scope, + resource_kind=resource_kind, + device_filter=device_filter, + detail_level=detail_level, + include_config_rules=include_config_rules, + include_ids=include_ids, + include_optical_links=include_optical_links, + user_id="api", + session_id="inventory", + ) + + +@app.get("/spectrum/candidates") +async def spectrum_candidates( + required_slots: int = DEFAULT_SLOT_WIDTH, + source_device: str = "", + destination_device: str = "", + channel_width_ghz: float | None = None, + capacity_gbps: float | None = None, + modulation_format: str = "", + preferred_band: str = "c_slots", +) -> Dict[str, Any]: + if source_device and destination_device: + result = await compute_tfs_optical_connectivity_candidates( + source_device, + destination_device, + channel_width_ghz=channel_width_ghz, + capacity_gbps=capacity_gbps, + modulation_format=modulation_format, + preferred_band=preferred_band, + ) + else: + result = spectrum.candidates(required_slots) + record_event( + "spectrum_candidates", + result, + user_id="api", + session_id="spectrum", + ) + return result + + +@app.post("/spectrum/candidates") +async def spectrum_candidates_post( + request: SpectrumCandidateRequest, +) -> Dict[str, Any]: + result = await compute_tfs_optical_connectivity_candidates( + request.source_device, + request.destination_device, + channel_width_ghz=request.channel_width_ghz, + capacity_gbps=request.capacity_gbps, + modulation_format=request.modulation_format, + preferred_band=request.preferred_band, + ) + record_event( + "spectrum_candidates", + result, + user_id=request.user_id, + session_id=request.session_id, + ) + return result + + +@app.post("/spectrum/reserve") +async def spectrum_reserve(request: ReserveRequest) -> Dict[str, Any]: + if request.candidate_bundle: + result = await create_tfs_spectrum_reservation( + request.service_id, + request.selected_range, + request.candidate_bundle, + request.metadata, + ) + else: + result = { + "ok": False, + "error": ( + "candidate_bundle_required_for_controller_authoritative_" + "reservation" + ), + "service_id": request.service_id, + } + record_event( + "spectrum_reserve", + result, + user_id=request.user_id, + session_id=request.session_id, + ) + return result + + +@app.post("/spectrum/update") +async def spectrum_update(request: ReservationUpdateRequest) -> Dict[str, Any]: + status = request.status.strip().lower() + if status in {"committed", "consumed", "consume"}: + result = await consume_tfs_spectrum_reservation( + request.reservation_id, + request.service_id or request.reservation_id, + request.context_uuid or DEFAULT_CONTEXT_UUID, + ) + elif status in {"rolled_back", "released", "release"}: + result = await release_tfs_spectrum_reservation( + request.reservation_id, + request.context_uuid or DEFAULT_CONTEXT_UUID, + ) + else: + result = { + "ok": False, + "error": "unsupported_controller_reservation_status", + "reservation_id": request.reservation_id, + "status": request.status, + } + record_event( + "spectrum_update", + result, + user_id=request.user_id, + session_id=request.session_id, + ) + return result + + +@app.post("/services/optical") +async def create_segment(request: SegmentRequest) -> Dict[str, Any]: + result = await create_optical_connectivity_service( + request.source_device, + request.destination_device, + service_name=request.service_name, + source_endpoint=request.source_endpoint, + destination_endpoint=request.destination_endpoint, + preferred_band=request.preferred_band, + capacity_gbps=request.capacity_gbps, + modulation_format=request.modulation_format, + channel_width_ghz=request.channel_width_ghz, + required_slots_override=request.required_slots_override, + slot_width_ghz_override=request.slot_width_ghz_override, + ) + record_event( + "create_segment", + result, + user_id=request.user_id, + session_id=request.session_id, + ) + return result + + +@app.post("/services/delete") +async def delete_segment(request: DeleteRequest) -> Dict[str, Any]: + result = await delete_service( + request.service_uuid, + correlation_id=request.correlation_id, + candidate_service_ids=request.candidate_service_ids, + ) + spectrum_release = spectrum.release_by_service(request.service_uuid) + result["spectrum_release"] = spectrum_release + record_event( + "delete_segment", + result, + user_id=request.user_id, + session_id=request.session_id, + ) + return result + + +@app.post("/services/delete-by-request") +async def delete_segments_by_request( + request: DeleteByRequestRequest, +) -> Dict[str, Any]: + result = await delete_local_services_for_request( + request.request_id, + candidate_service_ids=request.candidate_service_ids, + ) + record_event( + "delete_segments_by_request", + result, + user_id=request.user_id, + session_id=request.session_id, + ) + return result + + +@app.post("/workflows/cross-domain-optical") +async def create_cross_domain_workflow( + request: CrossDomainRequest, +) -> Dict[str, Any]: + return await create_cross_domain_optical_service( + request.source_device, + request.destination_device, + service_name=request.service_name, + minimum_slot=request.minimum_slot, + preferred_band=request.preferred_band, + capacity_gbps=request.capacity_gbps, + modulation_format=request.modulation_format, + channel_width_ghz=request.channel_width_ghz, + user_id=request.user_id, + session_id=request.session_id, + ) + + +@app.post("/workflows/cross-domain-optical/delete") +async def delete_cross_domain_workflow( + request: CrossDomainDeleteRequest, +) -> Dict[str, Any]: + return await delete_cross_domain_optical_service( + request.request_id, + peer_domain=request.peer_domain, + user_id=request.user_id, + session_id=request.session_id, + ) + + +@app.get("/sessions/{user_id}/{session_id}") +async def get_session( + user_id: str, + session_id: str, + limit: int = 50, +) -> Dict[str, Any]: + return { + "ok": True, + "domain_id": DOMAIN_ID, + "user_id": user_id, + "session_id": session_id, + "events": list_events(user_id, session_id, limit), + } diff --git a/src/agentic/service/graphs/__init__.py b/src/agentic/service/graphs/__init__.py new file mode 100644 index 000000000..e072451c3 --- /dev/null +++ b/src/agentic/service/graphs/__init__.py @@ -0,0 +1,30 @@ +# 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 .cross_domain import CROSS_DOMAIN_OPTICAL_NEGOTIATION_GRAPH +from .inventory import INVENTORY_READ_GRAPH +from .optical import SERVICE_DELETION_GRAPH, SINGLE_DOMAIN_OPTICAL_GRAPH +from .registry import STATIC_GRAPHS, graph_summary +from .types import GraphStep, StaticGraph + +__all__ = [ + "CROSS_DOMAIN_OPTICAL_NEGOTIATION_GRAPH", + "GraphStep", + "INVENTORY_READ_GRAPH", + "SERVICE_DELETION_GRAPH", + "SINGLE_DOMAIN_OPTICAL_GRAPH", + "STATIC_GRAPHS", + "StaticGraph", + "graph_summary", +] diff --git a/src/agentic/service/graphs/cross_domain.py b/src/agentic/service/graphs/cross_domain.py new file mode 100644 index 000000000..89dd5abeb --- /dev/null +++ b/src/agentic/service/graphs/cross_domain.py @@ -0,0 +1,124 @@ +# 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. + +"""Static cross-domain optical negotiation graph.""" + +from __future__ import annotations + +from .types import StaticGraph + + +CROSS_DOMAIN_OPTICAL_NEGOTIATION_GRAPH: StaticGraph = { + "id": "cross_domain_optical_negotiation", + "description": ( + "Static A2A spectrum negotiation and peer-first provisioning graph." + ), + "steps": [ + { + "id": "inspect_cross_domain_inventory", + "actor": "local_inventory_agent", + "action": ( + "Read local MCP inventory and peer Domain API inventory " + "when requested." + ), + "inputs": ["local_inventory", "peer_inventory"], + "outputs": ["domain_device_index", "domain_optical_links"], + }, + { + "id": "discover_remote_owner", + "actor": "endpoint_resolver_agent", + "action": ( + "Ask peer Domain APIs which domain owns the destination " + "endpoint device." + ), + "inputs": ["source_name", "destination_name", "local_inventory"], + "outputs": ["peer_domain", "destination_device"], + }, + { + "id": "derive_spectrum_sizing", + "actor": "intent_parser_agent", + "action": ( + "Derive required 6.25 GHz slots from explicit channel width " + "or capacity plus modulation." + ), + "inputs": [ + "channel_width_ghz", + "capacity_gbps", + "modulation_format", + ], + "outputs": ["required_slots", "rounded_channel_width_ghz"], + }, + { + "id": "compute_candidates", + "actor": "spectrum_calculator_agent", + "action": ( + "Compute local candidates and request peer candidate ranges " + "from the peer Domain API." + ), + "inputs": ["peer_domain", "required_slots"], + "outputs": ["local_candidate_slots", "peer_candidate_slots"], + }, + { + "id": "intersect_spectrum", + "actor": "negotiation_policy_agent", + "action": ( + "Intersect local and peer candidate slots and select a " + "common contiguous block." + ), + "inputs": ["local_candidate_slots", "peer_candidate_slots"], + "outputs": ["selected_slots", "preferred_band"], + }, + { + "id": "reserve_spectrum", + "actor": "reservation_agent", + "action": ( + "Soft-reserve the selected spectrum locally and on the peer " + "Domain API." + ), + "inputs": [ + "selected_slots", + "local_service_uuid", + "peer_service_uuid", + ], + "outputs": ["local_reservation", "peer_reservation"], + }, + { + "id": "provision_peer_segment", + "actor": "peer_domain_api", + "action": ( + "Request peer provisioning first with selected spectrum " + "slots." + ), + "inputs": ["peer_domain", "destination_name", "preferred_band"], + "outputs": ["peer_service_uuid", "peer_state"], + }, + { + "id": "provision_local_segment", + "actor": "provisioning_agent", + "action": "Provision local segment through MCP and verify ACTIVE.", + "inputs": ["approval", "source_endpoint", "selected_slots"], + "outputs": ["local_service_uuid", "local_state"], + }, + { + "id": "commit_or_rollback", + "actor": "verification_agent", + "action": ( + "Commit reservations only if both services are ACTIVE, " + "otherwise rollback local and peer resources." + ), + "inputs": ["peer_state", "local_state", "selected_slots"], + "outputs": ["final_state", "rollback_evidence"], + }, + ], +} diff --git a/src/agentic/service/graphs/inventory.py b/src/agentic/service/graphs/inventory.py new file mode 100644 index 000000000..25ad0556f --- /dev/null +++ b/src/agentic/service/graphs/inventory.py @@ -0,0 +1,56 @@ +# 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. + +"""Static read-only inventory graph.""" + +from __future__ import annotations + +from .types import StaticGraph + + +INVENTORY_READ_GRAPH: StaticGraph = { + "id": "inventory_read", + "description": "Read-only TFS controller inventory through MCP.", + "steps": [ + { + "id": "select_scope", + "actor": "inventory_agent", + "action": ( + "Default missing context_uuid and topology_uuid to admin." + ), + "inputs": ["user_request", "session_state"], + "outputs": ["context_uuid", "topology_uuid"], + }, + { + "id": "call_readonly_mcp_tool", + "actor": "inventory_agent", + "action": ( + "Call the discovered MCP read-only tool matching the " + "requested resource family." + ), + "inputs": ["context_uuid", "topology_uuid", "resource_family"], + "outputs": ["controller_payload"], + }, + { + "id": "summarize_inventory", + "actor": "inventory_agent", + "action": ( + "Summarize only controller-grounded facts; do not infer " + "missing inventory." + ), + "inputs": ["controller_payload"], + "outputs": ["answer", "session_state"], + }, + ], +} diff --git a/src/agentic/service/graphs/optical.py b/src/agentic/service/graphs/optical.py new file mode 100644 index 000000000..55000b989 --- /dev/null +++ b/src/agentic/service/graphs/optical.py @@ -0,0 +1,158 @@ +# 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. + +"""Static single-domain optical graph.""" + +from __future__ import annotations + +from .types import StaticGraph + + +SINGLE_DOMAIN_OPTICAL_GRAPH: StaticGraph = { + "id": "single_domain_optical", + "description": ( + "One unidirectional local TFS optical connectivity service with " + "ACTIVE-state verification." + ), + "steps": [ + { + "id": "pre_validate_inventory", + "actor": "local_inventory_agent", + "action": ( + "Read MCP device inventory and verify both endpoint devices " + "exist." + ), + "inputs": [ + "source_name", + "destination_name", + "context_uuid", + "topology_uuid", + ], + "outputs": [ + "available_devices", + "validated_source", + "validated_destination", + ], + }, + { + "id": "resolve_local_endpoints", + "actor": "endpoint_resolver_agent", + "action": ( + "Resolve default optical endpoints for transponders or " + "remote-network abstractions." + ), + "inputs": [ + "validated_source", + "validated_destination", + "source_endpoint", + "destination_endpoint", + ], + "outputs": ["source_endpoint_id", "destination_endpoint_id"], + }, + { + "id": "create_service_shell", + "actor": "provisioning_agent", + "action": ( + "Reserve the service UUID with the TFS CreateService " + "lifecycle step." + ), + "inputs": ["context_uuid", "service_uuid", "service_type"], + "outputs": ["reserved_service_uuid"], + }, + { + "id": "update_service_payload", + "actor": "provisioning_agent", + "action": ( + "Submit endpoints and optical constraints with the TFS " + "UpdateService lifecycle step." + ), + "inputs": [ + "reserved_service_uuid", + "source_endpoint_id", + "destination_endpoint_id", + "constraints", + ], + "outputs": ["update_result"], + }, + { + "id": "verify_service_active", + "actor": "verification_agent", + "action": ( + "Poll TFS until the service reaches ACTIVE or a bounded " + "timeout expires." + ), + "inputs": [ + "reserved_service_uuid", + "poll_attempts", + "poll_interval_seconds", + ], + "outputs": ["observed_status", "service_uuid"], + }, + { + "id": "rollback_if_not_active", + "actor": "recovery_agent", + "action": ( + "Delete the reserved service if update or ACTIVE " + "verification fails." + ), + "inputs": ["reserved_service_uuid", "observed_status"], + "outputs": ["rollback_result"], + }, + ], +} + + +SERVICE_DELETION_GRAPH: StaticGraph = { + "id": "service_deletion", + "description": ( + "Delete a TFS service with bounded verify-and-retry semantics." + ), + "steps": [ + { + "id": "delete_service", + "actor": "provisioning_agent", + "action": ( + "Call the MCP delete-service tool for the requested service " + "UUID." + ), + "inputs": ["context_uuid", "service_uuid"], + "outputs": ["delete_result"], + }, + { + "id": "verify_service_absent", + "actor": "verification_agent", + "action": ( + "Retrieve the service after deletion and confirm it is " + "absent or no longer valid." + ), + "inputs": ["context_uuid", "service_uuid", "delete_result"], + "outputs": ["observed_status"], + }, + { + "id": "retry_delete_if_needed", + "actor": "recovery_agent", + "action": ( + "Retry deletion a bounded number of times for optical " + "services that need staged removal." + ), + "inputs": [ + "context_uuid", + "service_uuid", + "observed_status", + "max_attempts", + ], + "outputs": ["final_delete_status"], + }, + ], +} diff --git a/src/agentic/service/graphs/registry.py b/src/agentic/service/graphs/registry.py new file mode 100644 index 000000000..cb3c0d88e --- /dev/null +++ b/src/agentic/service/graphs/registry.py @@ -0,0 +1,42 @@ +# 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. + +"""Static graph registry.""" + +from __future__ import annotations + +from typing import Dict + +from .cross_domain import CROSS_DOMAIN_OPTICAL_NEGOTIATION_GRAPH +from .inventory import INVENTORY_READ_GRAPH +from .optical import SERVICE_DELETION_GRAPH, SINGLE_DOMAIN_OPTICAL_GRAPH +from .types import StaticGraph + + +STATIC_GRAPHS: Dict[str, StaticGraph] = { + INVENTORY_READ_GRAPH["id"]: INVENTORY_READ_GRAPH, + SINGLE_DOMAIN_OPTICAL_GRAPH["id"]: SINGLE_DOMAIN_OPTICAL_GRAPH, + SERVICE_DELETION_GRAPH["id"]: SERVICE_DELETION_GRAPH, + CROSS_DOMAIN_OPTICAL_NEGOTIATION_GRAPH["id"]: ( + CROSS_DOMAIN_OPTICAL_NEGOTIATION_GRAPH + ), +} + + +def graph_summary() -> str: + lines = [] + for graph in STATIC_GRAPHS.values(): + steps = " -> ".join(step["id"] for step in graph["steps"]) + lines.append(f"{graph['id']}: {steps}") + return "\n".join(lines) diff --git a/src/agentic/service/graphs/types.py b/src/agentic/service/graphs/types.py new file mode 100644 index 000000000..8e44d5925 --- /dev/null +++ b/src/agentic/service/graphs/types.py @@ -0,0 +1,33 @@ +# 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. + +"""Shared static graph types.""" + +from __future__ import annotations + +from typing import List, TypedDict + + +class GraphStep(TypedDict): + id: str + actor: str + action: str + inputs: List[str] + outputs: List[str] + + +class StaticGraph(TypedDict): + id: str + description: str + steps: List[GraphStep] diff --git a/src/agentic/service/llm.py b/src/agentic/service/llm.py new file mode 100644 index 000000000..8e430473b --- /dev/null +++ b/src/agentic/service/llm.py @@ -0,0 +1,27 @@ +# 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. + +"""LLM construction helpers for ADK agents.""" + +from google.adk.models.lite_llm import LiteLlm + +from agentic.service.settings import MODEL_NAME, OLLAMA_API_BASE + + +def build_litellm() -> LiteLlm: + """Build a LiteLLM model with explicit Ollama endpoint wiring.""" + + if MODEL_NAME.startswith("ollama") and OLLAMA_API_BASE: + return LiteLlm(model=MODEL_NAME, api_base=OLLAMA_API_BASE) + return LiteLlm(model=MODEL_NAME) diff --git a/src/agentic/service/ollama_proxy.py b/src/agentic/service/ollama_proxy.py new file mode 100644 index 000000000..a068955d9 --- /dev/null +++ b/src/agentic/service/ollama_proxy.py @@ -0,0 +1,107 @@ +# 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. + +"""Small TCP proxy used when pods cannot route directly to an Ollama host.""" + +from __future__ import annotations + +import asyncio +import logging +import signal + +from agentic.Config import ( + get_ollama_proxy_listen_host, + get_ollama_proxy_listen_port, + get_ollama_proxy_target_host, + get_ollama_proxy_target_port, +) +from common.Settings import get_log_level + +LOGGER = logging.getLogger("ollama_proxy") + + +async def _pipe( + reader: asyncio.StreamReader, + writer: asyncio.StreamWriter, +) -> None: + try: + while data := await reader.read(65536): + writer.write(data) + await writer.drain() + except (ConnectionResetError, BrokenPipeError): + pass + finally: + writer.close() + + +async def _handle_client( + client_reader: asyncio.StreamReader, + client_writer: asyncio.StreamWriter, + target_host: str, + target_port: int, +) -> None: + peer = client_writer.get_extra_info("peername") + try: + target_reader, target_writer = await asyncio.open_connection( + target_host, + target_port, + ) + except OSError as exc: + LOGGER.warning("connect target=%s:%d peer=%s failed: %s", + target_host, target_port, peer, exc) + client_writer.close() + await client_writer.wait_closed() + return + + LOGGER.info("proxy peer=%s target=%s:%d", peer, target_host, target_port) + await asyncio.gather( + _pipe(client_reader, target_writer), + _pipe(target_reader, client_writer), + return_exceptions=True, + ) + + +async def _serve() -> None: + logging.basicConfig(level=get_log_level().upper(), + format="%(asctime)s %(levelname)s %(message)s") + listen_host = get_ollama_proxy_listen_host() + listen_port = get_ollama_proxy_listen_port() + target_host = get_ollama_proxy_target_host() + target_port = get_ollama_proxy_target_port() + if not target_host: + raise SystemExit("OLLAMA_PROXY_TARGET_HOST is required") + + server = await asyncio.start_server( + lambda reader, writer: _handle_client( + reader, writer, target_host, target_port), + listen_host, + listen_port, + ) + stop_event = asyncio.Event() + loop = asyncio.get_running_loop() + for sig in (signal.SIGINT, signal.SIGTERM): + loop.add_signal_handler(sig, stop_event.set) + LOGGER.info("listening on %s:%d -> %s:%d", listen_host, + listen_port, target_host, target_port) + async with server: + await stop_event.wait() + LOGGER.info("stopping") + + +def main() -> None: + asyncio.run(_serve()) + + +if __name__ == "__main__": + main() diff --git a/src/agentic/service/peer_client.py b/src/agentic/service/peer_client.py new file mode 100644 index 000000000..94c04f04c --- /dev/null +++ b/src/agentic/service/peer_client.py @@ -0,0 +1,211 @@ +# 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. + +"""A2A peer client for the ADK static-graph runtime.""" + +from __future__ import annotations + +import asyncio +import time +from typing import Any, Dict, List, Optional + +from agentic.Config import get_agentic_peer_inventory_cache_ttl_seconds +from agentic.service.a2a_transport import ( + action_from_legacy_get_path, + action_from_legacy_post_path, + call_a2a_action, +) +from agentic.service.settings import DOMAIN_ID, PEER_SPEC + +PEER_INVENTORY_CACHE_TTL_SECONDS = ( + get_agentic_peer_inventory_cache_ttl_seconds() +) + +_PEER_DEVICE_CACHE: Dict[str, tuple[float, Dict[str, Any]]] = {} +_PEER_DEVICE_LOCKS: Dict[str, asyncio.Lock] = {} + + +def parse_peers() -> Dict[str, str]: + peers: Dict[str, str] = {} + for item in (PEER_SPEC or "").replace(";", ",").split(","): + item = item.strip() + if not item: + continue + if "=" not in item: + continue + domain_id, url = item.split("=", 1) + peers[domain_id.strip().upper()] = url.strip().rstrip("/") + return peers + + +async def peer_get(domain_id: str, path: str) -> Dict[str, Any]: + peers = parse_peers() + base = peers[domain_id.upper()] + request = action_from_legacy_get_path(path) + return await call_a2a_action(base, request["action"], request["payload"]) + + +async def peer_post( + domain_id: str, + path: str, + payload: Dict[str, Any], +) -> Dict[str, Any]: + peers = parse_peers() + base = peers[domain_id.upper()] + request = action_from_legacy_post_path(path, payload) + return await call_a2a_action(base, request["action"], request["payload"]) + + +def clear_peer_inventory_cache() -> None: + """Clear short-lived peer inventory cache.""" + + _PEER_DEVICE_CACHE.clear() + _PEER_DEVICE_LOCKS.clear() + + +async def peer_device_inventory( + domain_id: str, + use_cache: bool = True, +) -> Dict[str, Any]: + """Return peer device inventory with short-lived cache.""" + + domain_id = domain_id.upper() + now = time.monotonic() + cached = _PEER_DEVICE_CACHE.get(domain_id) + if ( + use_cache + and cached is not None + and now - cached[0] <= PEER_INVENTORY_CACHE_TTL_SECONDS + ): + return cached[1] + + lock = _PEER_DEVICE_LOCKS.setdefault(domain_id, asyncio.Lock()) + async with lock: + now = time.monotonic() + cached = _PEER_DEVICE_CACHE.get(domain_id) + if ( + use_cache + and cached is not None + and now - cached[0] <= PEER_INVENTORY_CACHE_TTL_SECONDS + ): + return cached[1] + inventory = await peer_get(domain_id, "/inventory/devices") + _PEER_DEVICE_CACHE[domain_id] = (time.monotonic(), inventory) + return inventory + + +async def discover_endpoint_owner(device_name: str) -> Dict[str, Any]: + target = device_name.strip() + checked: List[Dict[str, Any]] = [] + local_inventory = ( + await peer_device_inventory(DOMAIN_ID) + if DOMAIN_ID in parse_peers() + else None + ) + if local_inventory: + local_names = [item.get("name") for item in local_inventory.get( + "devices", []) if isinstance(item, dict)] + checked.append( + {"domain_id": DOMAIN_ID, "device_count": len(local_names)}) + if target in local_names: + return { + "ok": True, + "domain_id": DOMAIN_ID, + "device_name": target, + "checked": checked, + } + peers = parse_peers() + peer_results = await _parallel_peer_device_inventories( + [domain_id for domain_id in peers if domain_id != DOMAIN_ID] + ) + for domain_id, inventory in peer_results: + if isinstance(inventory, Exception): + checked.append({"domain_id": domain_id, "error": str(inventory)}) + continue + names = _device_names(inventory) + checked.append({"domain_id": domain_id, "device_count": len(names)}) + if target in names: + return { + "ok": True, + "domain_id": domain_id, + "device_name": target, + "checked": checked, + } + return { + "ok": False, + "error": "endpoint_owner_not_found", + "device_name": target, + "checked": checked, + } + + +async def find_endpoint_owner( + device_name: str, + local_devices: Optional[List[Dict[str, Any]]] = None, +) -> Dict[str, Any]: + target = device_name.strip() + checked: List[Dict[str, Any]] = [] + if local_devices is not None: + local_names = [item.get("name") + for item in local_devices if isinstance(item, dict)] + checked.append( + {"domain_id": DOMAIN_ID, "device_count": len(local_names)}) + if target in local_names: + return { + "ok": True, + "domain_id": DOMAIN_ID, + "device_name": target, + "role": "local", + "checked": checked, + } + peer_results = await _parallel_peer_device_inventories(parse_peers()) + for domain_id, inventory in peer_results: + if isinstance(inventory, Exception): + checked.append({"domain_id": domain_id, "error": str(inventory)}) + continue + names = _device_names(inventory) + checked.append({"domain_id": domain_id, "device_count": len(names)}) + if target in names: + return { + "ok": True, + "domain_id": domain_id, + "device_name": target, + "role": "peer", + "checked": checked, + } + return { + "ok": False, + "error": "endpoint_owner_not_found", + "device_name": target, + "checked": checked, + } + + +async def _parallel_peer_device_inventories( + domain_ids: Any, +) -> List[tuple[str, Dict[str, Any] | Exception]]: + ordered_domain_ids = sorted(str(domain_id).upper() + for domain_id in domain_ids) + tasks = [peer_device_inventory(domain_id) + for domain_id in ordered_domain_ids] + results = await asyncio.gather(*tasks, return_exceptions=True) + return list(zip(ordered_domain_ids, results)) + + +def _device_names(inventory: Dict[str, Any]) -> List[str]: + return [ + str(item.get("name")) + for item in inventory.get("devices", []) + if isinstance(item, dict) and item.get("name") + ] diff --git a/src/agentic/service/session_store.py b/src/agentic/service/session_store.py new file mode 100644 index 000000000..d35714d88 --- /dev/null +++ b/src/agentic/service/session_store.py @@ -0,0 +1,181 @@ +# 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. + +"""SQLite-backed per-domain session persistence.""" + +from __future__ import annotations + +import json +import sqlite3 +import time +from pathlib import Path +from typing import Any, Dict, List, Sequence + +from agentic.service.settings import DOMAIN_ID, SESSION_DB_PATH + + +def _connect() -> sqlite3.Connection: + Path(SESSION_DB_PATH).parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(SESSION_DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute( + """ + CREATE TABLE IF NOT EXISTS session_events ( + id INTEGER PRIMARY KEY AUTOINCREMENT, + ts REAL NOT NULL, + domain_id TEXT NOT NULL, + user_id TEXT NOT NULL, + session_id TEXT NOT NULL, + event_type TEXT NOT NULL, + payload TEXT NOT NULL + ) + """ + ) + conn.execute( + """ + CREATE INDEX IF NOT EXISTS idx_session_events_lookup + ON session_events(domain_id, user_id, session_id, id) + """ + ) + return conn + + +def record_event( + event_type: str, + payload: Dict[str, Any], + *, + user_id: str = "default", + session_id: str = "default", +) -> Dict[str, Any]: + ts = time.time() + with _connect() as conn: + cur = conn.execute( + """ + INSERT INTO session_events( + ts, domain_id, user_id, session_id, event_type, payload + ) + VALUES (?, ?, ?, ?, ?, ?) + """, + (ts, DOMAIN_ID, user_id, session_id, event_type, + json.dumps(payload, sort_keys=True)), + ) + conn.commit() + event_id = int(cur.lastrowid) + return { + "event_id": event_id, + "ts": ts, + "domain_id": DOMAIN_ID, + "user_id": user_id, + "session_id": session_id, + "event_type": event_type, + "payload": payload, + } + + +def list_events( + user_id: str = "default", + session_id: str = "default", + limit: int = 50, +) -> List[Dict[str, Any]]: + limit = max(1, min(int(limit or 50), 500)) + with _connect() as conn: + rows = conn.execute( + """ + SELECT id, ts, domain_id, user_id, session_id, event_type, payload + FROM session_events + WHERE domain_id = ? AND user_id = ? AND session_id = ? + ORDER BY id DESC + LIMIT ? + """, + (DOMAIN_ID, user_id, session_id, limit), + ).fetchall() + events = [] + for row in reversed(rows): + payload = json.loads(row["payload"]) + events.append( + { + "event_id": row["id"], + "ts": row["ts"], + "domain_id": row["domain_id"], + "user_id": row["user_id"], + "session_id": row["session_id"], + "event_type": row["event_type"], + "payload": payload, + } + ) + return events + + +def find_events_by_request_id( + request_id: str, + event_types: Sequence[str] | None = None, + limit: int = 20, +) -> List[Dict[str, Any]]: + """Return recent events whose payload contains a workflow request id.""" + + request_id = request_id.strip() + if not request_id: + return [] + limit = max(1, min(int(limit or 20), 200)) + event_types = tuple(event_types or ()) + params: List[Any] = [DOMAIN_ID, f"%{request_id}%"] + event_filter = "" + if event_types: + placeholders = ",".join("?" for _ in event_types) + event_filter = f" AND event_type IN ({placeholders})" + params.extend(event_types) + params.append(limit) + with _connect() as conn: + rows = conn.execute( + f""" + SELECT id, ts, domain_id, user_id, session_id, event_type, payload + FROM session_events + WHERE domain_id = ? AND payload LIKE ?{event_filter} + ORDER BY id DESC + LIMIT ? + """, + params, + ).fetchall() + events = [] + for row in rows: + payload = json.loads(row["payload"]) + if ( + str(payload.get("request_id", payload.get("service_uuid", ""))) + != request_id + ): + continue + events.append( + { + "event_id": row["id"], + "ts": row["ts"], + "domain_id": row["domain_id"], + "user_id": row["user_id"], + "session_id": row["session_id"], + "event_type": row["event_type"], + "payload": payload, + } + ) + return events + + +def session_health() -> Dict[str, Any]: + with _connect() as conn: + count = conn.execute( + "SELECT COUNT(*) AS count FROM session_events").fetchone()["count"] + return { + "ok": True, + "domain_id": DOMAIN_ID, + "db_path": str(Path(SESSION_DB_PATH).resolve()), + "events": int(count), + } diff --git a/src/agentic/service/settings.py b/src/agentic/service/settings.py new file mode 100644 index 000000000..eb6f25eec --- /dev/null +++ b/src/agentic/service/settings.py @@ -0,0 +1,72 @@ +# 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. + +"""Runtime settings for the Agentic component. + +The ADK runtime imports constants from this module, while environment access +and default handling remain centralized in agentic.Config and common.Settings. +""" + +from __future__ import annotations + +from agentic.Config import ( + get_agentic_default_slot_width, + get_agentic_domain_id, + get_agentic_domain_name, + get_agentic_graph_normalized, + get_agentic_mcp_auth_token, + get_agentic_mcp_startup_warmup_timeout_seconds, + get_agentic_mcp_tool_timeout_seconds, + get_agentic_mcp_url, + get_agentic_model, + get_agentic_ollama_api_base, + get_agentic_peers, + get_agentic_run_timeout_seconds, + get_agentic_session_db_path, + get_agentic_spectrum_base, + get_agentic_spectrum_blocked, + get_agentic_spectrum_db_path, + get_agentic_startup_warmup_enabled, + get_agentic_startup_warmup_prompts, + get_agentic_startup_warmup_timeout_seconds, + get_agentic_tool_profile, + get_tfs_default_context_uuid, +) + + +MODEL_NAME = get_agentic_model() +OLLAMA_API_BASE = get_agentic_ollama_api_base() +TFS_MCP_URL = get_agentic_mcp_url() +TFS_MCP_AUTH_TOKEN = get_agentic_mcp_auth_token() +DOMAIN_ID = get_agentic_domain_id() +DOMAIN_NAME = get_agentic_domain_name() +PEER_SPEC = get_agentic_peers() +SESSION_DB_PATH = get_agentic_session_db_path() +SPECTRUM_DB_PATH = get_agentic_spectrum_db_path() +SPECTRUM_BASE = get_agentic_spectrum_base() +SPECTRUM_BLOCKED = get_agentic_spectrum_blocked() +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() +AGENT_RUN_TIMEOUT_SECONDS = get_agentic_run_timeout_seconds() +MCP_STARTUP_WARMUP_TIMEOUT_SECONDS = ( + get_agentic_mcp_startup_warmup_timeout_seconds() +) +MCP_TOOL_TIMEOUT_SECONDS = get_agentic_mcp_tool_timeout_seconds() +AGENT_STARTUP_WARMUP_ENABLED = get_agentic_startup_warmup_enabled() +AGENT_STARTUP_WARMUP_TIMEOUT_SECONDS = ( + get_agentic_startup_warmup_timeout_seconds() +) +AGENT_STARTUP_WARMUP_PROMPTS = get_agentic_startup_warmup_prompts() diff --git a/src/agentic/service/spectrum.py b/src/agentic/service/spectrum.py new file mode 100644 index 000000000..d590f7c82 --- /dev/null +++ b/src/agentic/service/spectrum.py @@ -0,0 +1,407 @@ +# 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. + +"""Simple persisted flex-grid spectrum state for negotiation.""" + +from __future__ import annotations + +import json +import math +import sqlite3 +import time +import uuid +from pathlib import Path +from typing import Any, Dict, Iterable, List, Tuple + +from agentic.service.settings import ( + DEFAULT_SLOT_WIDTH, + DOMAIN_ID, + SPECTRUM_BASE, + SPECTRUM_BLOCKED, + SPECTRUM_DB_PATH, +) + +Range = Tuple[int, int] +SLOT_WIDTH_GHZ = 6.25 +CHANNEL_WIDTH_GRANULARITY_GHZ = 25.0 + +MODULATION_SPECTRAL_EFFICIENCY = { + "bpsk": 1.0, + "qpsk": 2.0, + "8qam": 3.0, + "16qam": 4.0, + "32qam": 5.0, + "64qam": 6.0, + "dp-bpsk": 2.0, + "dp-qpsk": 4.0, + "dp-8qam": 6.0, + "dp-16qam": 8.0, + "dp-32qam": 10.0, + "dp-64qam": 12.0, +} + + +def _connect() -> sqlite3.Connection: + Path(SPECTRUM_DB_PATH).parent.mkdir(parents=True, exist_ok=True) + conn = sqlite3.connect(SPECTRUM_DB_PATH) + conn.row_factory = sqlite3.Row + conn.execute( + """ + CREATE TABLE IF NOT EXISTS reservations ( + id TEXT PRIMARY KEY, + ts REAL NOT NULL, + domain_id TEXT NOT NULL, + service_id TEXT NOT NULL, + n_start INTEGER NOT NULL, + n_end INTEGER NOT NULL, + status TEXT NOT NULL, + metadata TEXT NOT NULL + ) + """ + ) + return conn + + +def _parse_ranges(text: str) -> List[Range]: + ranges: List[Range] = [] + for item in (text or "").replace(";", ",").split(","): + item = item.strip() + if not item: + continue + if "-" in item: + start, end = item.split("-", 1) + else: + start = end = item + ranges.append((int(start), int(end))) + return sorted(ranges) + + +def _range_dicts(ranges: Iterable[Range]) -> List[Dict[str, int]]: + return [{"n_start": start, "n_end": end} for start, end in ranges] + + +def normalize_modulation(modulation_format: str) -> str: + return (modulation_format or "").strip().lower().replace("_", "-") + + +def channel_width_from_capacity( + capacity_gbps: float, + modulation_format: str, +) -> Dict[str, Any]: + modulation = normalize_modulation(modulation_format) + efficiency = MODULATION_SPECTRAL_EFFICIENCY.get(modulation) + if not efficiency: + return { + "ok": False, + "error": "unsupported_modulation_format", + "modulation_format": modulation_format, + "supported_modulation_formats": sorted( + MODULATION_SPECTRAL_EFFICIENCY + ), + } + capacity = float(capacity_gbps) + if capacity <= 0: + return { + "ok": False, + "error": "invalid_capacity_gbps", + "capacity_gbps": capacity_gbps, + } + return { + "ok": True, + "capacity_gbps": capacity, + "modulation_format": modulation, + "spectral_efficiency_bps_per_hz": efficiency, + "channel_width_ghz": capacity / efficiency, + } + + +def slots_for_channel_width(channel_width_ghz: float) -> Dict[str, Any]: + width = float(channel_width_ghz) + if width <= 0: + return { + "ok": False, + "error": "invalid_channel_width_ghz", + "channel_width_ghz": channel_width_ghz, + } + rounded_width = math.ceil( + width / CHANNEL_WIDTH_GRANULARITY_GHZ) * CHANNEL_WIDTH_GRANULARITY_GHZ + required_slots = int(math.ceil(rounded_width / SLOT_WIDTH_GHZ)) + return { + "ok": True, + "slot_width_ghz": SLOT_WIDTH_GHZ, + "channel_width_granularity_ghz": CHANNEL_WIDTH_GRANULARITY_GHZ, + "channel_width_ghz": width, + "required_slots": required_slots, + "rounded_channel_width_ghz": rounded_width, + "tfs_optical_band_width_ghz": int(rounded_width), + } + + +def spectrum_request( + capacity_gbps: float | None = None, + modulation_format: str = "", + channel_width_ghz: float | None = None, +) -> Dict[str, Any]: + if channel_width_ghz is not None: + slot_result = slots_for_channel_width(channel_width_ghz) + return { + **slot_result, + "source": "explicit_channel_width_ghz", + "capacity_gbps": capacity_gbps, + "modulation_format": normalize_modulation(modulation_format), + } + if capacity_gbps is None or not (modulation_format or "").strip(): + return { + "ok": False, + "error": "missing_spectrum_sizing", + "message": ( + "Provide either channel_width_ghz, or both capacity_gbps " + "and modulation_format." + ), + } + width_result = channel_width_from_capacity( + capacity_gbps, modulation_format) + if not width_result.get("ok"): + return width_result + slot_result = slots_for_channel_width(width_result["channel_width_ghz"]) + return {**width_result, **slot_result, "source": "capacity_and_modulation"} + + +def _subtract(base: List[Range], blocked: List[Range]) -> List[Range]: + free = list(base) + for b_start, b_end in blocked: + next_free: List[Range] = [] + for f_start, f_end in free: + if b_end < f_start or b_start > f_end: + next_free.append((f_start, f_end)) + continue + if b_start > f_start: + next_free.append((f_start, b_start - 1)) + if b_end < f_end: + next_free.append((b_end + 1, f_end)) + free = next_free + return free + + +def _active_reservations() -> List[Dict[str, Any]]: + with _connect() as conn: + rows = conn.execute( + """ + SELECT id, service_id, n_start, n_end, status, metadata + FROM reservations + WHERE domain_id = ? AND status IN ('soft', 'committed') + ORDER BY n_start + """, + (DOMAIN_ID,), + ).fetchall() + return [ + { + "reservation_id": row["id"], + "service_id": row["service_id"], + "n_start": int(row["n_start"]), + "n_end": int(row["n_end"]), + "status": row["status"], + "metadata": json.loads(row["metadata"]), + } + for row in rows + ] + + +def candidates(required_slots: int = DEFAULT_SLOT_WIDTH) -> Dict[str, Any]: + required_slots = max(1, int(required_slots or DEFAULT_SLOT_WIDTH)) + active = _active_reservations() + blocked = _parse_ranges(SPECTRUM_BLOCKED) + \ + [(item["n_start"], item["n_end"]) for item in active] + free = _subtract(_parse_ranges(SPECTRUM_BASE), blocked) + feasible = [(start, end) + for start, end in free if end - start + 1 >= required_slots] + return { + "ok": True, + "domain_id": DOMAIN_ID, + "required_slots": required_slots, + "base_ranges": _range_dicts(_parse_ranges(SPECTRUM_BASE)), + "blocked_ranges": _range_dicts(blocked), + "candidate_ranges": _range_dicts(feasible), + "active_reservations": active, + } + + +def intersect_and_select( + local_candidates: List[Dict[str, int]], + peer_candidates: List[Dict[str, int]], + required_slots: int = DEFAULT_SLOT_WIDTH, + minimum_slot: int | None = None, + preferred_band: str = "", +) -> Dict[str, Any]: + required_slots = max(1, int(required_slots or DEFAULT_SLOT_WIDTH)) + minimum_slot = int(minimum_slot) if minimum_slot is not None else None + intersections: List[Range] = [] + for local in local_candidates: + for peer in peer_candidates: + start = max(int(local["n_start"]), int(peer["n_start"])) + end = min(int(local["n_end"]), int(peer["n_end"])) + if end - start + 1 >= required_slots: + intersections.append((start, end)) + if not intersections: + return { + "ok": False, + "error": "no_common_spectrum", + "required_slots": required_slots, + } + + preferred_band = (preferred_band or "").strip() + if preferred_band: + preferred = _parse_ranges(preferred_band) + if len(preferred) != 1: + return { + "ok": False, + "error": "invalid_preferred_band", + "preferred_band": preferred_band, + } + preferred_start, preferred_end = preferred[0] + if preferred_end - preferred_start + 1 < required_slots: + return { + "ok": False, + "error": "preferred_band_too_small", + "required_slots": required_slots, + "preferred_band": preferred_band, + } + for start, end in intersections: + if preferred_start >= start and preferred_end <= end: + chosen = {"n_start": preferred_start, + "n_end": preferred_start + required_slots - 1} + return { + "ok": True, + "required_slots": required_slots, + "intersection_ranges": _range_dicts(intersections), + "selected_range": chosen, + "preferred_band": f"{chosen['n_start']}-{chosen['n_end']}", + "selection_reason": "preferred_band", + } + return { + "ok": False, + "error": "preferred_band_not_available", + "required_slots": required_slots, + "preferred_band": preferred_band, + "intersection_ranges": _range_dicts(intersections), + } + + if minimum_slot is not None: + bounded = [] + for start, end in intersections: + bounded_start = max(start, minimum_slot) + if end - bounded_start + 1 >= required_slots: + bounded.append((bounded_start, end)) + if not bounded: + return { + "ok": False, + "error": "no_common_spectrum_at_or_above_minimum", + "required_slots": required_slots, + "minimum_slot": minimum_slot, + "intersection_ranges": _range_dicts(intersections), + } + chosen_start, _ = min(bounded, key=lambda item: (item[0], item[1])) + else: + chosen_start, _ = min( + intersections, key=lambda item: (item[0], item[1])) + chosen = {"n_start": chosen_start, + "n_end": chosen_start + required_slots - 1} + return { + "ok": True, + "required_slots": required_slots, + "intersection_ranges": _range_dicts(intersections), + "selected_range": chosen, + "preferred_band": f"{chosen['n_start']}-{chosen['n_end']}", + "minimum_slot": minimum_slot, + "selection_reason": ( + "minimum_slot" if minimum_slot is not None else "lowest_common" + ), + } + + +def reserve( + service_id: str, + selected_range: Dict[str, int], + metadata: Dict[str, Any] | None = None, +) -> Dict[str, Any]: + start = int(selected_range["n_start"]) + end = int(selected_range["n_end"]) + for active in _active_reservations(): + if not (end < active["n_start"] or start > active["n_end"]): + return { + "ok": False, + "error": "spectrum_conflict", + "conflict": active, + } + reservation_id = f"res-{uuid.uuid4().hex[:12]}" + with _connect() as conn: + conn.execute( + """ + INSERT INTO reservations( + id, ts, domain_id, service_id, n_start, n_end, status, + metadata + ) + VALUES (?, ?, ?, ?, ?, ?, 'soft', ?) + """, + (reservation_id, time.time(), DOMAIN_ID, service_id, + start, end, json.dumps(metadata or {}, sort_keys=True)), + ) + conn.commit() + return { + "ok": True, + "operation": "reserve", + "reservation_id": reservation_id, + "service_id": service_id, + "domain_id": DOMAIN_ID, + "selected_range": {"n_start": start, "n_end": end}, + "status": "soft", + } + + +def update_reservation(reservation_id: str, status: str) -> Dict[str, Any]: + if status not in {"committed", "rolled_back"}: + return {"ok": False, "error": "unsupported_status", "status": status} + with _connect() as conn: + cur = conn.execute( + "UPDATE reservations SET status = ? WHERE id = ? AND domain_id = ?", + (status, reservation_id, DOMAIN_ID), + ) + conn.commit() + return { + "ok": cur.rowcount > 0, + "operation": status, + "reservation_id": reservation_id, + "domain_id": DOMAIN_ID, + } + + +def release_by_service(service_id: str) -> Dict[str, Any]: + with _connect() as conn: + cur = conn.execute( + """ + UPDATE reservations + SET status = 'rolled_back' + WHERE domain_id = ? + AND service_id = ? + AND status IN ('soft', 'committed') + """, + (DOMAIN_ID, service_id), + ) + conn.commit() + return { + "ok": True, + "domain_id": DOMAIN_ID, + "service_id": service_id, + "released": int(cur.rowcount), + } diff --git a/src/agentic/service/static_graphs.py b/src/agentic/service/static_graphs.py new file mode 100644 index 000000000..9d0c2ed86 --- /dev/null +++ b/src/agentic/service/static_graphs.py @@ -0,0 +1,27 @@ +# 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. + +"""Backward-compatible static graph imports. + +Prefer importing from `agentic.service.graphs` in new code. +""" + +from agentic.service.graphs import ( + STATIC_GRAPHS, + GraphStep, + StaticGraph, + graph_summary, +) + +__all__ = ["GraphStep", "STATIC_GRAPHS", "StaticGraph", "graph_summary"] diff --git a/src/agentic/service/tools/__init__.py b/src/agentic/service/tools/__init__.py new file mode 100644 index 000000000..3fba80ff8 --- /dev/null +++ b/src/agentic/service/tools/__init__.py @@ -0,0 +1,34 @@ +# 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. + +__all__ = [ + "READ_ONLY_TFS_TOOLS", + "SERVICE_WORKFLOW_TOOLS", + "build_tfs_mcp_toolset", +] + + +def __getattr__(name): + if name in {"READ_ONLY_TFS_TOOLS", "build_tfs_mcp_toolset"}: + from .mcp import READ_ONLY_TFS_TOOLS, build_tfs_mcp_toolset + + return { + "READ_ONLY_TFS_TOOLS": READ_ONLY_TFS_TOOLS, + "build_tfs_mcp_toolset": build_tfs_mcp_toolset, + }[name] + if name == "SERVICE_WORKFLOW_TOOLS": + from .service import SERVICE_WORKFLOW_TOOLS + + return SERVICE_WORKFLOW_TOOLS + raise AttributeError(name) diff --git a/src/agentic/service/tools/granular.py b/src/agentic/service/tools/granular.py new file mode 100644 index 000000000..82d257870 --- /dev/null +++ b/src/agentic/service/tools/granular.py @@ -0,0 +1,609 @@ +# 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. + +"""Narrow tool contracts for granular ADK specialist agents.""" + +from __future__ import annotations + +from google.adk.tools import FunctionTool + +from agentic.service.tools.service import ( + delete_cross_domain_optical_service, + delete_service, + list_device_connections, + locate_device, + request_optical_connectivity, +) +from agentic.service.tools.service_workflow import ( + create_cross_domain_optical_service, + create_optical_connectivity_service, + list_domain_inventory as _list_domain_inventory, +) + + +async def create_local_optical_service( + source_device: str, + destination_device: str, + channel_width_ghz: float, + preferred_band: str = "", + service_name: str = "", +) -> dict: + """Create a local optical service between two local optical endpoints.""" + + return await create_optical_connectivity_service( + source_device=source_device, + destination_device=destination_device, + channel_width_ghz=channel_width_ghz, + preferred_band=preferred_band, + service_name=service_name, + ) + + +async def create_cross_domain_optical_service_tool( + source_device: str, + destination_device: str, + channel_width_ghz: float, + minimum_slot: int = 0, + preferred_band: str = "", + service_name: str = "", +) -> dict: + """Create a cross-domain optical service with path-wide negotiation.""" + + return await request_optical_connectivity( + source_device=source_device, + destination_device=destination_device, + channel_width_ghz=channel_width_ghz, + minimum_slot=minimum_slot, + preferred_band=preferred_band, + service_name=service_name, + ) + + +async def create_cross_domain_optical_service_detailed( + source_device: str, + destination_device: str, + channel_width_ghz: float, + minimum_slot: int = 0, + preferred_band: str = "", + service_name: str = "", +) -> dict: + """Create a cross-domain optical service with detailed output.""" + + return await create_cross_domain_optical_service( + source_device=source_device, + destination_device=destination_device, + channel_width_ghz=channel_width_ghz, + minimum_slot=minimum_slot, + preferred_band=preferred_band, + service_name=service_name, + ) + + +async def remove_local_optical_service(service_uuid: str) -> dict: + """Remove a local optical service by UUID or name.""" + + return await delete_service(service_uuid=service_uuid) + + +async def remove_cross_domain_optical_service(request_id: str) -> dict: + """Remove cross-domain optical service segments by request ID.""" + + return await delete_cross_domain_optical_service(request_id=request_id) + + +async def query_devices_or_endpoints( + scope: str = "all", + resource_kind: str = "devices", + device_filter: str = "", +) -> dict: + """Query compact device, endpoint, datacenter, or router headers.""" + + inventory = await _list_domain_inventory( + scope=scope, + resource_kind=resource_kind, + device_filter=device_filter, + detail_level="summary", + ) + return _compact_inventory_headers(inventory) + + +async def query_detailed_devices_or_endpoints( + scope: str = "all", + resource_kind: str = "devices", + device_filter: str = "", +) -> dict: + """Query detailed device or endpoint inventory.""" + + return await _list_domain_inventory( + scope=scope, + resource_kind=resource_kind, + device_filter=device_filter, + detail_level="full", + include_config_rules=True, + include_ids=True, + ) + + +async def query_device_location(device_name: str) -> dict: + """Locate a device or endpoint-like resource across MD-NAF domains.""" + + return await locate_device(device_name=device_name) + + +async def query_device_connections( + device_name: str, + scope: str = "all", +) -> dict: + """Query direct neighbors and links for a device.""" + + return await list_device_connections(device_name=device_name, scope=scope) + + +async def query_packet_links(scope: str = "all") -> dict: + """Query compact packet-link headers.""" + + inventory = await _list_domain_inventory( + scope=scope, + resource_kind="links", + detail_level="summary", + ) + return _compact_inventory_headers(inventory) + + +async def query_detailed_packet_links(scope: str = "all") -> dict: + """Query detailed packet links in local, remote, or all MD-NAF domains.""" + + return await _list_domain_inventory( + scope=scope, + resource_kind="links", + detail_level="full", + include_ids=True, + ) + + +async def query_optical_links(scope: str = "all") -> dict: + """Query compact optical-link headers.""" + + inventory = await _list_domain_inventory( + scope=scope, + resource_kind="optical_links", + detail_level="summary", + ) + return _compact_inventory_headers(inventory) + + +async def query_detailed_optical_links(scope: str = "all") -> dict: + """Query detailed optical links in local, remote, or all MD-NAF domains.""" + + return await _list_domain_inventory( + scope=scope, + resource_kind="optical_links", + detail_level="full", + include_ids=True, + ) + + +async def query_services(scope: str = "all") -> dict: + """Query compact service headers in local, remote, or all MD-NAF domains.""" + + inventory = await _list_domain_inventory( + scope=scope, + resource_kind="services", + detail_level="summary", + ) + return _compact_inventory_headers(inventory) + + +async def query_detailed_services(scope: str = "all") -> dict: + """Query detailed services in local, remote, or all MD-NAF domains.""" + + return await _list_domain_inventory( + scope=scope, + resource_kind="services", + detail_level="full", + include_config_rules=True, + include_ids=True, + ) + + +async def query_connections(scope: str = "all") -> dict: + """Query compact connection headers.""" + + inventory = await _list_domain_inventory( + scope=scope, + resource_kind="connections", + detail_level="summary", + ) + return _compact_inventory_headers(inventory) + + +async def query_detailed_connections(scope: str = "all") -> dict: + """Query detailed connections in local, remote, or all MD-NAF domains.""" + + return await _list_domain_inventory( + scope=scope, + resource_kind="connections", + detail_level="full", + include_ids=True, + ) + + +async def query_local_device_headers( + resource_kind: str = "devices", + device_filter: str = "", +) -> dict: + """Query compact local device, endpoint, or router headers.""" + + return await query_devices_or_endpoints( + scope="local", + resource_kind=resource_kind, + device_filter=device_filter, + ) + + +async def query_local_detailed_devices( + resource_kind: str = "devices", + device_filter: str = "", +) -> dict: + """Query detailed local device or endpoint inventory.""" + + return await query_detailed_devices_or_endpoints( + scope="local", + resource_kind=resource_kind, + device_filter=device_filter, + ) + + +async def query_local_packet_link_headers() -> dict: + """Query compact local packet-link headers.""" + + return await query_packet_links(scope="local") + + +async def query_local_detailed_packet_links() -> dict: + """Query detailed local packet links.""" + + return await query_detailed_packet_links(scope="local") + + +async def query_local_optical_link_headers() -> dict: + """Query compact local optical-link headers.""" + + return await query_optical_links(scope="local") + + +async def query_local_detailed_optical_links() -> dict: + """Query detailed local optical links.""" + + return await query_detailed_optical_links(scope="local") + + +async def query_local_service_headers() -> dict: + """Query compact local service headers.""" + + return await query_services(scope="local") + + +async def query_local_detailed_services() -> dict: + """Query detailed local services including IDs and config rules.""" + + return await query_detailed_services(scope="local") + + +async def query_local_connection_headers() -> dict: + """Query compact local connection headers.""" + + return await query_connections(scope="local") + + +async def query_local_detailed_connections() -> dict: + """Query detailed local connections.""" + + return await query_detailed_connections(scope="local") + + +async def query_cross_domain_device_headers( + resource_kind: str = "devices", + device_filter: str = "", +) -> dict: + """Query compact all-domain device, endpoint, or router headers.""" + + return await query_devices_or_endpoints( + scope="all", + resource_kind=resource_kind, + device_filter=device_filter, + ) + + +async def query_cross_domain_detailed_devices( + resource_kind: str = "devices", + device_filter: str = "", +) -> dict: + """Query detailed all-domain device or endpoint inventory.""" + + return await query_detailed_devices_or_endpoints( + scope="all", + resource_kind=resource_kind, + device_filter=device_filter, + ) + + +async def query_cross_domain_packet_link_headers() -> dict: + """Query compact all-domain packet-link headers.""" + + return await query_packet_links(scope="all") + + +async def query_cross_domain_detailed_packet_links() -> dict: + """Query detailed all-domain packet links.""" + + return await query_detailed_packet_links(scope="all") + + +async def query_cross_domain_optical_link_headers() -> dict: + """Query compact all-domain optical-link headers.""" + + return await query_optical_links(scope="all") + + +async def query_cross_domain_detailed_optical_links() -> dict: + """Query detailed all-domain optical links.""" + + return await query_detailed_optical_links(scope="all") + + +async def query_cross_domain_service_headers() -> dict: + """Query compact all-domain service headers.""" + + return await query_services(scope="all") + + +async def query_cross_domain_detailed_services() -> dict: + """Query detailed all-domain services including IDs and config rules.""" + + return await query_detailed_services(scope="all") + + +async def query_cross_domain_connection_headers() -> dict: + """Query compact all-domain connection headers.""" + + return await query_connections(scope="all") + + +async def query_cross_domain_detailed_connections() -> dict: + """Query detailed all-domain connections.""" + + return await query_detailed_connections(scope="all") + + +def _compact_inventory_headers(payload: dict) -> dict: + """Keep answer-ready headers while dropping verbose object arrays.""" + + if not isinstance(payload, dict): + return payload + compact_domains = {} + for domain_id, domain in (payload.get("domains") or {}).items(): + if not isinstance(domain, dict): + compact_domains[domain_id] = domain + continue + entry = { + "domain_id": domain.get("domain_id", domain_id), + "role": domain.get("role", ""), + "resource_kind": domain.get( + "resource_kind", payload.get("resource_kind", "") + ), + "resource_family": domain.get( + "resource_family", payload.get("resource_family", "") + ), + } + for key in ( + "device_count", + "endpoint_count", + "link_count", + "optical_link_count", + "service_count", + "connection_count", + "error", + ): + if key in domain: + entry[key] = domain[key] + if "device_names" in domain: + entry["device_names"] = domain["device_names"] + if "endpoints" in domain: + entry["endpoint_names"] = [ + ( + f"{item.get('device_name', '')}/" + f"{item.get('endpoint_name', '')}" + ).strip("/") + for item in domain.get("endpoints", []) + if isinstance(item, dict) + ] + if "links" in domain: + entry["link_names"] = [ + item.get("name", "") + for item in domain.get("links", []) + if isinstance(item, dict) and item.get("name") + ] + if "optical_links" in domain: + entry["optical_link_names"] = [ + item.get("name", "") + for item in domain.get("optical_links", []) + if isinstance(item, dict) and item.get("name") + ] + if "services" in domain: + entry["service_names"] = [ + item.get("name") or item.get("uuid", "") + for item in domain.get("services", []) + if ( + isinstance(item, dict) + and (item.get("name") or item.get("uuid")) + ) + ] + if "connections" in domain: + entry["connection_names"] = [ + item.get("name") or item.get("uuid", "") + for item in domain.get("connections", []) + if ( + isinstance(item, dict) + and (item.get("name") or item.get("uuid")) + ) + ] + compact_domains[domain_id] = entry + return { + key: value + for key, value in { + "ok": payload.get("ok"), + "local_domain": payload.get("local_domain"), + "scope": payload.get("scope"), + "resource_kind": payload.get("resource_kind"), + "resource_family": payload.get("resource_family"), + "device_filter": payload.get("device_filter"), + "detail_level": "headers", + "domains_checked": payload.get("domains_checked"), + "domains": compact_domains, + "failures": payload.get("failures"), + "total_device_count": payload.get("total_device_count"), + "total_endpoint_count": payload.get("total_endpoint_count"), + "total_link_count": payload.get("total_link_count"), + "total_optical_link_count": payload.get("total_optical_link_count"), + "total_service_count": payload.get("total_service_count"), + "total_connection_count": payload.get("total_connection_count"), + }.items() + if value not in (None, "", [], {}) + } + + +LOCAL_OPTICAL_CREATE_TOOLS = [FunctionTool(create_local_optical_service)] +CROSS_DOMAIN_OPTICAL_CREATE_TOOLS = [ + FunctionTool(create_cross_domain_optical_service_tool) +] +LOCAL_OPTICAL_DELETE_TOOLS = [FunctionTool(remove_local_optical_service)] +CROSS_DOMAIN_OPTICAL_DELETE_TOOLS = [ + FunctionTool(remove_cross_domain_optical_service) +] +DEVICE_ENDPOINT_QUERY_TOOLS = [ + FunctionTool(query_devices_or_endpoints), + FunctionTool(query_device_location), + FunctionTool(query_device_connections), +] +DETAILED_DEVICE_ENDPOINT_QUERY_TOOLS = [ + FunctionTool(query_detailed_devices_or_endpoints), + FunctionTool(query_device_location), + FunctionTool(query_device_connections), +] +PACKET_LINK_QUERY_TOOLS = [FunctionTool(query_packet_links)] +DETAILED_PACKET_LINK_QUERY_TOOLS = [FunctionTool(query_detailed_packet_links)] +OPTICAL_LINK_QUERY_TOOLS = [FunctionTool(query_optical_links)] +DETAILED_OPTICAL_LINK_QUERY_TOOLS = [ + FunctionTool(query_detailed_optical_links) +] +SERVICE_INVENTORY_QUERY_TOOLS = [FunctionTool(query_services)] +DETAILED_SERVICE_INVENTORY_QUERY_TOOLS = [ + FunctionTool(query_detailed_services) +] +CONNECTION_INVENTORY_QUERY_TOOLS = [FunctionTool(query_connections)] +DETAILED_CONNECTION_INVENTORY_QUERY_TOOLS = [ + FunctionTool(query_detailed_connections) +] + +LOCAL_RETRIEVAL_TOOLS = [ + FunctionTool(query_local_device_headers), + FunctionTool(query_local_detailed_devices), + FunctionTool(query_device_location), + FunctionTool(query_device_connections), + FunctionTool(query_local_packet_link_headers), + FunctionTool(query_local_detailed_packet_links), + FunctionTool(query_local_optical_link_headers), + FunctionTool(query_local_detailed_optical_links), + FunctionTool(query_local_service_headers), + FunctionTool(query_local_detailed_services), + FunctionTool(query_local_connection_headers), + FunctionTool(query_local_detailed_connections), +] + +CROSS_DOMAIN_RETRIEVAL_TOOLS = [ + FunctionTool(query_cross_domain_device_headers), + FunctionTool(query_cross_domain_detailed_devices), + FunctionTool(query_device_location), + FunctionTool(query_device_connections), + FunctionTool(query_cross_domain_packet_link_headers), + FunctionTool(query_cross_domain_detailed_packet_links), + FunctionTool(query_cross_domain_optical_link_headers), + FunctionTool(query_cross_domain_detailed_optical_links), + FunctionTool(query_cross_domain_service_headers), + FunctionTool(query_cross_domain_detailed_services), + FunctionTool(query_cross_domain_connection_headers), + FunctionTool(query_cross_domain_detailed_connections), +] + +LOCAL_DEVICE_RETRIEVAL_TOOLS = [ + FunctionTool(query_local_device_headers), + FunctionTool(query_local_detailed_devices), + FunctionTool(query_device_location), + FunctionTool(query_device_connections), +] + +CROSS_DOMAIN_DEVICE_RETRIEVAL_TOOLS = [ + FunctionTool(query_cross_domain_device_headers), + FunctionTool(query_cross_domain_detailed_devices), + FunctionTool(query_device_location), + FunctionTool(query_device_connections), +] + +LOCAL_PACKET_LINK_RETRIEVAL_TOOLS = [ + FunctionTool(query_local_packet_link_headers), + FunctionTool(query_local_detailed_packet_links), +] + +CROSS_DOMAIN_PACKET_LINK_RETRIEVAL_TOOLS = [ + FunctionTool(query_cross_domain_packet_link_headers), + FunctionTool(query_cross_domain_detailed_packet_links), +] + +LOCAL_OPTICAL_LINK_RETRIEVAL_TOOLS = [ + FunctionTool(query_local_optical_link_headers), + FunctionTool(query_local_detailed_optical_links), +] + +CROSS_DOMAIN_OPTICAL_LINK_RETRIEVAL_TOOLS = [ + FunctionTool(query_cross_domain_optical_link_headers), + FunctionTool(query_cross_domain_detailed_optical_links), +] + +LOCAL_SERVICE_RETRIEVAL_TOOLS = [ + FunctionTool(query_local_service_headers), + FunctionTool(query_local_detailed_services), +] + +CROSS_DOMAIN_SERVICE_RETRIEVAL_TOOLS = [ + FunctionTool(query_cross_domain_service_headers), + FunctionTool(query_cross_domain_detailed_services), +] + +LOCAL_CONNECTION_RETRIEVAL_TOOLS = [ + FunctionTool(query_local_connection_headers), + FunctionTool(query_local_detailed_connections), +] + +CROSS_DOMAIN_CONNECTION_RETRIEVAL_TOOLS = [ + FunctionTool(query_cross_domain_connection_headers), + FunctionTool(query_cross_domain_detailed_connections), +] + +SERVICE_MUTATION_TOOLS = [ + FunctionTool(create_local_optical_service), + FunctionTool(create_cross_domain_optical_service_tool), + FunctionTool(remove_local_optical_service), + FunctionTool(remove_cross_domain_optical_service), +] diff --git a/src/agentic/service/tools/mcp.py b/src/agentic/service/tools/mcp.py new file mode 100644 index 000000000..cbd7ed753 --- /dev/null +++ b/src/agentic/service/tools/mcp.py @@ -0,0 +1,70 @@ +# 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. + +"""MCP toolset wiring for TFS controller access.""" + +from __future__ import annotations + +from typing import Dict + +from google.adk.tools import McpToolset +from google.adk.tools.mcp_tool.mcp_session_manager import SseConnectionParams + +from agentic.service.settings import TFS_MCP_AUTH_TOKEN, TFS_MCP_URL + + +READ_ONLY_TFS_TOOLS = [ + "tfs_health_check", + "tfs_list_context_ids", + "tfs_list_contexts", + "tfs_get_context", + "tfs_list_topology_ids", + "tfs_list_topologies", + "tfs_get_topology", + "tfs_get_topology_details", + "tfs_list_device_ids", + "tfs_list_devices", + "tfs_get_device", + "tfs_list_link_ids", + "tfs_list_links", + "tfs_get_link", + "tfs_list_optical_link_ids", + "tfs_list_optical_links", + "tfs_get_optical_link", + "tfs_compute_optical_connectivity_candidates", + "tfs_list_service_ids", + "tfs_list_services", + "tfs_get_service", + "tfs_list_connection_ids", + "tfs_list_connections", + "tfs_list_all_connections", + "tfs_get_connection", +] + + +def mcp_headers() -> Dict[str, str]: + headers = {"Accept": "text/event-stream"} + if TFS_MCP_AUTH_TOKEN: + headers["Authorization"] = f"Bearer {TFS_MCP_AUTH_TOKEN}" + return headers + + +def build_tfs_mcp_toolset() -> McpToolset: + return McpToolset( + connection_params=SseConnectionParams( + url=TFS_MCP_URL, + headers=mcp_headers(), + ), + tool_filter=READ_ONLY_TFS_TOOLS, + ) diff --git a/src/agentic/service/tools/mcp_client.py b/src/agentic/service/tools/mcp_client.py new file mode 100644 index 000000000..0beca7b26 --- /dev/null +++ b/src/agentic/service/tools/mcp_client.py @@ -0,0 +1,188 @@ +# 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. + +"""Small MCP client helpers used by deterministic ADK workflow tools.""" + +from __future__ import annotations + +import asyncio +import json +import logging +from contextlib import AsyncExitStack +from typing import Any, Dict + +from mcp import ClientSession +from mcp.client.sse import sse_client + +from agentic.service.settings import ( + MCP_TOOL_TIMEOUT_SECONDS, + TFS_MCP_AUTH_TOKEN, + TFS_MCP_URL, +) + +LOGGER = logging.getLogger(__name__) +_SESSION_LOCK = asyncio.Lock() +_EXIT_STACK: AsyncExitStack | None = None +_SESSION: ClientSession | None = None + + +def _headers() -> Dict[str, str]: + headers = {"Accept": "text/event-stream"} + if TFS_MCP_AUTH_TOKEN: + headers["Authorization"] = f"Bearer {TFS_MCP_AUTH_TOKEN}" + return headers + + +async def initialize_mcp_session() -> None: + """Open the process-level MCP session if it is not already connected.""" + + global _EXIT_STACK, _SESSION # pylint: disable=global-statement + async with _SESSION_LOCK: + if _SESSION is not None: + return + stack = AsyncExitStack() + try: + read, write = await stack.enter_async_context( + sse_client( + TFS_MCP_URL, + headers=_headers(), + timeout=10, + sse_read_timeout=120, + ) + ) + session = await stack.enter_async_context( + ClientSession(read, write) + ) + await session.initialize() + except Exception: + await stack.aclose() + raise + _EXIT_STACK = stack + _SESSION = session + LOGGER.info("Initialized MCP session url=%s", TFS_MCP_URL) + + +async def close_mcp_session() -> None: + """Close the process-level MCP session.""" + + global _EXIT_STACK, _SESSION # pylint: disable=global-statement + async with _SESSION_LOCK: + stack = _EXIT_STACK + _EXIT_STACK = None + _SESSION = None + if stack is not None: + await stack.aclose() + LOGGER.info("Closed MCP session url=%s", TFS_MCP_URL) + + +async def _reset_mcp_session() -> None: + await close_mcp_session() + + +async def warm_mcp_session() -> Dict[str, Any]: + """Initialize MCP and execute a cheap read-only tool to warm the path.""" + + await initialize_mcp_session() + result = await _call_tool_with_session("tfs_list_context_ids", {}) + return _parse_tool_result("tfs_list_context_ids", {}, result) + + +async def _call_tool_with_session( + tool_name: str, + arguments: Dict[str, Any], +) -> Any: + global _SESSION # pylint: disable=global-statement + async with _SESSION_LOCK: + if _SESSION is None: + stack = AsyncExitStack() + try: + read, write = await stack.enter_async_context( + sse_client( + TFS_MCP_URL, + headers=_headers(), + timeout=10, + sse_read_timeout=120, + ) + ) + session = await stack.enter_async_context( + ClientSession(read, write) + ) + await session.initialize() + except Exception: + await stack.aclose() + raise + _set_session_unlocked(stack, session) + LOGGER.info("Initialized MCP session url=%s", TFS_MCP_URL) + return await _SESSION.call_tool(tool_name, arguments) + + +def _set_session_unlocked( + stack: AsyncExitStack, + session: ClientSession, +) -> None: + global _EXIT_STACK, _SESSION # pylint: disable=global-statement + _EXIT_STACK = stack + _SESSION = session + + +async def call_mcp_tool( + tool_name: str, + arguments: Dict[str, Any], +) -> Dict[str, Any]: + """Call one MCP tool and return parsed structured JSON where possible.""" + + try: + result = await asyncio.wait_for( + _call_tool_with_session(tool_name, arguments), + timeout=MCP_TOOL_TIMEOUT_SECONDS, + ) + except Exception as exc: + LOGGER.warning( + "MCP tool call failed; reconnecting once tool=%s error=%s", + tool_name, + exc, + ) + await _reset_mcp_session() + result = await asyncio.wait_for( + _call_tool_with_session(tool_name, arguments), + timeout=MCP_TOOL_TIMEOUT_SECONDS, + ) + + return _parse_tool_result(tool_name, arguments, result) + + +def _parse_tool_result( + tool_name: str, + arguments: Dict[str, Any], + result: Any, +) -> Dict[str, Any]: + if getattr(result, "isError", False): + return { + "error": True, + "tool_name": tool_name, + "arguments": arguments, + "result": str(result), + } + + content = getattr(result, "content", []) or [] + if not content: + return {} + first = content[0] + text = getattr(first, "text", "") + if not text: + return {"text": str(first)} + try: + return json.loads(text) + except json.JSONDecodeError: + return {"text": text} diff --git a/src/agentic/service/tools/service.py b/src/agentic/service/tools/service.py new file mode 100644 index 000000000..2f176f1f0 --- /dev/null +++ b/src/agentic/service/tools/service.py @@ -0,0 +1,379 @@ +# 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 FunctionTool wrappers for deterministic service workflows.""" + +from typing import Any + +from google.adk.tools import FunctionTool + +from agentic.service.settings import TOOL_PROFILE + +from .service_workflow import ( + create_cross_domain_optical_service as _create_cross_domain_optical_service, + create_optical_connectivity_service as _create_optical_connectivity_service, + delete_cross_domain_optical_service as _delete_cross_domain_optical_service, + delete_service as _delete_service, + list_device_connections as _list_device_connections, + list_domain_inventory as _list_domain_inventory, + list_mdnaf_domains as _list_mdnaf_domains, + locate_device as _locate_device, +) + + +def _compact_ranges(value: Any) -> list[dict[str, Any]]: + if not isinstance(value, list): + return [] + ranges = [item for item in value if isinstance(item, dict)] + return ranges[:8] + + +def _workflow_status(result: dict[str, Any]) -> str: + if result.get("observed_service_status"): + return str(result["observed_service_status"]) + local_create = result.get("local_create") + if ( + isinstance(local_create, dict) + and local_create.get("observed_service_status") + ): + return str(local_create["observed_service_status"]) + service_status = result.get("service_status") + if service_status: + return str(service_status).replace("SERVICESTATUS_", "") + if result.get("ok") and result.get("phase") == "active": + return "ACTIVE" + return "" + + +def _compact_endpoint_resolution(value: Any) -> dict[str, Any]: + if not isinstance(value, dict): + return {} + compact: dict[str, Any] = {} + for key in ("source", "destination"): + item = value.get(key) + if not isinstance(item, dict): + continue + owner = item.get("owner") if isinstance( + item.get("owner"), dict) else {} + compact[key] = { + "ok": item.get("ok"), + "requested_device": item.get("requested_device"), + "resolved_device": item.get("resolved_device"), + "domain_id": owner.get("domain_id"), + "domain_name": owner.get("domain_name"), + } + return compact + + +def _compact_workflow_agents(trace: Any) -> list[dict[str, Any]]: + if not isinstance(trace, list): + return [] + by_role: dict[str, dict[str, Any]] = {} + for row in trace: + if not isinstance(row, dict): + continue + role = str(row.get("agent_role") or row.get("actor") or "") + if not role: + continue + item = by_role.setdefault( + role, + { + "agent_role": role, + "agent_type": row.get("agent_type", ""), + "steps": 0, + "duration_ms": 0.0, + "parallel_groups": set(), + }, + ) + item["steps"] += 1 + try: + item["duration_ms"] += float(row.get("duration_ms") or 0.0) + except (TypeError, ValueError): + pass + if row.get("parallel_group"): + item["parallel_groups"].add(str(row["parallel_group"])) + + compact = [] + for item in by_role.values(): + parallel_groups = sorted(item.pop("parallel_groups")) + item["duration_ms"] = round(float(item["duration_ms"]), 3) + if parallel_groups: + item["parallel_groups"] = parallel_groups + compact.append(item) + return sorted(compact, key=lambda item: item["agent_role"]) + + +def _compact_service_workflow_result(result: dict[str, Any]) -> dict[str, Any]: + """Keep model-facing evidence bounded while preserving validation fields.""" + + if not isinstance(result, dict): + return { + "ok": False, + "phase": "invalid_result", + "error": "workflow returned a non-dict result", + } + + selection = result.get("selection") if isinstance( + result.get("selection"), dict) else {} + selected_range = selection.get("selected_range") if isinstance( + selection.get("selected_range"), dict) else {} + local_candidates = result.get("local_candidates") if isinstance( + result.get("local_candidates"), dict) else {} + peer_candidates = result.get("peer_candidates") if isinstance( + result.get("peer_candidates"), dict) else {} + local_create = result.get("local_create") if isinstance( + result.get("local_create"), dict) else {} + peer_create = result.get("peer_create") if isinstance( + result.get("peer_create"), dict) else {} + + compact = { + "ok": result.get("ok", False), + "phase": result.get("phase", ""), + "error": result.get("error", ""), + "request_id": result.get("request_id", result.get("service_uuid", "")), + "requested_source_device": result.get( + "requested_source_device", + result.get("source_device", ""), + ), + "requested_destination_device": result.get( + "requested_destination_device", + result.get("destination_device", ""), + ), + "source_device": result.get("source_device", ""), + "destination_device": result.get("destination_device", ""), + "local_domain": result.get("local_domain", ""), + "peer_domain": result.get("peer_domain", ""), + "destination_domain": result.get("destination_domain", ""), + "domain_path": result.get("domain_path", []), + "endpoint_resolution": _compact_endpoint_resolution( + result.get("endpoint_resolution") + ), + "preferred_band": result.get("preferred_band", ""), + "requested_preferred_band": result.get("requested_preferred_band", ""), + "requested_minimum_slot": result.get("requested_minimum_slot", ""), + "selection": ( + {"selected_range": selected_range} + if selected_range + else selection + ), + "spectrum_sizing": { + key: value + for key, value in (result.get("spectrum_sizing") or {}).items() + if key in { + "ok", + "capacity_gbps", + "modulation_format", + "channel_width_ghz", + "slot_width_ghz", + "required_slots", + "tfs_optical_band_width_ghz", + "source", + } + }, + "spectrum": { + "preferred_band": result.get("preferred_band", ""), + "selected_range": selected_range, + "local_candidate_ranges": _compact_ranges( + local_candidates.get("candidate_ranges") + ), + "peer_candidate_ranges": _compact_ranges( + peer_candidates.get("candidate_ranges") + ), + "local_blocked_ranges": _compact_ranges( + local_candidates.get("blocked_ranges") + ), + "peer_blocked_ranges": _compact_ranges( + peer_candidates.get("blocked_ranges") + ), + }, + "local_service_uuid": result.get( + "local_service_uuid", + result.get("service_uuid", ""), + ), + "peer_service_uuid": result.get("peer_service_uuid", ""), + "segment_service_uuids": result.get("segment_service_uuids", []), + "downstream_service_uuids": result.get("downstream_service_uuids", []), + "observed_service_status": _workflow_status(result), + "local_observed_service_status": ( + _workflow_status(local_create) if local_create else "" + ), + "peer_observed_service_status": ( + _workflow_status(peer_create) if peer_create else "" + ), + "workflow_trace": result.get("workflow_trace", []), + "workflow_agents": _compact_workflow_agents( + result.get("workflow_trace") + ), + "peer_workflow_trace": result.get("peer_workflow_trace", []), + "local_workflow_trace": result.get("local_workflow_trace", []), + "delegated_workflow_trace": result.get("delegated_workflow_trace", []), + "model_facing_result": "compact", + } + return { + key: value + for key, value in compact.items() + if value not in ("", [], {}, None) + } + + +async def list_domain_inventory( + scope: str, + resource_kind: str, + detail_level: str = "summary", +) -> dict: + """List MD-NAF inventory in local, remote, or all domains.""" + + return await _list_domain_inventory( + scope=scope, + resource_kind=resource_kind, + detail_level=detail_level, + ) + + +async def locate_device(device_name: str) -> dict: + """Locate the MD-NAF domain containing a device or endpoint-like device.""" + + return await _locate_device(device_name=device_name) + + +async def list_device_connections(device_name: str, scope: str = "all") -> dict: + """List direct connectivity for a device.""" + + return await _list_device_connections(device_name=device_name, scope=scope) + + +async def list_mdnaf_domains() -> dict: + """Summarize configured MD-NAF domains and peer agents.""" + + return await _list_mdnaf_domains() + + +async def request_optical_connectivity( + source_device: str, + destination_device: str, + channel_width_ghz: float, + minimum_slot: int = 0, + preferred_band: str = "", + service_name: str = "", + directionality: str = "unidirectional", +) -> dict: + """Create and verify an optical connectivity request. + + This is the normalized model-facing entry point. The deterministic service + workflow resolves local versus cross-domain routing and decomposes + bidirectional intent outside the LLM. + """ + + if directionality.strip().lower() not in { + "", + "unidirectional", + "single", + "one-way", + }: + return { + "ok": False, + "phase": "unsupported_directionality", + "error": ( + "only unidirectional optical connectivity is currently " + "exposed through this tool" + ), + "requested_directionality": directionality, + } + + result = await _create_cross_domain_optical_service( + source_device=source_device, + destination_device=destination_device, + channel_width_ghz=channel_width_ghz, + minimum_slot=minimum_slot, + preferred_band=preferred_band, + service_name=service_name, + ) + if result.get("phase") != "not_cross_domain": + return _compact_service_workflow_result(result) + + local_result = await _create_optical_connectivity_service( + source_device=source_device, + destination_device=destination_device, + channel_width_ghz=channel_width_ghz, + preferred_band=preferred_band, + service_name=service_name, + ) + return _compact_service_workflow_result(local_result) + + +async def delete_cross_domain_optical_service( + request_id: str, + peer_domain: str = "", +) -> dict: + """Delete a cross-domain optical service by request ID. + + peer_domain is optional; when omitted, the deterministic workflow searches + all configured peer domains for matching segment services. + """ + + return await _delete_cross_domain_optical_service( + request_id=request_id, + peer_domain=peer_domain, + ) + + +async def delete_service(service_uuid: str) -> dict: + """Delete a local TFS service by UUID or name.""" + + return await _delete_service(service_uuid=service_uuid) + + +list_domain_inventory_tool = FunctionTool(list_domain_inventory) +locate_device_tool = FunctionTool(locate_device) +list_device_connections_tool = FunctionTool(list_device_connections) +list_mdnaf_domains_tool = FunctionTool(list_mdnaf_domains) +request_optical_connectivity_tool = FunctionTool( + request_optical_connectivity +) +delete_cross_domain_optical_service_tool = FunctionTool( + delete_cross_domain_optical_service) +delete_service_tool = FunctionTool(delete_service) + +INVENTORY_WORKFLOW_TOOLS = [ + list_domain_inventory_tool, + locate_device_tool, + list_device_connections_tool, + list_mdnaf_domains_tool, +] + +OPTICAL_CREATE_WORKFLOW_TOOLS = [ + request_optical_connectivity_tool, +] + +OPTICAL_DELETE_WORKFLOW_TOOLS = [ + delete_cross_domain_optical_service_tool, + delete_service_tool, +] + + +def _selected_workflow_tools() -> list[FunctionTool]: + if TOOL_PROFILE == "inventory": + return INVENTORY_WORKFLOW_TOOLS + if TOOL_PROFILE == "create": + return INVENTORY_WORKFLOW_TOOLS + OPTICAL_CREATE_WORKFLOW_TOOLS + if TOOL_PROFILE == "delete": + return INVENTORY_WORKFLOW_TOOLS + OPTICAL_DELETE_WORKFLOW_TOOLS + return ( + INVENTORY_WORKFLOW_TOOLS + + OPTICAL_CREATE_WORKFLOW_TOOLS + + OPTICAL_DELETE_WORKFLOW_TOOLS + ) + + +SERVICE_WORKFLOW_TOOLS = _selected_workflow_tools() diff --git a/src/agentic/service/tools/service_roles.py b/src/agentic/service/tools/service_roles.py new file mode 100644 index 000000000..0ea8fa739 --- /dev/null +++ b/src/agentic/service/tools/service_roles.py @@ -0,0 +1,133 @@ +# 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. + +"""Bounded service-agent role metadata for deterministic workflow traces.""" + +from __future__ import annotations + +from dataclasses import dataclass + + +@dataclass(frozen=True) +class ServiceAgentRole: + """Describes the bounded agent role responsible for a workflow step.""" + + name: str + agent_type: str + + +COORDINATOR = ServiceAgentRole( + "coordinator_agent", "llm_supervised" +) +INTENT_PARSER = ServiceAgentRole( + "intent_parser_agent", "llm_supervised" +) +LOCAL_INVENTORY = ServiceAgentRole( + "local_inventory_agent", "deterministic" +) +ENDPOINT_RESOLVER = ServiceAgentRole( + "endpoint_resolver_agent", "deterministic" +) +ADJACENCY = ServiceAgentRole( + "adjacency_agent", "deterministic" +) +PATH_ENUMERATOR = ServiceAgentRole( + "path_enumerator_agent", "deterministic" +) +SPECTRUM_CALCULATOR = ServiceAgentRole( + "spectrum_calculator_agent", "deterministic" +) +NEGOTIATION_POLICY = ServiceAgentRole( + "negotiation_policy_agent", "deterministic_policy" +) +RESERVATION = ServiceAgentRole( + "reservation_agent", "deterministic" +) +PROVISIONING = ServiceAgentRole( + "provisioning_agent", "deterministic" +) +VERIFICATION = ServiceAgentRole( + "verification_agent", "deterministic" +) +RECOVERY = ServiceAgentRole( + "recovery_agent", "deterministic" +) + + +def controller_domain_from_actor(actor: str) -> str: + """Extract a controller domain label from trace actors such as domain_A.""" + + if actor.startswith("domain_") and len(actor) > len("domain_"): + return actor.split("domain_", 1)[1].upper() + if actor in {"domain_local", "local_mcp"}: + return "local" + return "" + + +def role_for_step(phase: str, actor: str, operation: str) -> ServiceAgentRole: + """Map low-level workflow trace fields to bounded MD-NAF agent roles.""" + + normalized = operation.lower() + if "normalize" in normalized or "size_spectrum" in normalized: + return INTENT_PARSER + if actor == "endpoint_resolver_agent" or "resolve_" in normalized: + return ENDPOINT_RESOLVER + if "inspect_adjacency" in normalized: + return ADJACENCY + if "select_next_hop" in normalized: + return PATH_ENUMERATOR + if "candidate" in normalized or "slot" in normalized: + return SPECTRUM_CALCULATOR + if "intersect" in normalized or "select_spectrum" in normalized: + return NEGOTIATION_POLICY + if any( + token in normalized + for token in ( + "reservation", + "reserve", + "consume_", + "commit_spectrum", + "release_spectrum", + ) + ): + return RESERVATION + if ( + phase == "rollback" + or "rollback" in normalized + or "after_" in normalized + ): + return RECOVERY + if "delete" in normalized: + return RECOVERY if phase == "rollback" else PROVISIONING + if any( + token in normalized + for token in ( + "provision", + "create_service", + "update_service", + "tfs_create", + "tfs_update", + ) + ): + return PROVISIONING + if any( + token in normalized + for token in ("verify", "get_service", "active", "allocation") + ): + return VERIFICATION + if "list_devices" in normalized or "inventory" in normalized: + return LOCAL_INVENTORY + if actor == "domain_agent": + return COORDINATOR + return COORDINATOR diff --git a/src/agentic/service/tools/service_workflow.py b/src/agentic/service/tools/service_workflow.py new file mode 100644 index 000000000..1f998d747 --- /dev/null +++ b/src/agentic/service/tools/service_workflow.py @@ -0,0 +1,3732 @@ +# 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. + +"""Deterministic service workflow tools for the TFS-Agentic ADK runtime.""" + +from __future__ import annotations + +import asyncio +import json +import re +import time +import uuid +from typing import Any, Dict, List + +from agentic.Config import get_tfs_default_context_uuid, get_tfs_default_topology_uuid +from agentic.service import spectrum +from agentic.service.peer_client import find_endpoint_owner, parse_peers, peer_device_inventory, peer_get, peer_post +from agentic.service.session_store import find_events_by_request_id, record_event +from agentic.service.settings import DOMAIN_ID +from agentic.service.tools.mcp_client import call_mcp_tool +from agentic.service.tools.service_roles import controller_domain_from_actor, role_for_step + + +DEFAULT_CONTEXT_UUID = get_tfs_default_context_uuid() +DEFAULT_TOPOLOGY_UUID = get_tfs_default_topology_uuid() + + +class WorkflowTrace: + """Collect monotonic workflow phase timings in milliseconds.""" + + def __init__(self) -> None: + self.started = time.perf_counter() + self.rows: List[Dict[str, Any]] = [] + + async def timed( + self, + phase: str, + actor: str, + operation: str, + awaitable: Any, + parallel_group: str = "", + input_payload: Any = None, + ) -> Any: + start = time.perf_counter() + status = "ok" + result: Any = None + error = "" + try: + result = await awaitable + if isinstance(result, dict) and not result.get("ok", True): + status = "failed" + return result + except Exception as exc: + status = "exception" + error = f"{type(exc).__name__}: {exc}" + raise + finally: + end = time.perf_counter() + row = { + "phase": phase, + "actor": actor, + "operation": operation, + "start_ms": round((start - self.started) * 1000.0, 3), + "duration_ms": round((end - start) * 1000.0, 3), + "status": status, + } + _add_profile_metadata(row, input_payload=input_payload, output_payload=result, error=error) + row.update(_agent_metadata(phase, actor, operation)) + if parallel_group: + row["parallel_group"] = parallel_group + self.rows.append(row) + + def mark( + self, + phase: str, + actor: str, + operation: str, + status: str = "ok", + start: float | None = None, + parallel_group: str = "", + input_payload: Any = None, + output_payload: Any = None, + error: str = "", + ) -> None: + end = time.perf_counter() + phase_start = start if start is not None else end + row = { + "phase": phase, + "actor": actor, + "operation": operation, + "start_ms": round((phase_start - self.started) * 1000.0, 3), + "duration_ms": round((end - phase_start) * 1000.0, 3), + "status": status, + } + _add_profile_metadata(row, input_payload=input_payload, output_payload=output_payload, error=error) + row.update(_agent_metadata(phase, actor, operation)) + if parallel_group: + row["parallel_group"] = parallel_group + self.rows.append(row) + + def export(self) -> List[Dict[str, Any]]: + return list(self.rows) + + +def _add_profile_metadata( + row: Dict[str, Any], + input_payload: Any = None, + output_payload: Any = None, + error: str = "", +) -> None: + if input_payload is not None: + row["input_bytes"] = _payload_size_bytes(input_payload) + if output_payload is not None: + row["output_bytes"] = _payload_size_bytes(output_payload) + if error: + row["error"] = error[:500] + + +def _payload_size_bytes(payload: Any) -> int: + try: + encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + except (TypeError, ValueError): + encoded = str(payload).encode("utf-8") + return len(encoded) + + +def _agent_metadata(phase: str, actor: str, operation: str) -> Dict[str, str]: + role = role_for_step(phase, actor, operation) + metadata = { + "agent_role": role.name, + "agent_type": role.agent_type, + } + controller_domain = controller_domain_from_actor(actor) + if controller_domain: + metadata["controller_domain"] = controller_domain + return metadata + + +def _endpoint_id(device_uuid: str, endpoint_uuid: str) -> Dict[str, Any]: + return { + "device_id": {"device_uuid": {"uuid": device_uuid}}, + "endpoint_uuid": {"uuid": endpoint_uuid}, + } + + +def _device_uuid(device: Dict[str, Any]) -> str: + return str(device.get("device_id", {}).get("device_uuid", {}).get("uuid", "")) + + +def _device_name(device: Dict[str, Any]) -> str: + return str(device.get("name") or _device_uuid(device)) + + +def _endpoint_name(endpoint: Dict[str, Any]) -> str: + return str(endpoint.get("name") or endpoint.get("endpoint_id", {}).get("endpoint_uuid", {}).get("uuid", "")) + + +def _resolve_device_uuid_from_details(devices: List[Dict[str, Any]], device_name_or_uuid: str) -> str: + target = str(device_name_or_uuid).strip() + for device in devices: + if target in {_device_uuid(device), _device_name(device)}: + return _device_uuid(device) + return target + + +def _resolve_endpoint_uuid_from_details( + devices: List[Dict[str, Any]], device_uuid: str, preferred_endpoint_name: str +) -> str: + for device in devices: + if _device_uuid(device) != device_uuid: + continue + endpoints = device.get("device_endpoints", []) + for endpoint in endpoints: + endpoint_uuid = str(endpoint.get("endpoint_id", {}).get("endpoint_uuid", {}).get("uuid", "")) + if preferred_endpoint_name in {endpoint_uuid, _endpoint_name(endpoint)}: + return endpoint_uuid + if endpoints: + return str(endpoints[0].get("endpoint_id", {}).get("endpoint_uuid", {}).get("uuid", preferred_endpoint_name)) + return preferred_endpoint_name + + +async def _default_context_and_topology() -> Dict[str, str]: + contexts_reply = await call_mcp_tool("tfs_list_contexts", {}) + contexts = contexts_reply.get("contexts", []) if isinstance(contexts_reply, dict) else [] + preferred_context = (DEFAULT_CONTEXT_UUID or "admin").strip() + preferred_topology = (DEFAULT_TOPOLOGY_UUID or "admin").strip() + for context in contexts: + context_uuid = str(context.get("context_id", {}).get("context_uuid", {}).get("uuid", "")) + context_name = str(context.get("name", "")) + if preferred_context not in {context_uuid, context_name}: + continue + for topology_id in context.get("topology_ids", []): + topology_uuid = str(topology_id.get("topology_uuid", {}).get("uuid", "")) + if preferred_topology in {topology_uuid, "admin"}: + return {"context_uuid": context_uuid, "topology_uuid": topology_uuid} + if contexts: + context = contexts[0] + context_uuid = str(context.get("context_id", {}).get("context_uuid", {}).get("uuid", preferred_context)) + topology_ids = context.get("topology_ids", []) + if topology_ids: + topology_uuid = str(topology_ids[0].get("topology_uuid", {}).get("uuid", preferred_topology)) + return {"context_uuid": context_uuid, "topology_uuid": topology_uuid} + return {"context_uuid": preferred_context, "topology_uuid": preferred_topology} + + +def _service_id(context_uuid: str, service_uuid: str) -> Dict[str, Any]: + return { + "context_id": {"context_uuid": {"uuid": context_uuid}}, + "service_uuid": {"uuid": service_uuid}, + } + + +def _constraint(constraint_type: str, constraint_value: str) -> Dict[str, Any]: + return { + "action": 1, + "custom": { + "constraint_type": constraint_type, + "constraint_value": constraint_value, + }, + } + + +def _service_status(payload: Dict[str, Any]) -> str: + status = payload.get("service_status", {}) if isinstance(payload, dict) else {} + raw = status.get("service_status") if isinstance(status, dict) else status + if raw in (2, "2", "SERVICESTATUS_ACTIVE", "ACTIVE"): + return "ACTIVE" + if raw in (1, "1", "SERVICESTATUS_PLANNED", "PLANNED"): + return "PLANNED" + if raw in (3, "3", "SERVICESTATUS_UPDATING", "UPDATING"): + return "UPDATING" + if raw in (4, "4", "SERVICESTATUS_PENDING_REMOVAL", "PENDING_REMOVAL"): + return "PENDING_REMOVAL" + if raw in (5, "5", "SERVICESTATUS_SLA_VIOLATED", "SLA_VIOLATED"): + return "SLA_VIOLATED" + return str(raw or "UNKNOWN") + + +def _device_names(payload: Dict[str, Any]) -> List[str]: + devices = payload.get("devices", []) if isinstance(payload, dict) else [] + return [str(item.get("name") or "") for item in devices if isinstance(item, dict) and item.get("name")] + + +def _optical_endpoint_candidates(device_name: str) -> List[str]: + target = device_name.strip() + upper = target.upper() + if "-TP" in upper or upper.startswith("DOMAIN-") or upper == "REMOTE-NET": + return [target] + return [f"{target}-TP{index}" for index in range(1, 5)] + + +async def _resolve_optical_endpoint_device( + requested_device: str, + local_devices: List[Dict[str, Any]], +) -> Dict[str, Any]: + requested_device = requested_device.strip() + checked_candidates = [] + for candidate in _optical_endpoint_candidates(requested_device): + owner = await find_endpoint_owner(candidate, local_devices) + checked_candidates.append({"candidate": candidate, "owner": owner}) + if owner.get("ok"): + return { + "ok": True, + "requested_device": requested_device, + "resolved_device": candidate, + "inferred": candidate != requested_device, + "inference_rule": "datacenter_to_transponder" if candidate != requested_device else "exact_optical_endpoint", + "owner": owner, + "checked_candidates": checked_candidates, + } + + exact_owner = await find_endpoint_owner(requested_device, local_devices) + return { + "ok": False, + "requested_device": requested_device, + "resolved_device": "", + "error": "optical_transponder_not_found", + "exact_owner": exact_owner, + "checked_candidates": checked_candidates, + } + + +def _tool_failed(payload: Any) -> bool: + if isinstance(payload, dict): + return bool(payload.get("error") or payload.get("status") == "error") + return False + + +def _default_endpoint_for_device(device_name: str, direction: str) -> str: + upper = device_name.upper() + if upper.startswith("DOMAIN-") or upper == "REMOTE-NET": + return "port-in" if direction == "in" else "port-out" + return "CHANNEL" + + +def _candidate_ranges_from_tfs(reply: Dict[str, Any]) -> List[Dict[str, int]]: + ranges: List[Dict[str, int]] = [] + seen: set[tuple[int, int]] = set() + for candidate in reply.get("candidates", []) if isinstance(reply, dict) else []: + if not isinstance(candidate, dict): + continue + candidate_ranges = candidate.get("available_slot_ranges") or [ + {"n_start": candidate.get("n_start"), "n_end": candidate.get("n_end")} + ] + for candidate_range in candidate_ranges: + if not isinstance(candidate_range, dict): + continue + try: + item = (int(candidate_range["n_start"]), int(candidate_range["n_end"])) + except (KeyError, TypeError, ValueError): + continue + if item in seen: + continue + seen.add(item) + ranges.append({"n_start": item[0], "n_end": item[1]}) + return ranges + + +def _candidate_for_selected_range(candidate_reply: Dict[str, Any], selected_range: Dict[str, int]) -> Dict[str, Any]: + """Return the TFS candidate whose common available range contains selected_range.""" + + try: + selected_start = int(selected_range["n_start"]) + selected_end = int(selected_range["n_end"]) + except (KeyError, TypeError, ValueError): + return {} + for candidate in candidate_reply.get("candidates", []) if isinstance(candidate_reply, dict) else []: + if not isinstance(candidate, dict): + continue + candidate_ranges = candidate.get("available_slot_ranges") or [ + {"n_start": candidate.get("n_start"), "n_end": candidate.get("n_end")} + ] + for candidate_range in candidate_ranges: + if not isinstance(candidate_range, dict): + continue + try: + range_start = int(candidate_range["n_start"]) + range_end = int(candidate_range["n_end"]) + except (KeyError, TypeError, ValueError): + continue + if range_start <= selected_start and selected_end <= range_end: + return candidate + return {} + + +def _optical_link_ids_from_candidate(candidate: Dict[str, Any]) -> List[Dict[str, Any]]: + link_ids = [] + for link_uuid in candidate.get("optical_link_ids", []) if isinstance(candidate, dict) else []: + if isinstance(link_uuid, dict): + link_uuid = link_uuid.get("link_uuid", {}).get("uuid", "") + link_uuid = str(link_uuid or "").strip() + if link_uuid: + link_ids.append({"link_uuid": {"uuid": link_uuid}}) + return link_ids + + +def _reservation_uuid(service_id: str, role: str) -> str: + safe_service = re.sub(r"[^A-Za-z0-9_.-]+", "-", service_id.strip())[:48].strip("-") + safe_role = re.sub(r"[^A-Za-z0-9_.-]+", "-", role.strip() or "segment")[:16].strip("-") + return f"{safe_service or 'service'}-{safe_role or 'segment'}-res-{uuid.uuid4().hex[:8]}" + + +def _reservation_uuid_from_create_reply(reply: Any, fallback: str) -> str: + if isinstance(reply, list) and reply: + first = reply[0] + if isinstance(first, dict): + value = first.get("reservation_uuid", {}) + if isinstance(value, dict) and value.get("uuid"): + return str(value["uuid"]) + if isinstance(reply, dict): + for path in ( + ["reservation_uuid", "uuid"], + ["reservation_id", "reservation_uuid", "uuid"], + ): + value: Any = reply + for key in path: + value = value.get(key, {}) if isinstance(value, dict) else {} + if value: + return str(value) + return fallback + + +async def create_tfs_spectrum_reservation( + service_id: str, + selected_range: Dict[str, int], + candidate_bundle: Dict[str, Any], + metadata: Dict[str, Any] | None = None, +) -> Dict[str, Any]: + """Create a controller-authoritative spectrum hold for the selected segment.""" + + metadata = metadata or {} + context_uuid = str(candidate_bundle.get("context_uuid") or DEFAULT_CONTEXT_UUID) + topology_uuid = str(candidate_bundle.get("topology_uuid") or DEFAULT_TOPOLOGY_UUID) + candidate_reply = candidate_bundle.get("tfs_candidate_reply", candidate_bundle) + candidate = _candidate_for_selected_range(candidate_reply, selected_range) + if not candidate: + return { + "ok": False, + "error": "selected_range_not_in_tfs_candidate", + "service_id": service_id, + "selected_range": selected_range, + } + optical_link_ids = _optical_link_ids_from_candidate(candidate) + if not optical_link_ids: + return { + "ok": False, + "error": "candidate_missing_optical_links", + "service_id": service_id, + "selected_range": selected_range, + "candidate": candidate, + } + try: + n_start = int(selected_range["n_start"]) + n_end = int(selected_range["n_end"]) + except (KeyError, TypeError, ValueError): + return {"ok": False, "error": "invalid_selected_range", "selected_range": selected_range} + requested_reservation_uuid = _reservation_uuid(service_id, str(metadata.get("role", "segment"))) + reservation = { + "reservation_id": { + "context_id": {"context_uuid": {"uuid": context_uuid}}, + "reservation_uuid": {"uuid": requested_reservation_uuid}, + }, + "topology_id": { + "context_id": {"context_uuid": {"uuid": context_uuid}}, + "topology_uuid": {"uuid": topology_uuid}, + }, + "optical_link_ids": optical_link_ids, + "band": str(candidate.get("band") or "c_slots"), + "n_start": n_start, + "n_end": n_end, + "required_slots": n_end - n_start + 1, + "owner_id": f"md-naf-domain-{DOMAIN_ID}", + "correlation_id": str(metadata.get("correlation_id") or service_id), + "status": 1, + } + reply = await call_mcp_tool( + "tfs_create_optical_spectrum_reservation", + {"context_uuid": context_uuid, "reservation": reservation}, + ) + if _tool_failed(reply): + return { + "ok": False, + "operation": "reserve", + "reservation_id": requested_reservation_uuid, + "requested_reservation_id": requested_reservation_uuid, + "service_id": service_id, + "domain_id": DOMAIN_ID, + "selected_range": {"n_start": n_start, "n_end": n_end}, + "reservation": reservation, + "reply": reply, + } + canonical_reservation_uuid = _reservation_uuid_from_create_reply(reply, requested_reservation_uuid) + return { + "ok": True, + "operation": "reserve", + "reservation_id": canonical_reservation_uuid, + "requested_reservation_id": requested_reservation_uuid, + "service_id": service_id, + "domain_id": DOMAIN_ID, + "context_uuid": context_uuid, + "topology_uuid": topology_uuid, + "selected_range": {"n_start": n_start, "n_end": n_end}, + "band": reservation["band"], + "optical_link_ids": [item["link_uuid"]["uuid"] for item in optical_link_ids], + "status": "reserved", + "reservation": reservation, + "reply": reply, + } + + +async def consume_tfs_spectrum_reservation( + reservation_id: str, + service_id: str, + context_uuid: str = DEFAULT_CONTEXT_UUID, +) -> Dict[str, Any]: + reservation = await call_mcp_tool( + "tfs_get_optical_spectrum_reservation", + {"context_uuid": context_uuid, "reservation_uuid": reservation_id}, + ) + service = await call_mcp_tool( + "tfs_get_service", + {"context_uuid": context_uuid, "service_uuid": service_id}, + ) + canonical_service_uuid = ( + service.get("service_id", {}).get("service_uuid", {}).get("uuid", service_id) + if isinstance(service, dict) + else service_id + ) + canonical_context_uuid = ( + service.get("service_id", {}).get("context_id", {}).get("context_uuid", {}).get("uuid", context_uuid) + if isinstance(service, dict) + else context_uuid + ) + payload = dict(reservation) if isinstance(reservation, dict) else {} + payload["service_id"] = _service_id(canonical_context_uuid, canonical_service_uuid) + reply = await call_mcp_tool( + "tfs_consume_optical_spectrum_reservation", + {"context_uuid": context_uuid, "reservation_uuid": reservation_id, "reservation": payload}, + ) + return { + "ok": not _tool_failed(reply), + "operation": "consume", + "reservation_id": reservation_id, + "service_id": service_id, + "context_uuid": context_uuid, + "canonical_service_uuid": canonical_service_uuid, + "reservation_get": reservation, + "service_get": service, + "reply": reply, + } + + +async def release_tfs_spectrum_reservation( + reservation_id: str, + context_uuid: str = DEFAULT_CONTEXT_UUID, +) -> Dict[str, Any]: + reply = await call_mcp_tool( + "tfs_release_optical_spectrum_reservation", + {"context_uuid": context_uuid, "reservation_uuid": reservation_id}, + ) + return { + "ok": not _tool_failed(reply), + "operation": "release", + "reservation_id": reservation_id, + "context_uuid": context_uuid, + "reply": reply, + } + + +def _reservation_uuid_from_record(reservation: Dict[str, Any]) -> str: + return str( + reservation.get("reservation_id", {}) + .get("reservation_uuid", {}) + .get("uuid", "") + ) + + +def _reservation_service_uuid(reservation: Dict[str, Any]) -> str: + return str( + reservation.get("service_id", {}) + .get("service_uuid", {}) + .get("uuid", "") + ) + + +def _reservation_context_uuid(reservation: Dict[str, Any], fallback: str = DEFAULT_CONTEXT_UUID) -> str: + return str( + reservation.get("reservation_id", {}) + .get("context_id", {}) + .get("context_uuid", {}) + .get("uuid", fallback) + or fallback + ) + + +def _reservation_status(reservation: Dict[str, Any]) -> str: + return str(reservation.get("status", "")).strip().upper() + + +def _reservation_is_active(reservation: Dict[str, Any]) -> bool: + status = _reservation_status(reservation) + return status.endswith("_RESERVED") or status.endswith("_CONSUMED") or status in {"RESERVED", "CONSUMED"} + + +async def release_matching_tfs_spectrum_reservations( + correlation_ids: List[str] | None = None, + service_ids: List[str] | None = None, + context_uuid: str = DEFAULT_CONTEXT_UUID, +) -> Dict[str, Any]: + """Release controller reservations matching request correlation or service IDs.""" + + correlation_set = {str(item).strip() for item in (correlation_ids or []) if str(item).strip()} + service_set = {str(item).strip() for item in (service_ids or []) if str(item).strip()} + context_uuid = (context_uuid or DEFAULT_CONTEXT_UUID).strip() + reply = await call_mcp_tool("tfs_list_optical_spectrum_reservations", {"context_uuid": context_uuid}) + if _tool_failed(reply): + return { + "ok": False, + "operation": "release_matching", + "context_uuid": context_uuid, + "correlation_ids": sorted(correlation_set), + "service_ids": sorted(service_set), + "list_reply": reply, + "released": 0, + "matches": [], + "failures": [reply], + } + reservations = reply.get("reservations", []) if isinstance(reply, dict) else [] + matches: List[Dict[str, Any]] = [] + releases: List[Dict[str, Any]] = [] + failures: List[Dict[str, Any]] = [] + for reservation in reservations: + if not isinstance(reservation, dict): + continue + reservation_uuid = _reservation_uuid_from_record(reservation) + reservation_service_uuid = _reservation_service_uuid(reservation) + correlation_id = str(reservation.get("correlation_id", "")).strip() + matched_by = [] + if correlation_id and correlation_id in correlation_set: + matched_by.append("correlation_id") + if reservation_service_uuid and reservation_service_uuid in service_set: + matched_by.append("service_id") + if reservation_uuid and reservation_uuid in service_set: + matched_by.append("reservation_id") + if not matched_by: + continue + match = { + "reservation_id": reservation_uuid, + "service_id": reservation_service_uuid, + "correlation_id": correlation_id, + "status": reservation.get("status", ""), + "matched_by": matched_by, + } + matches.append(match) + if not reservation_uuid or not _reservation_is_active(reservation): + continue + release_context_uuid = _reservation_context_uuid(reservation, context_uuid) + release = await release_tfs_spectrum_reservation(reservation_uuid, release_context_uuid) + releases.append({**match, "release": release}) + if not release.get("ok"): + failures.append({**match, "release": release}) + return { + "ok": not failures, + "operation": "release_matching", + "context_uuid": context_uuid, + "correlation_ids": sorted(correlation_set), + "service_ids": sorted(service_set), + "matches": matches, + "releases": releases, + "released": len([item for item in releases if item.get("release", {}).get("ok")]), + "failures": failures, + } + + +async def verify_tfs_service_allocation( + service_id: str, + band: str, + selected_range: Dict[str, int], + context_uuid: str = DEFAULT_CONTEXT_UUID, +) -> Dict[str, Any]: + reply = await call_mcp_tool( + "tfs_verify_optical_service_allocation", + { + "context_uuid": context_uuid, + "service_uuid": service_id, + "expected_band": band, + "expected_n_start": int(selected_range["n_start"]), + "expected_n_end": int(selected_range["n_end"]), + }, + ) + return { + "ok": bool(reply.get("ok")) and not _tool_failed(reply), + "operation": "verify_allocation", + "service_id": service_id, + "context_uuid": context_uuid, + "selected_range": selected_range, + "band": band, + "reply": reply, + } + + +def _candidate_modulation_formats(candidate_reply: Dict[str, Any]) -> set[str]: + if not isinstance(candidate_reply, dict): + return set() + modulations: set[str] = set() + summary_modulation = candidate_reply.get("request_summary", {}).get("requested_modulation_format") + if summary_modulation: + modulations.add(str(summary_modulation).strip().lower()) + for candidate in candidate_reply.get("candidates", []): + if not isinstance(candidate, dict): + continue + modulation = candidate.get("modulation_format") + if modulation: + modulations.add(str(modulation).strip().lower()) + return {item for item in modulations if item} + + +def _tfs_candidate_required_slots(candidate_reply: Dict[str, Any]) -> int | None: + if not isinstance(candidate_reply, dict): + return None + for value in (candidate_reply.get("required_slots"),): + if value is None: + continue + try: + slots = int(value) + except (TypeError, ValueError): + continue + if slots > 0: + return slots + for candidate in candidate_reply.get("candidates", []): + if not isinstance(candidate, dict): + continue + try: + slots = int(candidate["required_slots"]) + except (KeyError, TypeError, ValueError): + continue + if slots > 0: + return slots + return None + + +def _tfs_candidate_slot_width_ghz(candidate_reply: Dict[str, Any], required_slots: int) -> float | None: + if required_slots <= 0 or not isinstance(candidate_reply, dict): + return None + try: + width = float(candidate_reply.get("effective_channel_width_ghz")) + except (TypeError, ValueError): + return None + if width <= 0: + return None + return width / required_slots + + +def _controller_sizing_from_candidates( + sizing: Dict[str, Any], + local_candidates: Dict[str, Any], + peer_candidates: Dict[str, Any], +) -> Dict[str, Any]: + """Use TFS candidate sizing as authoritative once the controller returns it.""" + + local_reply = local_candidates.get("tfs_candidate_reply", {}) if isinstance(local_candidates, dict) else {} + peer_reply = peer_candidates.get("tfs_candidate_reply", {}) if isinstance(peer_candidates, dict) else {} + modulation_formats = _candidate_modulation_formats(local_reply) | _candidate_modulation_formats(peer_reply) + requested_modulation = str(sizing.get("modulation_format") or "").strip().lower() + if requested_modulation: + modulation_formats.add(requested_modulation) + if len(modulation_formats) > 1: + return { + **sizing, + "ok": False, + "error": "cross_domain_modulation_mismatch", + "modulation_formats": sorted(modulation_formats), + } + local_slots = _tfs_candidate_required_slots(local_reply) + peer_slots = _tfs_candidate_required_slots(peer_reply) + if local_slots is None and peer_slots is None: + return dict(sizing) + if local_slots is not None and peer_slots is not None and local_slots != peer_slots: + return { + **sizing, + "ok": False, + "error": "tfs_candidate_required_slot_mismatch", + "local_required_slots": local_slots, + "peer_required_slots": peer_slots, + } + + required_slots = int(local_slots if local_slots is not None else peer_slots) + slot_width = ( + _tfs_candidate_slot_width_ghz(local_reply, required_slots) + or _tfs_candidate_slot_width_ghz(peer_reply, required_slots) + or sizing.get("slot_width_ghz") + ) + return { + **sizing, + "required_slots": required_slots, + "slot_width_ghz": slot_width, + "source": "tfs_optical_connectivity_candidates", + "agentic_preliminary_required_slots": sizing.get("required_slots"), + } + + +async def compute_tfs_optical_connectivity_candidates( + source_device: str, + destination_device: str, + channel_width_ghz: float | None = None, + capacity_gbps: float | None = None, + modulation_format: str = "", + preferred_band: str = "c_slots", + max_candidates: int = 8, +) -> Dict[str, Any]: + """Ask local TFS optical controller for path/spectrum candidates through MCP.""" + + ids = await _default_context_and_topology() + details = await call_mcp_tool("tfs_get_topology_details", ids) + devices = details.get("devices", []) if isinstance(details, dict) else [] + src_device_uuid = _resolve_device_uuid_from_details(devices, source_device) + dst_device_uuid = _resolve_device_uuid_from_details(devices, destination_device) + src_endpoint_uuid = _resolve_endpoint_uuid_from_details( + devices, src_device_uuid, _default_endpoint_for_device(source_device, "out") + ) + dst_endpoint_uuid = _resolve_endpoint_uuid_from_details( + devices, dst_device_uuid, _default_endpoint_for_device(destination_device, "in") + ) + request: Dict[str, Any] = { + "src_endpoint_id": _endpoint_id(src_device_uuid, src_endpoint_uuid), + "dst_endpoint_id": _endpoint_id(dst_device_uuid, dst_endpoint_uuid), + "preferred_band": "c_slots" if not preferred_band or "-" in preferred_band else preferred_band, + "max_candidates": int(max_candidates), + } + if channel_width_ghz is not None: + request["channel_width_ghz"] = float(channel_width_ghz) + if capacity_gbps is not None: + request["capacity_gbps"] = float(capacity_gbps) + if modulation_format: + request["modulation_format"] = modulation_format + if preferred_band and re.fullmatch(r"\d+\s*-\s*\d+", preferred_band.strip()): + start, end = [int(item.strip()) for item in preferred_band.split("-", 1)] + request["preferred_n_start"] = start + request["preferred_n_end"] = end + reply = await call_mcp_tool("tfs_compute_optical_connectivity_candidates", { + "context_uuid": ids["context_uuid"], + "topology_uuid": ids["topology_uuid"], + "request": request, + }) + candidate_ranges = _candidate_ranges_from_tfs(reply) + return { + "ok": bool(candidate_ranges), + "domain_id": DOMAIN_ID, + "source_device": source_device, + "destination_device": destination_device, + "context_uuid": ids["context_uuid"], + "topology_uuid": ids["topology_uuid"], + "candidate_ranges": candidate_ranges, + "tfs_candidate_reply": reply, + } + + +def _resolve_spectrum_sizing( + capacity_gbps: float | None = None, + modulation_format: str = "", + channel_width_ghz: float | None = None, +) -> Dict[str, Any]: + return spectrum.spectrum_request(capacity_gbps, modulation_format, channel_width_ghz) + + +def _parse_ghz_value(value: Any) -> float | None: + if value is None: + return None + if isinstance(value, (int, float)): + return float(value) + text = str(value).strip().lower() + match = re.fullmatch(r"(\d+(?:\.\d+)?)\s*(?:g|ghz|gigahertz)", text) + if match: + return float(match.group(1)) + return None + + +def _normalize_spectrum_inputs( + preferred_band: str = "", + channel_width_ghz: float | None = None, +) -> tuple[str, float | None, Dict[str, Any]]: + preferred_band = (preferred_band or "").strip() + normalized = { + "original_preferred_band": preferred_band, + "original_channel_width_ghz": channel_width_ghz, + "coerced_channel_width_from_preferred_band": False, + } + if channel_width_ghz is None: + width = _parse_ghz_value(preferred_band) + if width is not None: + channel_width_ghz = width + preferred_band = "" + normalized["coerced_channel_width_from_preferred_band"] = True + return preferred_band, channel_width_ghz, normalized + + +async def create_optical_connectivity_service( + source_device: str, + destination_device: str, + service_name: str = "", + source_endpoint: str = "", + destination_endpoint: str = "", + preferred_band: str = "", + capacity_gbps: float | None = None, + modulation_format: str = "", + channel_width_ghz: float | None = None, + required_slots_override: int | None = None, + slot_width_ghz_override: float | None = None, + context_uuid: str = DEFAULT_CONTEXT_UUID, + poll_attempts: int = 30, + poll_interval_seconds: float = 2.0, +) -> Dict[str, Any]: + """Create one unidirectional optical connectivity service and verify ACTIVE.""" + + trace = WorkflowTrace() + trace = WorkflowTrace() + source_device = source_device.strip() + destination_device = destination_device.strip() + context_uuid = (context_uuid or DEFAULT_CONTEXT_UUID).strip() + service_name = (service_name or f"adk-opt-{uuid.uuid4().hex[:10]}").strip() + source_endpoint = (source_endpoint or _default_endpoint_for_device(source_device, "out")).strip() + destination_endpoint = (destination_endpoint or _default_endpoint_for_device(destination_device, "in")).strip() + poll_attempts = max(1, min(int(poll_attempts or 30), 60)) + poll_interval_seconds = max(0.2, min(float(poll_interval_seconds or 2.0), 10.0)) + planning_started = time.perf_counter() + preferred_band, channel_width_ghz, normalized_spectrum_inputs = _normalize_spectrum_inputs( + preferred_band, + channel_width_ghz, + ) + sizing = _resolve_spectrum_sizing(capacity_gbps, modulation_format, channel_width_ghz) + trace.mark("planning", "local_agent", "normalize_and_size_spectrum", "ok" if sizing.get("ok") else "failed", planning_started) + if not sizing.get("ok"): + return { + "ok": False, + "phase": "spectrum_sizing", + "spectrum_sizing": sizing, + "normalized_spectrum_inputs": normalized_spectrum_inputs, + "workflow_trace": trace.export(), + } + if required_slots_override is not None: + sizing = { + **sizing, + "required_slots": max(1, int(required_slots_override)), + "source": "tfs_optical_connectivity_candidates", + "agentic_preliminary_required_slots": sizing.get("required_slots"), + } + if slot_width_ghz_override is not None: + sizing = {**sizing, "slot_width_ghz": float(slot_width_ghz_override)} + + inventory = await trace.timed("planning", "local_mcp", "tfs_list_devices", call_mcp_tool("tfs_list_devices", {})) + names = set(_device_names(inventory)) + missing = [name for name in (source_device, destination_device) if name not in names] + if missing: + return { + "ok": False, + "phase": "pre_validation", + "error": "device_not_found", + "missing_devices": missing, + "available_devices": sorted(names), + "workflow_trace": trace.export(), + } + + sid = _service_id(context_uuid, service_name) + service_shell = {"service_id": sid, "name": service_name, "service_type": 6} + constraints = [ + _constraint("type", "flexi_grid"), + _constraint("bidirectionality", "0"), + _constraint("optical-band-width[GHz]", str(sizing["tfs_optical_band_width_ghz"])), + _constraint("spectrum-slot-width[GHz]", str(sizing["slot_width_ghz"])), + _constraint("spectrum-slots", str(sizing["required_slots"])), + ] + if sizing.get("capacity_gbps") is not None: + constraints.append(_constraint("bandwidth[gbps]", str(sizing["capacity_gbps"]))) + if sizing.get("modulation_format"): + constraints.append(_constraint("modulation-format", str(sizing["modulation_format"]))) + if preferred_band.strip(): + constraints.append(_constraint("preferred_band", preferred_band.strip())) + if re.fullmatch(r"\d+\s*-\s*\d+", preferred_band.strip()): + constraints.append(_constraint("optical-spectrum-reservation", f"c_slots:{preferred_band.strip()}")) + + service_update = { + "service_id": sid, + "name": service_name, + "service_type": 6, + "service_endpoint_ids": [ + _endpoint_id(source_device, source_endpoint), + _endpoint_id(destination_device, destination_endpoint), + ], + "service_constraints": constraints, + "service_config": {"config_rules": []}, + } + + create_result = await trace.timed( + "provisioning", + "local_mcp", + "tfs_create_services", + call_mcp_tool("tfs_create_services", {"context_uuid": context_uuid, "services": [service_shell]}), + ) + if _tool_failed(create_result): + return { + "ok": False, + "phase": "create", + "service_uuid": service_name, + "create_result": create_result, + "workflow_trace": trace.export(), + } + + update_result = await trace.timed( + "provisioning", + "local_mcp", + "tfs_update_service", + call_mcp_tool("tfs_update_service", {"context_uuid": context_uuid, "service_uuid": service_name, "service": service_update}), + ) + if _tool_failed(update_result): + delete_result = await trace.timed( + "rollback", + "local_mcp", + "tfs_delete_service", + call_mcp_tool("tfs_delete_service", {"context_uuid": context_uuid, "service_uuid": service_name}), + ) + return { + "ok": False, + "phase": "update", + "service_uuid": service_name, + "update_result": update_result, + "cleanup_result": delete_result, + "workflow_trace": trace.export(), + } + + observations = [] + for attempt in range(1, poll_attempts + 1): + if attempt > 1: + await asyncio.sleep(poll_interval_seconds) + service = await trace.timed( + "verification", + "local_mcp", + f"tfs_get_service_attempt_{attempt}", + call_mcp_tool("tfs_get_service", {"context_uuid": context_uuid, "service_uuid": service_name}), + ) + status = _service_status(service) + observations.append({"attempt": attempt, "status": status}) + if status == "ACTIVE": + allocation_verification: Dict[str, Any] | None = None + if re.fullmatch(r"\d+\s*-\s*\d+", preferred_band.strip()): + n_start, n_end = [int(item.strip()) for item in preferred_band.split("-", 1)] + allocation_verification = await trace.timed( + "verification", + "local_mcp", + "tfs_verify_optical_service_allocation", + verify_tfs_service_allocation( + service_name, + "c_slots", + {"n_start": n_start, "n_end": n_end}, + context_uuid, + ), + ) + if not allocation_verification.get("ok"): + delete_result = await trace.timed( + "rollback", + "local_mcp", + "tfs_delete_service", + call_mcp_tool("tfs_delete_service", {"context_uuid": context_uuid, "service_uuid": service_name}), + ) + return { + "ok": False, + "phase": "verify_allocation", + "service_uuid": service_name, + "context_uuid": context_uuid, + "allocation_verification": allocation_verification, + "rollback_result": delete_result, + "workflow_trace": trace.export(), + } + return { + "ok": True, + "phase": "active", + "service_uuid": service_name, + "context_uuid": context_uuid, + "source": f"{source_device}/{source_endpoint}", + "destination": f"{destination_device}/{destination_endpoint}", + "spectrum_sizing": sizing, + "normalized_spectrum_inputs": normalized_spectrum_inputs, + "observed_status": status, + "observations": observations, + "allocation_verification": allocation_verification, + "workflow_trace": trace.export(), + } + + delete_result = await trace.timed( + "rollback", + "local_mcp", + "tfs_delete_service", + call_mcp_tool("tfs_delete_service", {"context_uuid": context_uuid, "service_uuid": service_name}), + ) + return { + "ok": False, + "phase": "verify_active", + "service_uuid": service_name, + "observed_status": observations[-1]["status"] if observations else "UNKNOWN", + "observations": observations, + "rollback_result": delete_result, + "workflow_trace": trace.export(), + } + + +async def delete_service( + service_uuid: str, + context_uuid: str = DEFAULT_CONTEXT_UUID, + max_attempts: int = 3, + poll_interval_seconds: float = 2.0, + correlation_id: str = "", + candidate_service_ids: List[str] | None = None, + release_reservations: bool = True, +) -> Dict[str, Any]: + """Delete a TFS service and verify it disappears from inventory.""" + + trace = WorkflowTrace() + service_uuid = service_uuid.strip() + context_uuid = (context_uuid or DEFAULT_CONTEXT_UUID).strip() + max_attempts = max(1, min(int(max_attempts or 3), 5)) + poll_interval_seconds = max(0.2, min(float(poll_interval_seconds or 2.0), 10.0)) + attempts = [] + release_service_ids = {service_uuid, *(candidate_service_ids or [])} + existing_service = await trace.timed( + "deletion", + "local_mcp", + "tfs_get_service_before_delete", + call_mcp_tool("tfs_get_service", {"context_uuid": context_uuid, "service_uuid": service_uuid}), + input_payload={"context_uuid": context_uuid, "service_uuid": service_uuid}, + ) + if isinstance(existing_service, dict) and not _tool_failed(existing_service): + canonical_uuid = existing_service.get("service_id", {}).get("service_uuid", {}).get("uuid", "") + if canonical_uuid: + release_service_ids.add(str(canonical_uuid)) + + for attempt in range(1, max_attempts + 1): + delete_result = await trace.timed( + "deletion", + "local_mcp", + f"tfs_delete_service_attempt_{attempt}", + call_mcp_tool("tfs_delete_service", {"context_uuid": context_uuid, "service_uuid": service_uuid}), + input_payload={"context_uuid": context_uuid, "service_uuid": service_uuid}, + ) + await trace.timed( + "deletion", + "delete_wait", + f"wait_before_verify_attempt_{attempt}", + asyncio.sleep(poll_interval_seconds), + ) + observed = await trace.timed( + "verification", + "local_mcp", + f"tfs_get_service_after_delete_attempt_{attempt}", + call_mcp_tool("tfs_get_service", {"context_uuid": context_uuid, "service_uuid": service_uuid}), + input_payload={"context_uuid": context_uuid, "service_uuid": service_uuid}, + ) + exists = not _tool_failed(observed) + status = _service_status(observed) if exists else "NOT_FOUND" + attempts.append({"attempt": attempt, "delete_result": delete_result, "observed_status": status}) + if not exists or status in {"UNKNOWN", "NOT_FOUND"}: + reservation_release = ( + await trace.timed( + "deletion", + "local_mcp", + "release_matching_tfs_spectrum_reservations", + release_matching_tfs_spectrum_reservations( + correlation_ids=[correlation_id] if correlation_id else [], + service_ids=sorted(release_service_ids), + context_uuid=context_uuid, + ), + input_payload={ + "correlation_ids": [correlation_id] if correlation_id else [], + "service_ids": sorted(release_service_ids), + "context_uuid": context_uuid, + }, + ) + if release_reservations + else {"ok": True, "operation": "release_matching", "released": 0, "skipped": True} + ) + return { + "ok": bool(reservation_release.get("ok", True)), + "phase": "deleted", + "service_uuid": service_uuid, + "context_uuid": context_uuid, + "attempts": attempts, + "reservation_release": reservation_release, + "workflow_trace": trace.export(), + } + + reservation_release = ( + await trace.timed( + "deletion", + "local_mcp", + "release_matching_tfs_spectrum_reservations", + release_matching_tfs_spectrum_reservations( + correlation_ids=[correlation_id] if correlation_id else [], + service_ids=sorted(release_service_ids), + context_uuid=context_uuid, + ), + input_payload={ + "correlation_ids": [correlation_id] if correlation_id else [], + "service_ids": sorted(release_service_ids), + "context_uuid": context_uuid, + }, + ) + if release_reservations + else {"ok": True, "operation": "release_matching", "released": 0, "skipped": True} + ) + return { + "ok": False and bool(reservation_release.get("ok", True)), + "phase": "delete_verify", + "service_uuid": service_uuid, + "context_uuid": context_uuid, + "attempts": attempts, + "reservation_release": reservation_release, + "workflow_trace": trace.export(), + } + + +def _service_identity_values(service: Dict[str, Any]) -> List[str]: + """Extract stable service identifiers from a TFS service payload.""" + + values: List[str] = [] + service_id = service.get("service_id", {}) + if isinstance(service_id, dict): + service_uuid = service_id.get("service_uuid", {}) + if isinstance(service_uuid, dict) and service_uuid.get("uuid") not in (None, ""): + values.append(str(service_uuid["uuid"])) + for key in ("name", "uuid"): + value = service.get(key) + if value not in (None, ""): + values.append(str(value)) + unique_values: List[str] = [] + for value in values: + if value and value not in unique_values: + unique_values.append(value) + return unique_values + + +def _service_matches_request( + service: Dict[str, Any], + request_id: str, + candidate_service_ids: List[str] | None = None, +) -> bool: + """Return whether a service belongs to a request-scoped workflow.""" + + request_id = request_id.strip() + candidates = {item.strip() for item in (candidate_service_ids or []) if item and item.strip()} + if request_id: + candidates.add(request_id) + for identity in _service_identity_values(service): + if identity in candidates: + return True + if request_id and request_id in identity: + return True + return False + + +async def find_local_services_for_request( + request_id: str, + candidate_service_ids: List[str] | None = None, + context_uuid: str = DEFAULT_CONTEXT_UUID, +) -> Dict[str, Any]: + """List local TFS services and select those matching a request id.""" + + trace = WorkflowTrace() + request_id = request_id.strip() + list_result = await trace.timed( + "deletion_discovery", + "local_mcp", + "tfs_list_services_for_request", + call_mcp_tool("tfs_list_services", {"context_uuid": context_uuid}), + input_payload={"context_uuid": context_uuid, "request_id": request_id}, + ) + services = list_result.get("services", []) if isinstance(list_result, dict) else [] + matches: List[Dict[str, Any]] = [] + for service in services: + if not isinstance(service, dict): + continue + if not _service_matches_request(service, request_id, candidate_service_ids): + continue + identities = _service_identity_values(service) + matches.append({ + "name": service.get("name", ""), + "service_uuid": identities[0] if identities else "", + "identities": identities, + "raw": service, + }) + return { + "ok": True, + "request_id": request_id, + "context_uuid": context_uuid, + "service_count": len(services), + "match_count": len(matches), + "matches": matches, + "workflow_trace": trace.export(), + } + + +async def delete_local_services_for_request( + request_id: str, + candidate_service_ids: List[str] | None = None, + context_uuid: str = DEFAULT_CONTEXT_UUID, + max_attempts: int = 3, + poll_interval_seconds: float = 2.0, +) -> Dict[str, Any]: + """Delete local TFS services matching a request id after source-of-truth discovery.""" + + trace = WorkflowTrace() + discovery = await trace.timed( + "deletion_discovery", + "local_mcp", + "discover_matching_services", + find_local_services_for_request(request_id, candidate_service_ids, context_uuid), + input_payload={"context_uuid": context_uuid, "request_id": request_id}, + ) + matches = discovery.get("matches", []) if isinstance(discovery, dict) else [] + delete_tasks = [] + seen: set[str] = set() + for match in matches: + identities = [str(item) for item in match.get("identities", []) if item] + service_uuid = str(match.get("service_uuid", "") or (identities[0] if identities else "")) + if not service_uuid or service_uuid in seen: + continue + seen.add(service_uuid) + delete_tasks.append( + trace.timed( + "deletion", + "local_mcp", + "delete_matching_service", + delete_service( + service_uuid, + context_uuid=context_uuid, + max_attempts=max_attempts, + poll_interval_seconds=poll_interval_seconds, + correlation_id=request_id, + candidate_service_ids=identities, + ), + parallel_group="delete_matching_services", + input_payload={"context_uuid": context_uuid, "service_uuid": service_uuid}, + ) + ) + delete_results = await asyncio.gather(*delete_tasks) if delete_tasks else [] + nested_workflow_trace: List[Dict[str, Any]] = [] + for item in delete_results: + if isinstance(item, dict): + nested_workflow_trace.extend(item.get("workflow_trace", []) or []) + failures = [item for item in delete_results if isinstance(item, dict) and not item.get("ok")] + return { + "ok": not failures, + "phase": "deleted", + "request_id": request_id, + "context_uuid": context_uuid, + "domain_id": DOMAIN_ID, + "discovery": discovery, + "matched_count": len(matches), + "deleted_count": len(delete_results) - len(failures), + "delete_results": delete_results, + "workflow_trace": trace.export(), + "nested_workflow_trace": nested_workflow_trace, + "failures": failures, + } + + +def _remote_domain_device(domain_id: str) -> str: + return f"DOMAIN-{domain_id.upper()}" + + +def _service_uuid_for_domain(domain_id: str, service_name: str) -> str: + return f"{domain_id.lower()}-{service_name}" + + +def _stored_service_targets_for_request(request_id: str) -> Dict[str, Any]: + """Return segment domains and service IDs from the local persisted create event.""" + + events = find_events_by_request_id( + request_id, + event_types=("cross_domain_create", "cross_domain_create_delegated"), + limit=20, + ) + if not events: + return {"ok": False, "request_id": request_id, "domains": {}, "source": "not_found"} + for event in events: + payload = event.get("payload", {}) + if not isinstance(payload, dict) or not payload.get("ok", False): + continue + domain_path = [str(item).upper() for item in payload.get("domain_path", []) if str(item).strip()] + services = [str(item) for item in payload.get("segment_service_uuids", []) if str(item).strip()] + domains: Dict[str, List[str]] = {} + if domain_path and len(domain_path) == len(services): + for domain_id, service_id in zip(domain_path, services): + domains.setdefault(domain_id, []).append(service_id) + else: + for service_id in services: + match = re.match(r"^([a-z])-", service_id.strip(), flags=re.IGNORECASE) + if match: + domains.setdefault(match.group(1).upper(), []).append(service_id) + for key in ("local_service_uuid", "peer_service_uuid"): + service_id = str(payload.get(key, "") or "") + match = re.match(r"^([a-z])-", service_id.strip(), flags=re.IGNORECASE) + if service_id and match: + domains.setdefault(match.group(1).upper(), []).append(service_id) + domains = {domain_id: sorted(set(values)) for domain_id, values in domains.items() if values} + if domains: + return { + "ok": True, + "request_id": request_id, + "domains": domains, + "domain_path": domain_path, + "event_id": event.get("event_id"), + "source": "session_event", + } + return {"ok": False, "request_id": request_id, "domains": {}, "source": "no_successful_create_event"} + + +def _remote_abstraction_domains(devices: List[Dict[str, Any]]) -> set[str]: + domains = set() + for device in devices: + name = _device_name(device).upper() + if name.startswith("DOMAIN-") and len(name) > len("DOMAIN-"): + domains.add(name.split("DOMAIN-", 1)[1]) + return domains + + +async def _select_next_hop_domain( + destination_domain: str, + local_devices: List[Dict[str, Any]], + trace: WorkflowTrace, +) -> Dict[str, Any]: + destination_domain = destination_domain.upper() + local_adjacent = _remote_abstraction_domains(local_devices) + peers = parse_peers() + if destination_domain in local_adjacent and destination_domain in peers: + return { + "ok": True, + "next_hop_domain": destination_domain, + "destination_domain": destination_domain, + "domain_path": [DOMAIN_ID, destination_domain], + "direct": True, + "local_adjacent_domains": sorted(local_adjacent), + } + checked = [] + candidates = [candidate for candidate in sorted(local_adjacent) if candidate in peers] + peer_results = await asyncio.gather( + *[ + trace.timed( + "planning", + f"domain_{candidate}", + f"inspect_adjacency_to_{destination_domain}", + peer_device_inventory(candidate), + ) + for candidate in candidates + ], + return_exceptions=True, + ) + for candidate, peer_inventory in zip(candidates, peer_results): + if isinstance(peer_inventory, Exception): + checked.append({"domain_id": candidate, "error": str(peer_inventory)}) + continue + peer_devices = peer_inventory.get("devices", []) if isinstance(peer_inventory, dict) else [] + peer_adjacent = _remote_abstraction_domains(peer_devices) + checked.append({"domain_id": candidate, "adjacent_domains": sorted(peer_adjacent)}) + if destination_domain in peer_adjacent: + return { + "ok": True, + "next_hop_domain": candidate, + "destination_domain": destination_domain, + "domain_path": [DOMAIN_ID, candidate, destination_domain], + "direct": False, + "local_adjacent_domains": sorted(local_adjacent), + "checked": checked, + } + return { + "ok": False, + "error": "domain_path_not_found", + "destination_domain": destination_domain, + "local_adjacent_domains": sorted(local_adjacent), + "checked": checked, + } + + +def _intersect_range_sets(range_sets: List[List[Dict[str, int]]], required_slots: int) -> List[Dict[str, int]]: + """Return slot ranges available in every candidate set.""" + + if not range_sets: + return [] + intersections = [ + (int(item["n_start"]), int(item["n_end"])) + for item in range_sets[0] + if isinstance(item, dict) and "n_start" in item and "n_end" in item + ] + for ranges in range_sets[1:]: + next_intersections: List[tuple[int, int]] = [] + parsed_ranges = [ + (int(item["n_start"]), int(item["n_end"])) + for item in ranges + if isinstance(item, dict) and "n_start" in item and "n_end" in item + ] + for first_start, first_end in intersections: + for second_start, second_end in parsed_ranges: + start = max(first_start, second_start) + end = min(first_end, second_end) + if end - start + 1 >= required_slots: + next_intersections.append((start, end)) + intersections = next_intersections + if not intersections: + return [] + unique = sorted(set(intersections)) + return [{"n_start": start, "n_end": end} for start, end in unique] + + +def _select_from_ranges( + common_ranges: List[Dict[str, int]], + required_slots: int, + minimum_slot: int | None = None, + preferred_band: str = "", +) -> Dict[str, Any]: + """Select a contiguous slot block from precomputed common ranges.""" + + return spectrum.intersect_and_select( + common_ranges, + common_ranges, + required_slots, + minimum_slot=minimum_slot, + preferred_band=preferred_band, + ) + + +def _controller_sizing_from_candidate_list( + sizing: Dict[str, Any], + candidate_results: List[Dict[str, Any]], +) -> Dict[str, Any]: + """Use all per-domain TFS candidate replies to validate sizing.""" + + replies = [ + item.get("tfs_candidate_reply", {}) + for item in candidate_results + if isinstance(item, dict) + ] + modulation_formats: set[str] = set() + required_slot_values: set[int] = set() + slot_width_values: set[float] = set() + for reply in replies: + modulation_formats |= _candidate_modulation_formats(reply) + slots = _tfs_candidate_required_slots(reply) + if slots is not None: + required_slot_values.add(int(slots)) + slot_width = _tfs_candidate_slot_width_ghz(reply, int(slots)) + if slot_width is not None: + slot_width_values.add(float(slot_width)) + requested_modulation = str(sizing.get("modulation_format") or "").strip().lower() + if requested_modulation: + modulation_formats.add(requested_modulation) + if len(modulation_formats) > 1: + return { + **sizing, + "ok": False, + "error": "cross_domain_modulation_mismatch", + "modulation_formats": sorted(modulation_formats), + } + if len(required_slot_values) > 1: + return { + **sizing, + "ok": False, + "error": "tfs_candidate_required_slot_mismatch", + "required_slot_values": sorted(required_slot_values), + } + if not required_slot_values: + return dict(sizing) + required_slots = next(iter(required_slot_values)) + slot_width = next(iter(slot_width_values)) if slot_width_values else sizing.get("slot_width_ghz") + return { + **sizing, + "required_slots": required_slots, + "slot_width_ghz": slot_width, + "source": "tfs_optical_connectivity_candidates", + "agentic_preliminary_required_slots": sizing.get("required_slots"), + } + + +def _path_wide_segments( + domain_path: List[str], + source_device: str, + destination_device: str, + service_name: str, +) -> List[Dict[str, Any]]: + """Build one optical segment per domain along a discovered domain path.""" + + segments: List[Dict[str, Any]] = [] + for index, domain_id in enumerate(domain_path): + previous_domain = domain_path[index - 1] if index > 0 else "" + next_domain = domain_path[index + 1] if index + 1 < len(domain_path) else "" + segment_source = source_device if index == 0 else _remote_domain_device(previous_domain) + segment_destination = destination_device if index == len(domain_path) - 1 else _remote_domain_device(next_domain) + service_id = _service_uuid_for_domain(domain_id, service_name) + segments.append({ + "domain_id": domain_id, + "source_device": segment_source, + "destination_device": segment_destination, + "service_id": service_id, + "role": "origin" if index == 0 else "terminal" if index == len(domain_path) - 1 else "transit", + }) + return segments + + +async def _segment_candidates( + segment: Dict[str, Any], + sizing: Dict[str, Any], + required_slots: int, + preferred_band: str, + user_id: str, + session_id: str, +) -> Dict[str, Any]: + domain_id = str(segment["domain_id"]) + if domain_id == DOMAIN_ID: + result = await compute_tfs_optical_connectivity_candidates( + str(segment["source_device"]), + str(segment["destination_device"]), + channel_width_ghz=sizing.get("channel_width_ghz"), + capacity_gbps=sizing.get("capacity_gbps"), + modulation_format=sizing.get("modulation_format", ""), + preferred_band=preferred_band or "c_slots", + ) + else: + result = await peer_post( + domain_id, + "/spectrum/candidates", + { + "source_device": segment["source_device"], + "destination_device": segment["destination_device"], + "required_slots": required_slots, + "preferred_band": preferred_band or "c_slots", + "capacity_gbps": sizing.get("capacity_gbps"), + "modulation_format": sizing.get("modulation_format", ""), + "channel_width_ghz": sizing.get("channel_width_ghz"), + "user_id": user_id, + "session_id": session_id, + }, + ) + return {"segment": segment, "result": result} + + +async def _segment_reserve( + segment: Dict[str, Any], + selected_range: Dict[str, int], + candidate_bundle: Dict[str, Any], + selected_band: str, + requested_preferred_band: str, + minimum_slot: int | None, + service_name: str, + sizing: Dict[str, Any], + user_id: str, + session_id: str, +) -> Dict[str, Any]: + domain_id = str(segment["domain_id"]) + metadata = { + "role": segment.get("role", "segment"), + "requesting_domain": DOMAIN_ID, + "preferred_band": selected_band, + "requested_preferred_band": requested_preferred_band, + "requested_minimum_slot": minimum_slot, + "correlation_id": service_name, + "spectrum_sizing": sizing, + "domain_path_orchestration": True, + } + if domain_id == DOMAIN_ID: + result = await create_tfs_spectrum_reservation( + str(segment["service_id"]), + selected_range, + candidate_bundle, + metadata, + ) + else: + result = await peer_post( + domain_id, + "/spectrum/reserve", + { + "service_id": segment["service_id"], + "selected_range": selected_range, + "candidate_bundle": candidate_bundle, + "metadata": metadata, + "user_id": user_id, + "session_id": session_id, + }, + ) + return {"segment": segment, "result": result} + + +async def _segment_release( + segment: Dict[str, Any], + reservation: Dict[str, Any], + user_id: str, + session_id: str, +) -> Dict[str, Any]: + reservation_id = str(reservation.get("reservation_id", "")) + context_uuid = str(reservation.get("context_uuid") or DEFAULT_CONTEXT_UUID) + if not reservation_id: + return {"segment": segment, "result": {"ok": True, "status": "not_required"}} + domain_id = str(segment["domain_id"]) + if domain_id == DOMAIN_ID: + result = await release_tfs_spectrum_reservation(reservation_id, context_uuid) + else: + result = await peer_post( + domain_id, + "/spectrum/update", + { + "reservation_id": reservation_id, + "context_uuid": context_uuid, + "status": "rolled_back", + "user_id": user_id, + "session_id": session_id, + }, + ) + return {"segment": segment, "result": result} + + +async def _segment_provision( + segment: Dict[str, Any], + selected_band: str, + sizing: Dict[str, Any], + user_id: str, + session_id: str, +) -> Dict[str, Any]: + domain_id = str(segment["domain_id"]) + if domain_id == DOMAIN_ID: + result = await create_optical_connectivity_service( + str(segment["source_device"]), + str(segment["destination_device"]), + service_name=str(segment["service_id"]), + preferred_band=selected_band, + capacity_gbps=sizing.get("capacity_gbps"), + modulation_format=sizing.get("modulation_format", ""), + channel_width_ghz=sizing.get("channel_width_ghz"), + required_slots_override=sizing.get("required_slots"), + slot_width_ghz_override=sizing.get("slot_width_ghz"), + ) + else: + result = await peer_post( + domain_id, + "/services/optical", + { + "source_device": segment["source_device"], + "destination_device": segment["destination_device"], + "service_name": segment["service_id"], + "preferred_band": selected_band, + "capacity_gbps": sizing.get("capacity_gbps"), + "modulation_format": sizing.get("modulation_format", ""), + "channel_width_ghz": sizing.get("channel_width_ghz"), + "required_slots_override": sizing.get("required_slots"), + "slot_width_ghz_override": sizing.get("slot_width_ghz"), + "user_id": user_id, + "session_id": session_id, + }, + ) + return {"segment": segment, "result": result} + + +async def _segment_delete( + segment: Dict[str, Any], + user_id: str, + session_id: str, +) -> Dict[str, Any]: + domain_id = str(segment["domain_id"]) + service_id = str(segment["service_id"]) + if domain_id == DOMAIN_ID: + result = await delete_service(service_id) + else: + result = await peer_post( + domain_id, + "/services/delete", + {"service_uuid": service_id, "user_id": user_id, "session_id": session_id}, + ) + return {"segment": segment, "result": result} + + +async def _segment_commit( + segment: Dict[str, Any], + reservation: Dict[str, Any], + user_id: str, + session_id: str, +) -> Dict[str, Any]: + reservation_id = str(reservation.get("reservation_id", "")) + context_uuid = str(reservation.get("context_uuid") or DEFAULT_CONTEXT_UUID) + service_id = str(segment["service_id"]) + if not reservation_id: + return {"segment": segment, "result": {"ok": True, "status": "not_required"}} + domain_id = str(segment["domain_id"]) + if domain_id == DOMAIN_ID: + result = await consume_tfs_spectrum_reservation(reservation_id, service_id, context_uuid) + else: + result = await peer_post( + domain_id, + "/spectrum/update", + { + "reservation_id": reservation_id, + "context_uuid": context_uuid, + "status": "committed", + "service_id": service_id, + "user_id": user_id, + "session_id": session_id, + }, + ) + return {"segment": segment, "result": result} + + +def _segment_map(rows: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: + return {str(row.get("segment", {}).get("domain_id", "")): row.get("result", {}) for row in rows} + + +async def _create_path_wide_cross_domain_optical_service( + *, + trace: WorkflowTrace, + route: Dict[str, Any], + service_name: str, + requested_source_device: str, + requested_destination_device: str, + source_device: str, + destination_device: str, + source_resolution: Dict[str, Any], + destination_resolution: Dict[str, Any], + destination_domain: str, + sizing: Dict[str, Any], + normalized_spectrum_inputs: Dict[str, Any], + required_slots: int, + minimum_slot: int | None, + preferred_band: str, + user_id: str, + session_id: str, +) -> Dict[str, Any]: + """Provision all segments in a discovered domain path without recursive delegation.""" + + domain_path = [str(item).upper() for item in route.get("domain_path", []) if str(item).strip()] + if not domain_path and route.get("next_hop_domain"): + next_hop = str(route.get("next_hop_domain", "")).upper() + final_domain = str(route.get("destination_domain") or destination_domain or next_hop).upper() + domain_path = [DOMAIN_ID, next_hop] + if final_domain and final_domain != next_hop: + domain_path.append(final_domain) + if len(domain_path) < 2 or domain_path[0] != DOMAIN_ID: + return { + "ok": False, + "phase": "path_wide_routing", + "error": "unsupported_domain_path", + "domain_path": domain_path, + "route": route, + "workflow_trace": trace.export(), + } + segments = _path_wide_segments(domain_path, source_device, destination_device, service_name) + + candidate_rows = await asyncio.gather( + *[ + trace.timed( + "negotiation", + f"domain_{segment['domain_id']}", + "path_wide_compute_optical_connectivity_candidates", + _segment_candidates(segment, sizing, required_slots, preferred_band, user_id, session_id), + parallel_group="path_candidate_collection", + ) + for segment in segments + ] + ) + failed_candidates = [ + row for row in candidate_rows + if not isinstance(row.get("result"), dict) or not row["result"].get("ok", True) + ] + if failed_candidates: + result = { + "ok": False, + "phase": "path_candidate_collection", + "segments": segments, + "candidate_results": candidate_rows, + "failed_candidates": failed_candidates, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + candidate_results = [row["result"] for row in candidate_rows] + sizing = _controller_sizing_from_candidate_list(sizing, candidate_results) + if not sizing.get("ok", True): + result = { + "ok": False, + "phase": "controller_candidate_sizing", + "spectrum_sizing": sizing, + "segments": segments, + "candidate_results": candidate_rows, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + required_slots = int(sizing["required_slots"]) + common_ranges = _intersect_range_sets( + [ + row["result"].get("candidate_ranges", []) + for row in candidate_rows + if isinstance(row.get("result"), dict) + ], + required_slots, + ) + selection_started = time.perf_counter() + selected = _select_from_ranges(common_ranges, required_slots, minimum_slot=minimum_slot, preferred_band=preferred_band) + trace.mark( + "negotiation", + "negotiation_policy_agent", + "path_wide_intersect_and_select_spectrum", + "ok" if selected.get("ok") else "failed", + selection_started, + ) + if not selected.get("ok"): + result = { + "ok": False, + "phase": "spectrum_intersection", + "segments": segments, + "candidate_results": candidate_rows, + "common_candidate_ranges": common_ranges, + "selection": selected, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + selected_band = selected["preferred_band"] + candidates_by_domain = _segment_map(candidate_rows) + reservation_rows = await asyncio.gather( + *[ + trace.timed( + "negotiation", + f"domain_{segment['domain_id']}", + "path_wide_create_spectrum_reservation", + _segment_reserve( + segment, + selected["selected_range"], + candidates_by_domain[str(segment["domain_id"])], + selected_band, + preferred_band, + minimum_slot, + service_name, + sizing, + user_id, + session_id, + ), + parallel_group="path_spectrum_reservation", + ) + for segment in segments + ] + ) + failed_reservations = [ + row for row in reservation_rows + if not isinstance(row.get("result"), dict) or not row["result"].get("ok") + ] + if failed_reservations: + release_rows = await asyncio.gather( + *[ + trace.timed( + "rollback", + f"domain_{row['segment']['domain_id']}", + "path_wide_release_spectrum_after_reservation_failure", + _segment_release(row["segment"], row["result"], user_id, session_id), + parallel_group="path_reservation_failure_rollback", + ) + for row in reservation_rows + if isinstance(row.get("result"), dict) and row["result"].get("ok") + ], + return_exceptions=True, + ) + failed_domains = { + str(row.get("segment", {}).get("domain_id", "")) + for row in failed_reservations + } + phase = "reserve_spectrum" + if failed_domains == {DOMAIN_ID}: + phase = "reserve_local" + elif DOMAIN_ID not in failed_domains: + phase = "reserve_peer" + reservations_by_domain = _segment_map(reservation_rows) + releases_by_domain = _segment_map([ + row for row in release_rows + if isinstance(row, dict) + ]) + first_peer_domain = next((domain for domain in domain_path if domain != DOMAIN_ID), "") + result = { + "ok": False, + "phase": phase, + "segments": segments, + "candidate_results": candidate_rows, + "selection": selected, + "reservation_results": reservation_rows, + "failed_reservations": failed_reservations, + "release_results": release_rows, + "local_reservation": reservations_by_domain.get(DOMAIN_ID, {}), + "peer_reservation": reservations_by_domain.get(first_peer_domain, {}), + "local_release": releases_by_domain.get(DOMAIN_ID, {"ok": True, "status": "not_required"}), + "peer_release": releases_by_domain.get(first_peer_domain, {"ok": True, "status": "not_required"}), + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + provision_rows = await asyncio.gather( + *[ + trace.timed( + "provisioning", + f"domain_{segment['domain_id']}", + "path_wide_provision_optical_segment", + _segment_provision(segment, selected_band, sizing, user_id, session_id), + parallel_group="path_service_provisioning", + ) + for segment in segments + ] + ) + failed_provisions = [ + row for row in provision_rows + if not isinstance(row.get("result"), dict) or not row["result"].get("ok") + ] + reservations_by_domain = _segment_map(reservation_rows) + if failed_provisions: + rollback_rows = await asyncio.gather( + *[ + trace.timed( + "rollback", + f"domain_{segment['domain_id']}", + "path_wide_delete_segment_after_provision_failure", + _segment_delete(segment, user_id, session_id), + parallel_group="path_provision_failure_rollback", + ) + for segment in segments + if _segment_map(provision_rows).get(str(segment["domain_id"]), {}).get("ok") + ], + *[ + trace.timed( + "rollback", + f"domain_{segment['domain_id']}", + "path_wide_release_spectrum_after_provision_failure", + _segment_release(segment, reservations_by_domain[str(segment["domain_id"])], user_id, session_id), + parallel_group="path_provision_failure_rollback", + ) + for segment in segments + ], + return_exceptions=True, + ) + result = { + "ok": False, + "phase": "create_segments", + "segments": segments, + "candidate_results": candidate_rows, + "selection": selected, + "reservation_results": reservation_rows, + "provision_results": provision_rows, + "failed_provisions": failed_provisions, + "rollback_results": rollback_rows, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + commit_rows = await asyncio.gather( + *[ + trace.timed( + "verification", + f"domain_{segment['domain_id']}", + "path_wide_commit_spectrum_reservation", + _segment_commit(segment, reservations_by_domain[str(segment["domain_id"])], user_id, session_id), + parallel_group="path_reservation_commit", + ) + for segment in segments + ] + ) + failed_commits = [ + row for row in commit_rows + if not isinstance(row.get("result"), dict) or not row["result"].get("ok", True) + ] + if failed_commits: + rollback_rows = await asyncio.gather( + *[ + trace.timed( + "rollback", + f"domain_{segment['domain_id']}", + "path_wide_delete_segment_after_commit_failure", + _segment_delete(segment, user_id, session_id), + parallel_group="path_commit_failure_rollback", + ) + for segment in segments + ], + *[ + trace.timed( + "rollback", + f"domain_{segment['domain_id']}", + "path_wide_release_spectrum_after_commit_failure", + _segment_release(segment, reservations_by_domain[str(segment["domain_id"])], user_id, session_id), + parallel_group="path_commit_failure_rollback", + ) + for segment in segments + ], + return_exceptions=True, + ) + result = { + "ok": False, + "phase": "commit_reservations", + "segments": segments, + "candidate_results": candidate_rows, + "selection": selected, + "reservation_results": reservation_rows, + "provision_results": provision_rows, + "commit_results": commit_rows, + "failed_commits": failed_commits, + "rollback_results": rollback_rows, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + segment_service_uuids = [str(segment["service_id"]) for segment in segments] + 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) + result = { + "ok": True, + "phase": "active", + "request_id": service_name, + "requested_source_device": requested_source_device, + "requested_destination_device": requested_destination_device, + "source_device": source_device, + "destination_device": destination_device, + "endpoint_resolution": { + "source": source_resolution, + "destination": destination_resolution, + }, + "local_domain": DOMAIN_ID, + "peer_domain": peer_domain, + "destination_domain": destination_domain, + "domain_path": domain_path, + "route": route, + "segments": segments, + "preferred_band": selected_band, + "requested_preferred_band": preferred_band, + "requested_minimum_slot": minimum_slot, + "spectrum_sizing": sizing, + "normalized_spectrum_inputs": normalized_spectrum_inputs, + "common_candidate_ranges": common_ranges, + "selection": selected, + "local_service_uuid": local_service, + "peer_service_uuid": peer_service, + "downstream_service_uuids": segment_service_uuids[1:], + "segment_service_uuids": segment_service_uuids, + "candidate_results": candidate_rows, + "reservation_results": reservation_rows, + "provision_results": provision_rows, + "commit_results": commit_rows, + "workflow_trace": trace.export(), + "path_wide_orchestration": True, + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + +def _summarize_devices(devices: List[Dict[str, Any]]) -> Dict[str, Any]: + names = sorted(str(item.get("name")) for item in devices if isinstance(item, dict) and item.get("name")) + endpoint_devices = [name for name in names if name.startswith("DC") and "-TP" in name] + abstraction_devices = [name for name in names if name.startswith("DOMAIN-") or name == "REMOTE-NET"] + return { + "device_count": len(names), + "devices": names, + "endpoint_devices": endpoint_devices, + "abstraction_devices": abstraction_devices, + } + + +def _device_name(device: Dict[str, Any]) -> str: + return str(device.get("name") or device.get("device_uuid") or "").strip() + + +def _device_type(device: Dict[str, Any]) -> str: + return str(device.get("device_type") or "").strip().lower() + + +def _resource_name(resource: Dict[str, Any], id_field: str) -> str: + if resource.get("name"): + return str(resource.get("name")) + nested_id = resource.get(id_field, {}) if isinstance(resource, dict) else {} + if isinstance(nested_id, dict): + for key in ("device_uuid", "link_uuid", "service_uuid", "connection_uuid"): + value = nested_id.get(key, {}) + if isinstance(value, dict) and value.get("uuid"): + return str(value.get("uuid")) + return "" + + +def _uuid_from_path(payload: Dict[str, Any], path: List[str]) -> str: + value: Any = payload + for key in path: + value = value.get(key, {}) if isinstance(value, dict) else {} + return str(value.get("uuid", "")) if isinstance(value, dict) else "" + + +def _endpoint_id_summary(endpoint_id: Dict[str, Any]) -> Dict[str, str]: + return { + "device_uuid": _uuid_from_path(endpoint_id, ["device_id", "device_uuid"]), + "endpoint_uuid": _uuid_from_path(endpoint_id, ["endpoint_uuid"]), + } + + +def _endpoint_ref(endpoint_id: Dict[str, Any]) -> str: + summary = _endpoint_id_summary(endpoint_id) + if summary["device_uuid"] and summary["endpoint_uuid"]: + return f"{summary['device_uuid']}/{summary['endpoint_uuid']}" + return summary["endpoint_uuid"] or summary["device_uuid"] + + +def _config_rule_count(resource: Dict[str, Any], config_key: str) -> int: + config = resource.get(config_key, {}) if isinstance(resource, dict) else {} + rules = config.get("config_rules", []) if isinstance(config, dict) else [] + return len(rules) if isinstance(rules, list) else 0 + + +def _compact_device(device: Dict[str, Any], include_config_rules: bool, include_ids: bool) -> Dict[str, Any]: + endpoints = device.get("device_endpoints", []) or [] + compact: Dict[str, Any] = { + "name": _device_name(device), + "device_type": device.get("device_type", ""), + "operational_status": device.get("device_operational_status", ""), + "drivers": device.get("device_drivers", []), + "endpoint_count": len(endpoints) if isinstance(endpoints, list) else 0, + } + if include_ids: + compact["device_id"] = device.get("device_id", {}) + if include_config_rules: + compact["device_config"] = device.get("device_config", {}) + else: + compact["config_rule_count"] = _config_rule_count(device, "device_config") + return compact + + +def _compact_endpoint(endpoint: Dict[str, Any], device_name: str, include_ids: bool) -> Dict[str, Any]: + compact = { + "device_name": device_name, + "endpoint_name": endpoint.get("name", ""), + "endpoint_type": endpoint.get("endpoint_type", ""), + } + if include_ids: + compact["endpoint_id"] = endpoint.get("endpoint_id", {}) + return compact + + +def _compact_link(link: Dict[str, Any], include_ids: bool) -> Dict[str, Any]: + endpoints = link.get("link_endpoint_ids", []) or [] + compact = { + "name": link.get("name", ""), + "link_type": link.get("link_type", ""), + "endpoints": [_endpoint_ref(endpoint) for endpoint in endpoints if isinstance(endpoint, dict)], + } + if include_ids: + compact["link_id"] = link.get("link_id", {}) + compact["link_endpoint_ids"] = endpoints + return compact + + +def _compact_optical_link(link: Dict[str, Any], include_ids: bool) -> Dict[str, Any]: + compact = _compact_link(link, include_ids) + details = link.get("optical_details", {}) if isinstance(link, dict) else {} + if isinstance(details, dict): + compact["optical_details_keys"] = sorted(details.keys()) + return compact + + +def _compact_service(service: Dict[str, Any], include_config_rules: bool, include_ids: bool) -> Dict[str, Any]: + endpoints = service.get("service_endpoint_ids", []) or [] + constraints = service.get("service_constraints", []) or [] + status = service.get("service_status", {}) if isinstance(service.get("service_status", {}), dict) else {} + compact: Dict[str, Any] = { + "name": service.get("name", ""), + "service_type": service.get("service_type", ""), + "status": status.get("service_status", service.get("service_status", "")), + "endpoint_count": len(endpoints) if isinstance(endpoints, list) else 0, + "constraint_count": len(constraints) if isinstance(constraints, list) else 0, + } + if include_ids: + compact["service_id"] = service.get("service_id", {}) + compact["service_endpoint_ids"] = endpoints + if include_config_rules: + compact["service_config"] = service.get("service_config", {}) + else: + compact["config_rule_count"] = _config_rule_count(service, "service_config") + return compact + + +def _compact_connection(connection: Dict[str, Any], include_ids: bool) -> Dict[str, Any]: + path_hops = connection.get("path_hops_endpoint_ids", []) or [] + compact = { + "name": connection.get("name", ""), + "status": connection.get("connection_status", ""), + "path_hop_count": len(path_hops) if isinstance(path_hops, list) else 0, + } + if include_ids: + compact["connection_id"] = connection.get("connection_id", {}) + compact["path_hops_endpoint_ids"] = path_hops + return compact + + +def _normalize_detail_level(detail_level: str) -> str: + normalized = (detail_level or "summary").strip().lower() + if normalized in {"full", "raw", "detail", "details"}: + return "full" + return "summary" + + +def _device_matches_filter(device: Dict[str, Any], device_filter: str) -> bool: + name = _device_name(device) + upper = name.upper() + dtype = _device_type(device) + if device_filter in {"all", "devices"}: + return True + if device_filter == "datacenters": + return upper.startswith("DC") and "-TP" not in upper + if device_filter == "transponders": + return "transponder" in dtype or (upper.startswith("DC") and "-TP" in upper) + if device_filter == "roadms": + return "roadm" in dtype or upper.startswith("MGON") + if device_filter == "routers": + return "router" in dtype or upper.startswith("R") + if device_filter == "remote_abstractions": + return upper.startswith("DOMAIN-") or upper == "REMOTE-NET" + return device_filter in dtype or device_filter in upper.lower() + + +def _filter_device_records(devices: List[Dict[str, Any]], device_filter: str) -> List[Dict[str, Any]]: + filtered = [] + for device in devices: + if not isinstance(device, dict): + continue + name = _device_name(device) + if name and _device_matches_filter(device, device_filter): + filtered.append(device) + return sorted(filtered, key=_device_name) + + +def _endpoint_records( + devices: List[Dict[str, Any]], + device_filter: str, + include_ids: bool, +) -> List[Dict[str, Any]]: + records = [] + for device in _filter_device_records(devices, device_filter): + device_name = _device_name(device) + for endpoint in device.get("device_endpoints", []) or []: + if not isinstance(endpoint, dict): + continue + records.append(_compact_endpoint(endpoint, device_name, include_ids)) + return sorted(records, key=lambda item: (item["device_name"], item["endpoint_name"])) + + +def _normalize_resource_kind(resource_kind: str) -> str: + kind = (resource_kind or "devices").strip().lower().replace("_", "-").replace(" ", "-") + aliases = { + "all": "all", + "inventory": "all", + "device": "devices", + "devices": "devices", + "node": "devices", + "nodes": "devices", + "dc": "datacenters", + "dcs": "datacenters", + "data-center": "datacenters", + "data-centers": "datacenters", + "datacenter": "datacenters", + "datacenters": "datacenters", + "transponder": "transponders", + "transponders": "transponders", + "tp": "transponders", + "tps": "transponders", + "roadm": "roadms", + "roadms": "roadms", + "router": "routers", + "routers": "routers", + "remote-abstraction": "remote_abstractions", + "remote-abstractions": "remote_abstractions", + "remote-network": "remote_abstractions", + "remote-networks": "remote_abstractions", + "endpoint": "endpoints", + "endpoints": "endpoints", + "port": "endpoints", + "ports": "endpoints", + "link": "links", + "links": "links", + "packet-link": "links", + "packet-links": "links", + "optical-link": "optical_links", + "optical-links": "optical_links", + "service": "services", + "services": "services", + "optical-service": "services", + "optical-services": "services", + "connection": "connections", + "connections": "connections", + } + return aliases.get(kind, kind) + + +def _resource_plan(resource_kind: str) -> Dict[str, Any]: + normalized = _normalize_resource_kind(resource_kind) + device_filters = {"datacenters", "transponders", "roadms", "routers", "remote_abstractions"} + if normalized in device_filters: + return {"resource_family": "devices", "device_filter": normalized, "resource_kind": normalized} + if normalized in {"all", "devices", "endpoints", "links", "optical_links", "services", "connections"}: + return {"resource_family": normalized, "device_filter": "all", "resource_kind": normalized} + return {"resource_family": "devices", "device_filter": normalized, "resource_kind": normalized} + + +def _normalize_device_filter(device_filter: str) -> str: + if not device_filter: + return "" + plan = _resource_plan(device_filter) + if plan["resource_family"] == "devices": + return plan["device_filter"] + return _normalize_resource_kind(device_filter) + + +async def _local_resource_payload(resource_family: str) -> Dict[str, Any]: + if resource_family == "all": + devices, links, optical_links, services, connections = await asyncio.gather( + call_mcp_tool("tfs_list_devices", {}), + call_mcp_tool("tfs_list_links", {}), + call_mcp_tool("tfs_list_optical_links", {}), + call_mcp_tool("tfs_list_services", {}), + call_mcp_tool("tfs_list_all_connections", {}), + ) + return { + "devices": devices.get("devices", []) if isinstance(devices, dict) else [], + "links": links.get("links", []) if isinstance(links, dict) else [], + "optical_links": optical_links.get("optical_links", []) if isinstance(optical_links, dict) else [], + "services": services.get("services", []) if isinstance(services, dict) else [], + "connections": connections.get("connections", []) if isinstance(connections, dict) else [], + } + if resource_family in {"devices", "endpoints"}: + return await call_mcp_tool("tfs_list_devices", {}) + if resource_family == "links": + return await call_mcp_tool("tfs_list_links", {}) + if resource_family == "optical_links": + return await call_mcp_tool("tfs_list_optical_links", {}) + if resource_family == "services": + return await call_mcp_tool("tfs_list_services", {}) + if resource_family == "connections": + return await call_mcp_tool("tfs_list_all_connections", {}) + return await call_mcp_tool("tfs_list_devices", {}) + + +async def _peer_resource_payload( + domain_id: str, + resource_family: str, + device_filter: str, + detail_level: str, + include_config_rules: bool, + include_ids: bool, +) -> Dict[str, Any]: + path = f"/inventory/resources?resource_kind={resource_family}" + if device_filter: + path += f"&device_filter={device_filter}" + path += f"&detail_level={detail_level}" + path += f"&include_config_rules={str(include_config_rules).lower()}" + path += f"&include_ids={str(include_ids).lower()}" + payload = await peer_get(domain_id, path) + if isinstance(payload, dict) and isinstance(payload.get("domains"), dict): + return payload["domains"].get(domain_id, payload) + return payload + + +async def _domain_inventory_entry( + domain_id: str, + role: str, + plan: Dict[str, Any], + include_optical_links: bool, + detail_level: str, + include_config_rules: bool, + include_ids: bool, +) -> Dict[str, Any]: + resource_family = plan["resource_family"] + device_filter = plan["device_filter"] + payload = ( + await _local_resource_payload(resource_family) + if role == "local" + else await _peer_resource_payload( + domain_id, + resource_family, + device_filter, + detail_level, + include_config_rules, + include_ids, + ) + ) + if role != "local" and isinstance(payload, dict) and payload.get("resource_family"): + return {**payload, "domain_id": domain_id, "role": role} + entry: Dict[str, Any] = { + "domain_id": domain_id, + "role": role, + "resource_kind": plan["resource_kind"], + "resource_family": resource_family, + } + devices = payload.get("devices", []) if isinstance(payload, dict) else [] + if resource_family in {"all", "devices"}: + filtered_devices = _filter_device_records(devices, device_filter) + entry["devices"] = ( + filtered_devices + if detail_level == "full" + else [_compact_device(device, include_config_rules, include_ids) for device in filtered_devices] + ) + entry["device_names"] = [_device_name(device) for device in filtered_devices] + entry["device_count"] = len(filtered_devices) + if resource_family in {"all", "endpoints"}: + source_devices = devices or (payload.get("devices", []) if isinstance(payload, dict) else []) + endpoints = _endpoint_records(source_devices, device_filter, include_ids) + entry["endpoints"] = endpoints + entry["endpoint_count"] = len(endpoints) + if resource_family in {"all", "links"}: + links = payload.get("links", []) if isinstance(payload, dict) else [] + entry["links"] = links if detail_level == "full" else [_compact_link(link, include_ids) for link in links] + entry["link_count"] = len(links) + if resource_family in {"all", "optical_links"}: + optical_links = payload.get("optical_links", []) if isinstance(payload, dict) else [] + entry["optical_links"] = ( + optical_links + if detail_level == "full" + else [_compact_optical_link(link, include_ids) for link in optical_links] + ) + entry["optical_link_count"] = len(optical_links) + if resource_family in {"all", "services"}: + services = payload.get("services", []) if isinstance(payload, dict) else [] + entry["services"] = ( + services + if detail_level == "full" + else [_compact_service(service, include_config_rules, include_ids) for service in services] + ) + entry["service_count"] = len(services) + if resource_family in {"all", "connections"}: + connections = payload.get("connections", []) if isinstance(payload, dict) else [] + entry["connections"] = ( + connections + if detail_level == "full" + else [_compact_connection(connection, include_ids) for connection in connections] + ) + entry["connection_count"] = len(connections) + if include_optical_links: + if role == "local": + links_payload = await call_mcp_tool("tfs_list_optical_links", {}) + else: + links_payload = await peer_get(domain_id, "/inventory/optical-links") + entry["optical_links"] = links_payload.get("optical_links", []) if isinstance(links_payload, dict) else [] + entry["optical_link_count"] = len(entry["optical_links"]) + return entry + + +async def list_domain_inventory( + scope: str = "all", + resource_kind: str = "all", + device_filter: str = "", + detail_level: str = "summary", + include_config_rules: bool = False, + include_ids: bool = False, + include_optical_links: bool = False, + user_id: str = "default", + session_id: str = "default", +) -> Dict[str, Any]: + """List local, remote, or all-domain inventory with explicit filtering.""" + + normalized_scope = (scope or "all").strip().lower().replace("_", "-") + normalized_detail_level = _normalize_detail_level(detail_level) + plan = _resource_plan(resource_kind) + normalized_device_filter = _normalize_device_filter(device_filter) + if normalized_device_filter: + plan = {**plan, "device_filter": normalized_device_filter} + peers = sorted(parse_peers()) + domain_specs = [] + if normalized_scope in {"all", "local"}: + domain_specs.append((DOMAIN_ID, "local")) + if normalized_scope in {"all", "remote", "peer", "peers"}: + domain_specs.extend((peer_domain, "peer") for peer_domain in peers) + if not domain_specs: + domain_specs.append((DOMAIN_ID, "local")) + domain_specs.extend((peer_domain, "peer") for peer_domain in peers) + + entries = await asyncio.gather( + *[ + _domain_inventory_entry( + domain_id, + role, + plan, + include_optical_links, + normalized_detail_level, + include_config_rules, + include_ids, + ) + for domain_id, role in domain_specs + ], + return_exceptions=True, + ) + domains: Dict[str, Any] = {} + failures: Dict[str, str] = {} + for (domain_id, role), entry in zip(domain_specs, entries): + if isinstance(entry, Exception): + failures[domain_id] = str(entry) + domains[domain_id] = { + "domain_id": domain_id, + "role": role, + "resource_kind": plan["resource_kind"], + "resource_family": plan["resource_family"], + "error": str(entry), + } + else: + domains[domain_id] = entry + + result = { + "ok": not failures, + "local_domain": DOMAIN_ID, + "scope": normalized_scope, + "resource_kind": plan["resource_kind"], + "resource_family": plan["resource_family"], + "device_filter": plan["device_filter"], + "detail_level": normalized_detail_level, + "include_config_rules": include_config_rules, + "include_ids": include_ids, + "domains_checked": sorted(domains), + "domains": domains, + "failures": failures, + "total_device_count": sum(entry.get("device_count", 0) for entry in domains.values()), + "total_endpoint_count": sum(entry.get("endpoint_count", 0) for entry in domains.values()), + "total_link_count": sum(entry.get("link_count", 0) for entry in domains.values()), + "total_optical_link_count": sum(entry.get("optical_link_count", 0) for entry in domains.values()), + "total_service_count": sum(entry.get("service_count", 0) for entry in domains.values()), + "total_connection_count": sum(entry.get("connection_count", 0) for entry in domains.values()), + } + record_event("domain_inventory", result, user_id=user_id, session_id=session_id) + return result + + +def _split_link_endpoint(endpoint: str) -> Dict[str, str]: + endpoint = endpoint.strip() + if "/" not in endpoint: + return {"device": endpoint, "endpoint": ""} + device, port = endpoint.split("/", 1) + return {"device": device.strip(), "endpoint": port.strip()} + + +def _connection_matches_from_links( + device_name: str, + domain_id: str, + links: List[Dict[str, Any]], + kind: str, +) -> List[Dict[str, Any]]: + target = device_name.strip().upper() + matches = [] + for link in links: + if not isinstance(link, dict): + continue + name = str(link.get("name") or "") + if "==" not in name: + continue + left_raw, right_raw = name.split("==", 1) + left = _split_link_endpoint(left_raw) + right = _split_link_endpoint(right_raw) + left_device = left["device"].upper() + right_device = right["device"].upper() + if target not in {left_device, right_device}: + continue + local_endpoint = left if left_device == target else right + remote_endpoint = right if left_device == target else left + matches.append( + { + "domain_id": domain_id, + "kind": kind, + "link_name": name, + "device": local_endpoint["device"], + "endpoint": local_endpoint["endpoint"], + "neighbor_device": remote_endpoint["device"], + "neighbor_endpoint": remote_endpoint["endpoint"], + } + ) + return matches + + +async def _local_device_connections( + device_name: str, + include_packet_links: bool, + include_optical_links: bool, +) -> Dict[str, Any]: + matches: List[Dict[str, Any]] = [] + if include_packet_links: + packet_payload = await call_mcp_tool("tfs_list_links", {}) + packet_links = packet_payload.get("links", []) if isinstance(packet_payload, dict) else [] + matches.extend(_connection_matches_from_links(device_name, DOMAIN_ID, packet_links, "packet")) + if include_optical_links: + optical_payload = await call_mcp_tool("tfs_list_optical_links", {}) + optical_links = optical_payload.get("optical_links", []) if isinstance(optical_payload, dict) else [] + matches.extend(_connection_matches_from_links(device_name, DOMAIN_ID, optical_links, "optical")) + return {"ok": True, "domain_id": DOMAIN_ID, "device_name": device_name.strip(), "connections": matches} + + +async def list_device_connections( + device_name: str, + include_packet_links: bool = True, + include_optical_links: bool = True, + user_id: str = "default", + session_id: str = "default", +) -> Dict[str, Any]: + """Return direct packet/optical neighbors for a device across MD-NAF domains.""" + + target = device_name.strip() + local_result = await _local_device_connections(target, include_packet_links, include_optical_links) + all_connections = list(local_result.get("connections", [])) + domains_checked = [DOMAIN_ID] + peer_results = {} + for peer_domain in parse_peers(): + domains_checked.append(peer_domain) + peer_result = await peer_get( + peer_domain, + ( + f"/devices/connections?device_name={target}" + f"&include_packet_links={str(include_packet_links).lower()}" + f"&include_optical_links={str(include_optical_links).lower()}" + ), + ) + peer_results[peer_domain] = peer_result + all_connections.extend(peer_result.get("connections", []) if isinstance(peer_result, dict) else []) + + neighbor_names = sorted({item["neighbor_device"] for item in all_connections if item.get("neighbor_device")}) + result = { + "ok": bool(all_connections), + "device_name": target, + "neighbors": neighbor_names, + "connections": all_connections, + "domains_checked": sorted(domains_checked), + "peer_results": peer_results, + "error": None if all_connections else "connections_not_found", + } + record_event("list_device_connections", result, user_id=user_id, session_id=session_id) + return result + + +async def list_mdnaf_domains( + include_devices: bool = True, + user_id: str = "default", + session_id: str = "default", +) -> Dict[str, Any]: + """Return the MD-NAF domain view, not the TFS context list.""" + + local_payload = await call_mcp_tool("tfs_list_devices", {}) + local_devices = local_payload.get("devices", []) if isinstance(local_payload, dict) else [] + domains: Dict[str, Any] = { + DOMAIN_ID: { + "domain_id": DOMAIN_ID, + "role": "local", + **(_summarize_devices(local_devices) if include_devices else {"device_count": len(local_devices)}), + } + } + peer_domains = sorted(parse_peers()) + peer_results = await asyncio.gather( + *[peer_device_inventory(peer_domain) for peer_domain in peer_domains], + return_exceptions=True, + ) + for peer_domain, peer_payload in zip(peer_domains, peer_results): + if isinstance(peer_payload, Exception): + domains[peer_domain] = { + "domain_id": peer_domain, + "role": "peer", + "error": str(peer_payload), + } + continue + peer_devices = peer_payload.get("devices", []) + domains[peer_domain] = { + "domain_id": peer_domain, + "role": "peer", + **(_summarize_devices(peer_devices) if include_devices else {"device_count": len(peer_devices)}), + } + result = { + "ok": True, + "local_domain": DOMAIN_ID, + "domain_count": len(domains), + "domains": domains, + "note": "These are MD-NAF controller domains. TFS contexts are separate; the default TFS context is normally admin.", + } + record_event("mdnaf_domain_summary", result, user_id=user_id, session_id=session_id) + return result + + +async def locate_device( + device_name: str, + user_id: str = "default", + session_id: str = "default", +) -> Dict[str, Any]: + """Locate a device or endpoint-like device across MD-NAF domains.""" + + target = device_name.strip() + local_payload = await call_mcp_tool("tfs_list_devices", {}) + local_devices = local_payload.get("devices", []) if isinstance(local_payload, dict) else [] + domains: Dict[str, List[str]] = { + DOMAIN_ID: sorted(str(item.get("name")) for item in local_devices if isinstance(item, dict) and item.get("name")) + } + peer_domains = sorted(parse_peers()) + peer_results = await asyncio.gather( + *[peer_device_inventory(peer_domain) for peer_domain in peer_domains], + return_exceptions=True, + ) + for peer_domain, peer_payload in zip(peer_domains, peer_results): + if isinstance(peer_payload, Exception): + domains[peer_domain] = [] + continue + domains[peer_domain] = sorted( + str(item.get("name")) for item in peer_payload.get("devices", []) if isinstance(item, dict) and item.get("name") + ) + matches = [] + target_upper = target.upper() + for domain_id, names in domains.items(): + for name in names: + upper = name.upper() + if upper == target_upper or upper.startswith(f"{target_upper}-") or target_upper in upper: + matches.append({"domain_id": domain_id, "device_name": name}) + result = { + "ok": bool(matches), + "device_name": target, + "matches": matches, + "domains_checked": sorted(domains), + "error": None if matches else "device_not_found", + } + record_event("locate_device", result, user_id=user_id, session_id=session_id) + return result + + +async def create_cross_domain_optical_service( + source_device: str, + destination_device: str, + service_name: str = "", + minimum_slot: int | None = None, + preferred_band: str = "", + capacity_gbps: float | None = None, + modulation_format: str = "", + channel_width_ghz: float | None = None, + user_id: str = "default", + session_id: str = "default", +) -> Dict[str, Any]: + """Create a two-domain optical service using static peer negotiation.""" + + trace = WorkflowTrace() + source_device = source_device.strip() + destination_device = destination_device.strip() + service_name = (service_name or f"xdom-{uuid.uuid4().hex[:10]}").strip() + minimum_slot = int(minimum_slot) if minimum_slot is not None else None + planning_started = time.perf_counter() + preferred_band, channel_width_ghz, normalized_spectrum_inputs = _normalize_spectrum_inputs( + preferred_band, + channel_width_ghz, + ) + sizing = _resolve_spectrum_sizing(capacity_gbps, modulation_format, channel_width_ghz) + trace.mark("planning", "domain_agent", "normalize_and_size_spectrum", "ok" if sizing.get("ok") else "failed", planning_started) + if not sizing.get("ok"): + result = { + "ok": False, + "phase": "spectrum_sizing", + "spectrum_sizing": sizing, + "normalized_spectrum_inputs": normalized_spectrum_inputs, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + required_slots = int(sizing["required_slots"]) + + local_inventory = await trace.timed("planning", "local_mcp", "tfs_list_devices", call_mcp_tool("tfs_list_devices", {})) + local_devices = local_inventory.get("devices", []) if isinstance(local_inventory, dict) else [] + source_resolution, destination_resolution = await asyncio.gather( + trace.timed( + "planning", + "endpoint_resolver_agent", + "resolve_source_endpoint_owner", + _resolve_optical_endpoint_device(source_device, local_devices), + parallel_group="endpoint_resolution", + ), + trace.timed( + "planning", + "endpoint_resolver_agent", + "resolve_destination_endpoint_owner", + _resolve_optical_endpoint_device(destination_device, local_devices), + parallel_group="endpoint_resolution", + ), + ) + + if not source_resolution.get("ok") or not destination_resolution.get("ok"): + result = { + "ok": False, + "phase": "resolve_optical_endpoints", + "source_resolution": source_resolution, + "destination_resolution": destination_resolution, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + requested_source_device = source_device + requested_destination_device = destination_device + source_device = str(source_resolution["resolved_device"]) + destination_device = str(destination_resolution["resolved_device"]) + source_owner = source_resolution["owner"] + destination_owner = destination_resolution["owner"] + source_domain = str(source_owner["domain_id"]).upper() + destination_domain = str(destination_owner["domain_id"]).upper() + if source_domain != DOMAIN_ID: + delegated = await trace.timed( + "planning", + f"domain_{source_domain}", + "delegate_to_source_domain", + peer_post( + source_domain, + "/workflows/cross-domain-optical", + { + "source_device": source_device, + "destination_device": destination_device, + "service_name": service_name, + "required_slots": required_slots, + "minimum_slot": minimum_slot, + "preferred_band": preferred_band, + "capacity_gbps": sizing.get("capacity_gbps"), + "modulation_format": sizing.get("modulation_format", ""), + "channel_width_ghz": sizing.get("channel_width_ghz"), + "user_id": user_id, + "session_id": session_id, + }, + ), + ) + result = { + **delegated, + "delegated": True, + "delegated_to_domain": source_domain, + "requested_from_domain": DOMAIN_ID, + "endpoint_resolution": { + "source": source_resolution, + "destination": destination_resolution, + }, + "workflow_trace": trace.export(), + "delegated_workflow_trace": delegated.get("workflow_trace", []) if isinstance(delegated, dict) else [], + } + record_event("cross_domain_create_delegated", result, user_id=user_id, session_id=session_id) + return result + + if destination_domain == DOMAIN_ID: + result = { + "ok": False, + "phase": "not_cross_domain", + "error": "both_endpoints_are_local", + "source_owner": source_owner, + "destination_owner": destination_owner, + "endpoint_resolution": { + "source": source_resolution, + "destination": destination_resolution, + }, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + route_started = time.perf_counter() + route = await _select_next_hop_domain(destination_domain, local_devices, trace) + if route.get("direct"): + trace.mark("planning", "domain_agent", f"select_next_hop_to_{destination_domain}", "ok", route_started) + if not route.get("ok"): + result = { + "ok": False, + "phase": "domain_routing", + "route": route, + "endpoint_resolution": { + "source": source_resolution, + "destination": destination_resolution, + }, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + return await _create_path_wide_cross_domain_optical_service( + trace=trace, + route=route, + service_name=service_name, + requested_source_device=requested_source_device, + requested_destination_device=requested_destination_device, + source_device=source_device, + destination_device=destination_device, + source_resolution=source_resolution, + destination_resolution=destination_resolution, + destination_domain=destination_domain, + sizing=sizing, + normalized_spectrum_inputs=normalized_spectrum_inputs, + required_slots=required_slots, + minimum_slot=minimum_slot, + preferred_band=preferred_band, + user_id=user_id, + session_id=session_id, + ) + + peer_domain = route["next_hop_domain"] + local_candidate_destination = _remote_domain_device(peer_domain) + peer_candidate_source = _remote_domain_device(DOMAIN_ID) + peer_candidate_destination = ( + destination_device if route.get("direct") else _remote_domain_device(destination_domain) + ) + local_candidates, peer_candidates = await asyncio.gather( + trace.timed( + "negotiation", + "local_mcp", + "tfs_compute_local_optical_connectivity_candidates", + compute_tfs_optical_connectivity_candidates( + source_device, + local_candidate_destination, + channel_width_ghz=sizing.get("channel_width_ghz"), + capacity_gbps=sizing.get("capacity_gbps"), + modulation_format=sizing.get("modulation_format", ""), + preferred_band=preferred_band or "c_slots", + ), + parallel_group="candidate_collection", + ), + trace.timed( + "negotiation", + f"domain_{peer_domain}", + "request_peer_tfs_optical_connectivity_candidates", + peer_post( + peer_domain, + "/spectrum/candidates", + { + "source_device": peer_candidate_source, + "destination_device": peer_candidate_destination, + "required_slots": required_slots, + "preferred_band": preferred_band or "c_slots", + "capacity_gbps": sizing.get("capacity_gbps"), + "modulation_format": sizing.get("modulation_format", ""), + "channel_width_ghz": sizing.get("channel_width_ghz"), + "user_id": user_id, + "session_id": session_id, + }, + ), + parallel_group="candidate_collection", + ), + ) + sizing = _controller_sizing_from_candidates(sizing, local_candidates, peer_candidates) + if not sizing.get("ok", True): + result = { + "ok": False, + "phase": "controller_candidate_sizing", + "spectrum_sizing": sizing, + "local_candidates": local_candidates, + "peer_candidates": peer_candidates, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + required_slots = int(sizing["required_slots"]) + selection_started = time.perf_counter() + selected = spectrum.intersect_and_select( + local_candidates.get("candidate_ranges", []), + peer_candidates.get("candidate_ranges", []), + required_slots, + minimum_slot=minimum_slot, + preferred_band=preferred_band, + ) + trace.mark("negotiation", "domain_agent", "intersect_and_select_spectrum", "ok" if selected.get("ok") else "failed", selection_started) + if not selected.get("ok"): + result = { + "ok": False, + "phase": "spectrum_intersection", + "local_candidates": local_candidates, + "peer_candidates": peer_candidates, + "selection": selected, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + selected_band = selected["preferred_band"] + local_service = _service_uuid_for_domain(DOMAIN_ID, service_name) + peer_service = _service_uuid_for_domain(peer_domain, service_name) + if route.get("direct"): + local_reservation, peer_reservation = await asyncio.gather( + trace.timed( + "negotiation", + "local_mcp", + "tfs_create_local_spectrum_reservation", + create_tfs_spectrum_reservation( + local_service, + selected["selected_range"], + local_candidates, + { + "role": "local", + "peer_domain": peer_domain, + "preferred_band": selected_band, + "requested_preferred_band": preferred_band, + "requested_minimum_slot": minimum_slot, + "correlation_id": service_name, + "spectrum_sizing": sizing, + }, + ), + parallel_group="spectrum_reservation", + ), + trace.timed( + "negotiation", + f"domain_{peer_domain}", + "tfs_create_peer_spectrum_reservation", + peer_post( + peer_domain, + "/spectrum/reserve", + { + "service_id": peer_service, + "selected_range": selected["selected_range"], + "candidate_bundle": peer_candidates, + "metadata": { + "role": "peer", + "requesting_domain": DOMAIN_ID, + "preferred_band": selected_band, + "requested_preferred_band": preferred_band, + "requested_minimum_slot": minimum_slot, + "correlation_id": service_name, + "spectrum_sizing": sizing, + }, + "user_id": user_id, + "session_id": session_id, + }, + ), + parallel_group="spectrum_reservation", + ), + ) + else: + local_reservation = await trace.timed( + "negotiation", + "local_mcp", + "tfs_create_local_spectrum_reservation", + create_tfs_spectrum_reservation( + local_service, + selected["selected_range"], + local_candidates, + { + "role": "local", + "peer_domain": peer_domain, + "preferred_band": selected_band, + "requested_preferred_band": preferred_band, + "requested_minimum_slot": minimum_slot, + "correlation_id": service_name, + "spectrum_sizing": sizing, + }, + ), + ) + peer_reservation = { + "ok": True, + "status": "delegated", + "reservation_id": "", + "domain_id": peer_domain, + "note": "next-hop workflow owns downstream reservations", + } + if not local_reservation.get("ok") or not peer_reservation.get("ok"): + local_release = {"ok": True, "status": "not_required"} + peer_release = {"ok": True, "status": "not_required"} + release_tasks = [] + if local_reservation.get("ok") and local_reservation.get("reservation_id"): + release_tasks.append(( + "local", + trace.timed( + "rollback", + "local_mcp", + "tfs_release_local_spectrum_reservation_after_reserve_failure", + release_tfs_spectrum_reservation( + local_reservation["reservation_id"], + local_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), + ), + parallel_group="reservation_failure_rollback", + ), + )) + if peer_reservation.get("ok") and peer_reservation.get("reservation_id"): + release_tasks.append(( + "peer", + trace.timed( + "rollback", + f"domain_{peer_domain}", + "tfs_release_peer_spectrum_reservation_after_reserve_failure", + peer_post( + peer_domain, + "/spectrum/update", + { + "reservation_id": peer_reservation.get("reservation_id", ""), + "context_uuid": peer_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), + "status": "rolled_back", + "user_id": user_id, + "session_id": session_id, + }, + ), + parallel_group="reservation_failure_rollback", + ), + )) + if release_tasks: + release_results = await asyncio.gather(*(task for _, task in release_tasks), return_exceptions=True) + for (scope, _), release_result in zip(release_tasks, release_results): + normalized_release = ( + {"ok": False, "phase": "release_exception", "error": str(release_result)} + if isinstance(release_result, Exception) + else release_result + ) + if scope == "local": + local_release = normalized_release + else: + peer_release = normalized_release + result = { + "ok": False, + "phase": ( + "reserve_peer" + if local_reservation.get("ok") and not peer_reservation.get("ok") + else "reserve_local" + if not local_reservation.get("ok") and peer_reservation.get("ok") + else "reserve_spectrum" + ), + "local_reservation": local_reservation, + "peer_reservation": peer_reservation, + "local_release": local_release, + "peer_release": peer_release, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + peer_create_awaitable = ( + peer_post( + peer_domain, + "/services/optical", + { + "source_device": _remote_domain_device(DOMAIN_ID), + "destination_device": destination_device, + "service_name": peer_service, + "preferred_band": selected_band, + "capacity_gbps": sizing.get("capacity_gbps"), + "modulation_format": sizing.get("modulation_format", ""), + "channel_width_ghz": sizing.get("channel_width_ghz"), + "required_slots_override": sizing.get("required_slots"), + "slot_width_ghz_override": sizing.get("slot_width_ghz"), + "user_id": user_id, + "session_id": session_id, + }, + ) + if route.get("direct") + else peer_post( + peer_domain, + "/workflows/cross-domain-optical", + { + "source_device": _remote_domain_device(DOMAIN_ID), + "destination_device": destination_device, + "service_name": service_name, + "minimum_slot": minimum_slot, + "preferred_band": selected_band, + "capacity_gbps": sizing.get("capacity_gbps"), + "modulation_format": sizing.get("modulation_format", ""), + "channel_width_ghz": sizing.get("channel_width_ghz"), + "user_id": user_id, + "session_id": session_id, + }, + ) + ) + peer_operation = "provision_peer_optical_segment" if route.get("direct") else "delegate_multihop_peer_workflow" + local_create, peer_create = await asyncio.gather( + trace.timed( + "local_provisioning", + "domain_local", + "provision_local_optical_segment", + create_optical_connectivity_service( + source_device, + _remote_domain_device(peer_domain), + service_name=local_service, + preferred_band=selected_band, + capacity_gbps=sizing.get("capacity_gbps"), + modulation_format=sizing.get("modulation_format", ""), + channel_width_ghz=sizing.get("channel_width_ghz"), + required_slots_override=sizing.get("required_slots"), + slot_width_ghz_override=sizing.get("slot_width_ghz"), + ), + parallel_group="service_provisioning", + ), + trace.timed( + "remote_provisioning", + f"domain_{peer_domain}", + peer_operation, + peer_create_awaitable, + parallel_group="service_provisioning", + ), + ) + if not local_create.get("ok") or not peer_create.get("ok"): + rollback_tasks = [ + ( + "local_release", + trace.timed( + "rollback", + "local_mcp", + "tfs_release_local_spectrum_reservation_after_create_failure", + release_tfs_spectrum_reservation( + local_reservation["reservation_id"], + local_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), + ), + parallel_group="create_failure_rollback", + ), + ) + ] + if local_create.get("ok"): + rollback_tasks.append(( + "local_delete", + trace.timed( + "rollback", + "local_mcp", + "tfs_delete_local_service_after_create_failure", + delete_service(local_service), + parallel_group="create_failure_rollback", + ), + )) + if peer_create.get("ok"): + peer_delete_awaitable = ( + peer_post( + peer_domain, + "/services/delete", + {"service_uuid": peer_service, "user_id": user_id, "session_id": session_id}, + ) + if route.get("direct") + else peer_post( + peer_domain, + "/workflows/cross-domain-optical/delete", + {"request_id": service_name, "user_id": user_id, "session_id": session_id}, + ) + ) + rollback_tasks.append(( + "peer_delete", + trace.timed( + "rollback", + f"domain_{peer_domain}", + "tfs_delete_peer_service_after_create_failure", + peer_delete_awaitable, + parallel_group="create_failure_rollback", + ), + )) + if peer_reservation.get("reservation_id"): + rollback_tasks.append(( + "peer_release", + trace.timed( + "rollback", + f"domain_{peer_domain}", + "tfs_release_peer_spectrum_reservation_after_create_failure", + peer_post( + peer_domain, + "/spectrum/update", + { + "reservation_id": peer_reservation.get("reservation_id", ""), + "context_uuid": peer_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), + "status": "rolled_back", + "user_id": user_id, + "session_id": session_id, + }, + ), + parallel_group="create_failure_rollback", + ), + )) + rollback_results = await asyncio.gather(*(task for _, task in rollback_tasks), return_exceptions=True) + rollback = { + name: ( + {"ok": False, "phase": "rollback_exception", "error": str(value)} + if isinstance(value, Exception) + else value + ) + for (name, _), value in zip(rollback_tasks, rollback_results) + } + result = { + "ok": False, + "phase": "create_segments", + "local_reservation": local_reservation, + "peer_reservation": peer_reservation, + "local_release": rollback.get("local_release", {"ok": True, "status": "not_required"}), + "peer_release": rollback.get("peer_release", {"ok": True, "status": "not_required"}), + "local_delete": rollback.get("local_delete", {"ok": True, "status": "not_required"}), + "peer_delete": rollback.get("peer_delete", {"ok": True, "status": "not_required"}), + "peer_create": peer_create, + "local_create": local_create, + "workflow_trace": trace.export(), + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + if peer_reservation.get("reservation_id"): + local_commit, peer_commit = await asyncio.gather( + trace.timed( + "verification", + "local_mcp", + "tfs_consume_local_spectrum_reservation", + consume_tfs_spectrum_reservation( + local_reservation["reservation_id"], + local_service, + local_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), + ), + parallel_group="reservation_commit", + ), + trace.timed( + "verification", + f"domain_{peer_domain}", + "tfs_consume_peer_spectrum_reservation", + peer_post( + peer_domain, + "/spectrum/update", + { + "reservation_id": peer_reservation["reservation_id"], + "context_uuid": peer_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), + "status": "committed", + "service_id": peer_service, + "user_id": user_id, + "session_id": session_id, + }, + ), + parallel_group="reservation_commit", + ), + ) + else: + local_commit = await trace.timed( + "verification", + "local_mcp", + "tfs_consume_local_spectrum_reservation", + consume_tfs_spectrum_reservation( + local_reservation["reservation_id"], + local_service, + local_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), + ), + ) + peer_commit = {"ok": True, "status": "delegated", "domain_id": peer_domain} + if not local_commit.get("ok") or not peer_commit.get("ok", True): + peer_delete_awaitable = ( + peer_post( + peer_domain, + "/services/delete", + {"service_uuid": peer_service, "user_id": user_id, "session_id": session_id}, + ) + if route.get("direct") + else peer_post( + peer_domain, + "/workflows/cross-domain-optical/delete", + {"request_id": service_name, "user_id": user_id, "session_id": session_id}, + ) + ) + rollback_tasks = [ + ( + "local_delete", + trace.timed( + "rollback", + "local_mcp", + "tfs_delete_local_service_after_commit_failure", + delete_service(local_service), + parallel_group="commit_failure_rollback", + ), + ), + ( + "peer_delete", + trace.timed( + "rollback", + f"domain_{peer_domain}", + "tfs_delete_peer_service_after_commit_failure", + peer_delete_awaitable, + parallel_group="commit_failure_rollback", + ), + ), + ( + "local_release", + trace.timed( + "rollback", + "local_mcp", + "tfs_release_local_reservation_after_commit_failure", + release_tfs_spectrum_reservation( + local_reservation["reservation_id"], + local_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), + ), + parallel_group="commit_failure_rollback", + ), + ), + ] + if peer_reservation.get("reservation_id"): + rollback_tasks.append(( + "peer_release", + trace.timed( + "rollback", + f"domain_{peer_domain}", + "tfs_release_peer_reservation_after_commit_failure", + peer_post( + peer_domain, + "/spectrum/update", + { + "reservation_id": peer_reservation.get("reservation_id", ""), + "context_uuid": peer_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), + "status": "rolled_back", + "user_id": user_id, + "session_id": session_id, + }, + ), + parallel_group="commit_failure_rollback", + ), + )) + rollback_results = await asyncio.gather(*(task for _, task in rollback_tasks), return_exceptions=True) + rollback = { + name: ( + {"ok": False, "phase": "rollback_exception", "error": str(value)} + if isinstance(value, Exception) + else value + ) + for (name, _), value in zip(rollback_tasks, rollback_results) + } + result = { + "ok": False, + "phase": "commit_reservations", + "request_id": service_name, + "local_service_uuid": local_service, + "peer_service_uuid": peer_service, + "local_create": local_create, + "peer_create": peer_create, + "local_reservation": local_reservation, + "peer_reservation": peer_reservation, + "local_commit": local_commit, + "peer_commit": peer_commit, + "local_delete": rollback.get("local_delete", {"ok": True, "status": "not_required"}), + "peer_delete": rollback.get("peer_delete", {"ok": True, "status": "not_required"}), + "local_release": rollback.get("local_release", {"ok": True, "status": "not_required"}), + "peer_release": rollback.get("peer_release", {"ok": True, "status": "not_required"}), + "workflow_trace": trace.export(), + "peer_workflow_trace": peer_create.get("workflow_trace", []) if isinstance(peer_create, dict) else [], + "local_workflow_trace": local_create.get("workflow_trace", []) if isinstance(local_create, dict) else [], + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + result = { + "ok": True, + "phase": "active", + "request_id": service_name, + "requested_source_device": requested_source_device, + "requested_destination_device": requested_destination_device, + "source_device": source_device, + "destination_device": destination_device, + "endpoint_resolution": { + "source": source_resolution, + "destination": destination_resolution, + }, + "local_domain": DOMAIN_ID, + "peer_domain": peer_domain, + "destination_domain": destination_domain, + "domain_path": route.get("domain_path", [DOMAIN_ID, peer_domain]), + "route": route, + "preferred_band": selected_band, + "requested_preferred_band": preferred_band, + "requested_minimum_slot": minimum_slot, + "spectrum_sizing": sizing, + "normalized_spectrum_inputs": normalized_spectrum_inputs, + "selection": selected, + "local_service_uuid": local_service, + "peer_service_uuid": peer_service, + "downstream_service_uuids": ( + 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] + ), + "segment_service_uuids": [ + local_service, + *( + 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] + ), + ], + "local_create": local_create, + "peer_create": peer_create, + "local_reservation": local_reservation, + "peer_reservation": peer_reservation, + "local_commit": local_commit, + "peer_commit": peer_commit, + "workflow_trace": trace.export(), + "peer_workflow_trace": peer_create.get("workflow_trace", []) if isinstance(peer_create, dict) else [], + "local_workflow_trace": local_create.get("workflow_trace", []) if isinstance(local_create, dict) else [], + } + record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + return result + + +async def inspect_cross_domain_inventory( + include_optical_links: bool = True, + user_id: str = "default", + session_id: str = "default", +) -> Dict[str, Any]: + """Inspect local and peer-domain inventory through the static peer API.""" + + result = await list_domain_inventory( + scope="all", + resource_kind="all", + include_optical_links=include_optical_links, + user_id=user_id, + session_id=session_id, + ) + record_event("cross_domain_inventory", result, user_id=user_id, session_id=session_id) + return result + + +async def delete_cross_domain_optical_service( + request_id: str, + peer_domain: str = "", + user_id: str = "default", + session_id: str = "default", + peer_timeout_seconds: float = 45.0, +) -> Dict[str, Any]: + """Delete cross-domain service segments wherever they are found.""" + + trace = WorkflowTrace() + request_id = request_id.strip() + peer_domain = peer_domain.strip().upper() + stored_targets = _stored_service_targets_for_request(request_id) + domain_ids = {DOMAIN_ID, *parse_peers().keys()} + service_candidates_by_domain = { + domain_id: sorted( + { + request_id, + _service_uuid_for_domain(domain_id, request_id), + f"local-{request_id}", + f"peer-{request_id}", + } + - {""} + ) + for domain_id in domain_ids + } + if stored_targets.get("ok"): + for domain_id, service_ids in stored_targets.get("domains", {}).items(): + hints = set(service_candidates_by_domain.get(domain_id, [])) + hints.update(str(item) for item in service_ids if str(item).strip()) + service_candidates_by_domain[domain_id] = sorted(hints) + local_service = _service_uuid_for_domain(DOMAIN_ID, request_id) + peer_service = _service_uuid_for_domain(peer_domain, request_id) if peer_domain else "" + local_candidate_services = service_candidates_by_domain.get(DOMAIN_ID, [local_service]) + all_candidate_services = sorted({item for values in service_candidates_by_domain.values() for item in values}) + + async def bounded_local_delete_by_request() -> Dict[str, Any]: + try: + delete_result = await asyncio.wait_for( + delete_local_services_for_request( + request_id, + candidate_service_ids=local_candidate_services, + ), + timeout=peer_timeout_seconds, + ) + except asyncio.TimeoutError: + delete_result = { + "ok": False, + "phase": "delete_timeout", + "request_id": request_id, + "timeout_seconds": peer_timeout_seconds, + } + return { + "scope": "local", + "domain_id": DOMAIN_ID, + "request_id": request_id, + "delete": delete_result, + } + + async def bounded_peer_delete_by_request(target_peer: str) -> Dict[str, Any]: + peer_candidate_services = service_candidates_by_domain.get(target_peer, []) + try: + peer_result = await asyncio.wait_for( + peer_post( + target_peer, + "/services/delete-by-request", + { + "request_id": request_id, + "correlation_id": request_id, + "candidate_service_ids": peer_candidate_services, + "user_id": user_id, + "session_id": session_id, + }, + ), + timeout=peer_timeout_seconds, + ) + except asyncio.TimeoutError: + peer_result = { + "ok": False, + "phase": "delete_timeout", + "request_id": request_id, + "timeout_seconds": peer_timeout_seconds, + } + except Exception as exc: # pylint: disable=broad-exception-caught + peer_result = { + "ok": False, + "phase": "delete_error", + "request_id": request_id, + "error": str(exc), + } + return {"scope": "peer", "domain_id": target_peer, "request_id": request_id, "delete": peer_result} + + if stored_targets.get("ok"): + stored_domains = set(stored_targets.get("domains", {})) + target_peers = sorted(domain for domain in stored_domains if domain != DOMAIN_ID and domain in parse_peers()) + if peer_domain and peer_domain in parse_peers() and peer_domain not in target_peers: + target_peers.append(peer_domain) + else: + target_peers = [peer_domain] if peer_domain and peer_domain in parse_peers() else sorted(parse_peers()) + local_delete_tasks = [ + trace.timed( + "deletion", + f"domain_{DOMAIN_ID}", + "path_wide_delete_local_by_request", + bounded_local_delete_by_request(), + parallel_group="path_wide_delete_by_request", + input_payload={"domain_id": DOMAIN_ID, "request_id": request_id}, + ) + ] + peer_delete_tasks = [ + trace.timed( + "deletion", + f"domain_{target_peer}", + "path_wide_delete_peer_by_request", + bounded_peer_delete_by_request(target_peer), + parallel_group="path_wide_delete_by_request", + input_payload={"domain_id": target_peer, "request_id": request_id}, + ) + for target_peer in target_peers + ] + delete_results = await asyncio.gather(*(local_delete_tasks + peer_delete_tasks)) + local_deletes = [item for item in delete_results if item.get("scope") == "local"] + peer_deletes = [item for item in delete_results if item.get("scope") == "peer"] + + nested_workflow_trace = [] + for item in delete_results: + delete_result = item.get("delete", {}) + if isinstance(delete_result, dict): + nested_workflow_trace.extend(delete_result.get("workflow_trace", []) or []) + if nested_workflow_trace: + trace.mark( + "deletion", + "delete_orchestrator", + "path_wide_delete_nested_trace_available", + output_payload={"nested_span_count": len(nested_workflow_trace)}, + ) + + failures = [ + item + for item in local_deletes + if not item["delete"].get("ok") + ] + [ + item + for item in peer_deletes + if not item["delete"].get("ok") + ] + result = { + "ok": not failures, + "phase": "deleted", + "request_id": request_id, + "local_domain": DOMAIN_ID, + "peer_domain": peer_domain or "all", + "local_service_uuid": local_service, + "peer_service_uuid": peer_service, + "stored_service_targets": stored_targets, + "candidate_services": all_candidate_services, + "service_candidates_by_domain": service_candidates_by_domain, + "local_deletes": local_deletes, + "peer_deletes": peer_deletes, + "workflow_trace": trace.export(), + "nested_workflow_trace": nested_workflow_trace, + "failures": failures, + } + record_event("cross_domain_delete", result, user_id=user_id, session_id=session_id) + return result diff --git a/src/agentic/tests/__init__.py b/src/agentic/tests/__init__.py new file mode 100644 index 000000000..b53987a4e --- /dev/null +++ b/src/agentic/tests/__init__.py @@ -0,0 +1,13 @@ +# 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. diff --git a/src/agentic/tests/conftest.py b/src/agentic/tests/conftest.py new file mode 100644 index 000000000..2d63b92b0 --- /dev/null +++ b/src/agentic/tests/conftest.py @@ -0,0 +1,46 @@ +# 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. + +"""Reusable fixtures for Agentic component tests.""" + +from __future__ import annotations + +import json +from types import SimpleNamespace + +import pytest + + +class MockMcpSession: + """In-process MCP session mock for Agentic MCP client tests.""" + + def __init__(self, replies=None) -> None: + self.calls = [] + self.replies = replies or {} + + async def call_tool(self, tool_name, arguments): + self.calls.append((tool_name, arguments)) + payload = self.replies.get( + tool_name, + {"ok": True, "tool": tool_name, "arguments": arguments}, + ) + return SimpleNamespace( + isError=False, + content=[SimpleNamespace(text=json.dumps(payload))], + ) + + +@pytest.fixture +def mock_mcp_session(): + return MockMcpSession diff --git a/src/agentic/tests/test_integration.py b/src/agentic/tests/test_integration.py new file mode 100644 index 000000000..5ce726ac4 --- /dev/null +++ b/src/agentic/tests/test_integration.py @@ -0,0 +1,179 @@ +# 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. + +"""Offline integration tests for the installed Agentic package.""" + +from __future__ import annotations + +import asyncio +import importlib +from types import SimpleNamespace + + +def test_agentic_service_package_imports() -> None: + service = importlib.import_module("agentic.service") + + assert service.__name__ == "agentic.service" + + +def test_a2a_agent_card_uses_configured_domain_url(monkeypatch) -> None: + monkeypatch.setenv("ADK_A2A_PUBLIC_URL", + "http://agentic-a.example/agentic") + + transport = importlib.import_module("agentic.service.a2a_transport") + card = transport.build_agent_card() + + assert str(card.url) == "http://agentic-a.example/agentic/a2a" + assert card.name + + +def test_agent_modules_import() -> None: + module_names = [ + "agentic.service.agents.granular_root", + "agentic.service.agents.inventory", + "agentic.service.agents.inventory_granular", + "agentic.service.agents.mutation", + "agentic.service.agents.optical_create", + "agentic.service.agents.optical_delete", + "agentic.service.agents.retrieval", + "agentic.service.agents.root", + "agentic.service.agents.service", + "agentic.service.agents.single", + ] + + for module_name in module_names: + assert importlib.import_module(module_name) + + +def test_mcp_client_uses_process_session(monkeypatch, mock_mcp_session) -> None: + mcp_client = importlib.import_module("agentic.service.tools.mcp_client") + + fake_session = mock_mcp_session() + monkeypatch.setattr(mcp_client, "_SESSION", fake_session) + monkeypatch.setattr(mcp_client, "_EXIT_STACK", SimpleNamespace()) + + reply = asyncio.run( + mcp_client.call_mcp_tool( + "tfs_list_devices", + {"context_uuid": "admin"}, + ) + ) + + assert reply == { + "ok": True, + "tool": "tfs_list_devices", + "arguments": {"context_uuid": "admin"}, + } + assert fake_session.calls == [ + ("tfs_list_devices", {"context_uuid": "admin"}) + ] + + +def test_inventory_workflow_reads_mocked_mcp(monkeypatch) -> None: + workflow = importlib.import_module( + "agentic.service.tools.service_workflow") + recorded_events = [] + + async def fake_call_mcp_tool( + tool_name, + arguments, + ): # pylint: disable=unused-argument + replies = { + "tfs_list_devices": { + "devices": [ + { + "name": "DC1", + "type": "emu-datacenter", + "device_id": {"device_uuid": {"uuid": "DC1"}}, + }, + { + "name": "R1", + "type": "emu-packet-router", + "device_id": {"device_uuid": {"uuid": "R1"}}, + }, + ] + }, + "tfs_list_links": {"links": [{"name": "DC1==R1"}]}, + "tfs_list_optical_links": { + "optical_links": [{"name": "DC1-TP1==ROADM1"}] + }, + "tfs_list_services": {"services": [{"name": "svc-a"}]}, + "tfs_list_all_connections": {"connections": [{"name": "conn-a"}]}, + } + return replies[tool_name] + + monkeypatch.setattr(workflow, "call_mcp_tool", fake_call_mcp_tool) + monkeypatch.setattr(workflow, "parse_peers", lambda: {}) + monkeypatch.setattr( + workflow, + "record_event", + lambda *args, **kwargs: recorded_events.append((args, kwargs)), + ) + + reply = asyncio.run( + workflow.list_domain_inventory( + scope="local", + resource_kind="all", + session_id="test", + ) + ) + + assert reply["ok"] is True + assert reply["total_device_count"] == 2 + assert reply["total_link_count"] == 1 + assert reply["total_optical_link_count"] == 1 + assert reply["total_service_count"] == 1 + assert reply["total_connection_count"] == 1 + assert recorded_events + + +def test_agent_run_uses_mocked_llm_runner(monkeypatch) -> None: + domain_api = importlib.import_module("agentic.service.domain_api") + + class FakeRunner: + async def run_async(self, **kwargs): # pylint: disable=unused-argument + part = SimpleNamespace( + text="Mocked answer", + function_call=None, + function_response=None, + ) + yield SimpleNamespace( + content=SimpleNamespace(parts=[part]), + author="mock_agent", + model_version="mock-llm", + ) + + async def fake_ensure_agent_session( + user_id, + session_id, + ): # pylint: disable=unused-argument + return None + + monkeypatch.setattr(domain_api, "_agent_runner", FakeRunner()) + monkeypatch.setattr(domain_api, "_ensure_agent_session", + fake_ensure_agent_session) + + reply = asyncio.run( + domain_api._run_agent_internal( + user_id="ci", + session_id="mock-session", + prompt="list devices", + timeout_seconds=1.0, + record=False, + ) + ) + + assert reply["ok"] is True + assert reply["final_answer"] == "Mocked answer" + assert reply["events"][0]["author"] == "mock_agent" diff --git a/src/agentic/tests/test_unitary.py b/src/agentic/tests/test_unitary.py new file mode 100644 index 000000000..f021cd789 --- /dev/null +++ b/src/agentic/tests/test_unitary.py @@ -0,0 +1,58 @@ +# 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.Config import ( + DEFAULT_AGENTIC_GRAPH, + DEFAULT_AGENTIC_MCP_URL, + DEFAULT_AGENTIC_MODEL, + get_agentic_graph, + get_agentic_mcp_url, + get_agentic_model, + get_agentic_port, +) +from agentic.service import peer_client +from agentic.service.spectrum import spectrum_request + + +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_model() == DEFAULT_AGENTIC_MODEL + assert get_agentic_graph() == DEFAULT_AGENTIC_GRAPH + assert get_agentic_mcp_url() == DEFAULT_AGENTIC_MCP_URL + + +def test_parse_peers(monkeypatch) -> None: + peer_spec = "B=http://b.example/a2a, 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", + "C": "http://c.example/a2a", + } + + +def test_required_slots_from_channel_width() -> None: + result = spectrum_request(channel_width_ghz=50.0) + assert result["slot_width_ghz"] == 6.25 + assert result["required_slots"] == 8 + + +def test_required_slots_from_capacity_and_modulation() -> None: + result = spectrum_request(capacity_gbps=100.0, modulation_format="dp-qpsk") + assert result["slot_width_ghz"] == 6.25 + assert result["required_slots"] > 0 diff --git a/src/common/Constants.py b/src/common/Constants.py index 811baf6be..2bcd49bfe 100644 --- a/src/common/Constants.py +++ b/src/common/Constants.py @@ -37,6 +37,7 @@ INTERDOMAIN_TOPOLOGY_NAME = 'inter' # contains the abstract inter-domain top # Default service names class ServiceNameEnum(Enum): AUTOMATION = 'automation' + AGENTIC = 'agentic' CONTEXT = 'context' DEVICE = 'device' SERVICE = 'service' @@ -128,6 +129,7 @@ DEFAULT_SERVICE_GRPC_PORTS = { # Default HTTP/REST-API service ports DEFAULT_SERVICE_HTTP_PORTS = { + ServiceNameEnum.AGENTIC.value : 8800, ServiceNameEnum.MCP_SERVER.value : 3002, ServiceNameEnum.NBI .value : 8080, ServiceNameEnum.WEBUI.value : 8004, @@ -136,6 +138,7 @@ DEFAULT_SERVICE_HTTP_PORTS = { # Default HTTP/REST-API service base URLs DEFAULT_SERVICE_HTTP_BASEURLS = { + ServiceNameEnum.AGENTIC.value : None, ServiceNameEnum.MCP_SERVER.value : None, ServiceNameEnum.NBI .value : None, ServiceNameEnum.WEBUI.value : None, -- GitLab From 73045b69d0b313ad89f10afd981f7b4ded17b895 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 21 Jul 2026 16:43:11 +0000 Subject: [PATCH 2/4] Clean up agentic service workflow --- src/agentic/service/tools/service_workflow.py | 1909 +++++++++-------- src/agentic/tests/test_unitary.py | 34 + 2 files changed, 1033 insertions(+), 910 deletions(-) diff --git a/src/agentic/service/tools/service_workflow.py b/src/agentic/service/tools/service_workflow.py index 1f998d747..45e0f8924 100644 --- a/src/agentic/service/tools/service_workflow.py +++ b/src/agentic/service/tools/service_workflow.py @@ -23,13 +23,28 @@ import time import uuid from typing import Any, Dict, List -from agentic.Config import get_tfs_default_context_uuid, get_tfs_default_topology_uuid +from agentic.Config import ( + get_tfs_default_context_uuid, + get_tfs_default_topology_uuid, +) from agentic.service import spectrum -from agentic.service.peer_client import find_endpoint_owner, parse_peers, peer_device_inventory, peer_get, peer_post -from agentic.service.session_store import find_events_by_request_id, record_event +from agentic.service.peer_client import ( + find_endpoint_owner, + parse_peers, + peer_device_inventory, + peer_get, + peer_post, +) +from agentic.service.session_store import ( + find_events_by_request_id, + record_event, +) from agentic.service.settings import DOMAIN_ID from agentic.service.tools.mcp_client import call_mcp_tool -from agentic.service.tools.service_roles import controller_domain_from_actor, role_for_step +from agentic.service.tools.service_roles import ( + controller_domain_from_actor, + role_for_step, +) DEFAULT_CONTEXT_UUID = get_tfs_default_context_uuid() @@ -75,7 +90,12 @@ class WorkflowTrace: "duration_ms": round((end - start) * 1000.0, 3), "status": status, } - _add_profile_metadata(row, input_payload=input_payload, output_payload=result, error=error) + _add_profile_metadata( + row, + input_payload=input_payload, + output_payload=result, + error=error, + ) row.update(_agent_metadata(phase, actor, operation)) if parallel_group: row["parallel_group"] = parallel_group @@ -103,7 +123,12 @@ class WorkflowTrace: "duration_ms": round((end - phase_start) * 1000.0, 3), "status": status, } - _add_profile_metadata(row, input_payload=input_payload, output_payload=output_payload, error=error) + _add_profile_metadata( + row, + input_payload=input_payload, + output_payload=output_payload, + error=error, + ) row.update(_agent_metadata(phase, actor, operation)) if parallel_group: row["parallel_group"] = parallel_group @@ -129,7 +154,12 @@ def _add_profile_metadata( def _payload_size_bytes(payload: Any) -> int: try: - encoded = json.dumps(payload, sort_keys=True, separators=(",", ":"), default=str).encode("utf-8") + encoded = json.dumps( + payload, + sort_keys=True, + separators=(",", ":"), + default=str, + ).encode("utf-8") except (TypeError, ValueError): encoded = str(payload).encode("utf-8") return len(encoded) @@ -155,7 +185,11 @@ def _endpoint_id(device_uuid: str, endpoint_uuid: str) -> Dict[str, Any]: def _device_uuid(device: Dict[str, Any]) -> str: - return str(device.get("device_id", {}).get("device_uuid", {}).get("uuid", "")) + return str( + device.get("device_id", {}) + .get("device_uuid", {}) + .get("uuid", "") + ) def _device_name(device: Dict[str, Any]) -> str: @@ -163,10 +197,18 @@ def _device_name(device: Dict[str, Any]) -> str: def _endpoint_name(endpoint: Dict[str, Any]) -> str: - return str(endpoint.get("name") or endpoint.get("endpoint_id", {}).get("endpoint_uuid", {}).get("uuid", "")) + return str( + endpoint.get("name") + or endpoint.get("endpoint_id", {}) + .get("endpoint_uuid", {}) + .get("uuid", "") + ) -def _resolve_device_uuid_from_details(devices: List[Dict[str, Any]], device_name_or_uuid: str) -> str: +def _resolve_device_uuid_from_details( + devices: List[Dict[str, Any]], + device_name_or_uuid: str, +) -> str: target = str(device_name_or_uuid).strip() for device in devices: if target in {_device_uuid(device), _device_name(device)}: @@ -175,43 +217,84 @@ def _resolve_device_uuid_from_details(devices: List[Dict[str, Any]], device_name def _resolve_endpoint_uuid_from_details( - devices: List[Dict[str, Any]], device_uuid: str, preferred_endpoint_name: str + devices: List[Dict[str, Any]], + device_uuid: str, + preferred_endpoint_name: str, ) -> str: for device in devices: if _device_uuid(device) != device_uuid: continue endpoints = device.get("device_endpoints", []) for endpoint in endpoints: - endpoint_uuid = str(endpoint.get("endpoint_id", {}).get("endpoint_uuid", {}).get("uuid", "")) - if preferred_endpoint_name in {endpoint_uuid, _endpoint_name(endpoint)}: + endpoint_uuid = str( + endpoint.get("endpoint_id", {}) + .get("endpoint_uuid", {}) + .get("uuid", "") + ) + if preferred_endpoint_name in { + endpoint_uuid, + _endpoint_name(endpoint), + }: return endpoint_uuid if endpoints: - return str(endpoints[0].get("endpoint_id", {}).get("endpoint_uuid", {}).get("uuid", preferred_endpoint_name)) + return str( + endpoints[0] + .get("endpoint_id", {}) + .get("endpoint_uuid", {}) + .get("uuid", preferred_endpoint_name) + ) return preferred_endpoint_name async def _default_context_and_topology() -> Dict[str, str]: contexts_reply = await call_mcp_tool("tfs_list_contexts", {}) - contexts = contexts_reply.get("contexts", []) if isinstance(contexts_reply, dict) else [] + contexts = ( + contexts_reply.get("contexts", []) + if isinstance(contexts_reply, dict) + else [] + ) preferred_context = (DEFAULT_CONTEXT_UUID or "admin").strip() preferred_topology = (DEFAULT_TOPOLOGY_UUID or "admin").strip() for context in contexts: - context_uuid = str(context.get("context_id", {}).get("context_uuid", {}).get("uuid", "")) + context_uuid = str( + context.get("context_id", {}) + .get("context_uuid", {}) + .get("uuid", "") + ) context_name = str(context.get("name", "")) if preferred_context not in {context_uuid, context_name}: continue for topology_id in context.get("topology_ids", []): - topology_uuid = str(topology_id.get("topology_uuid", {}).get("uuid", "")) + topology_uuid = str( + topology_id.get("topology_uuid", {}).get("uuid", "") + ) if preferred_topology in {topology_uuid, "admin"}: - return {"context_uuid": context_uuid, "topology_uuid": topology_uuid} + return { + "context_uuid": context_uuid, + "topology_uuid": topology_uuid, + } if contexts: context = contexts[0] - context_uuid = str(context.get("context_id", {}).get("context_uuid", {}).get("uuid", preferred_context)) + context_uuid = str( + context.get("context_id", {}) + .get("context_uuid", {}) + .get("uuid", preferred_context) + ) topology_ids = context.get("topology_ids", []) if topology_ids: - topology_uuid = str(topology_ids[0].get("topology_uuid", {}).get("uuid", preferred_topology)) - return {"context_uuid": context_uuid, "topology_uuid": topology_uuid} - return {"context_uuid": preferred_context, "topology_uuid": preferred_topology} + topology_uuid = str( + topology_ids[0] + .get("topology_uuid", {}) + .get("uuid", preferred_topology) + ) + return { + "context_uuid": context_uuid, + "topology_uuid": topology_uuid, + } + return { + "context_uuid": preferred_context, + "topology_uuid": preferred_topology, + } def _service_id(context_uuid: str, service_uuid: str) -> Dict[str, Any]: @@ -232,7 +315,8 @@ def _constraint(constraint_type: str, constraint_value: str) -> Dict[str, Any]: def _service_status(payload: Dict[str, Any]) -> str: - status = payload.get("service_status", {}) if isinstance(payload, dict) else {} + status = payload.get("service_status", {}) if isinstance( + payload, dict) else {} raw = status.get("service_status") if isinstance(status, dict) else status if raw in (2, "2", "SERVICESTATUS_ACTIVE", "ACTIVE"): return "ACTIVE" @@ -249,7 +333,8 @@ def _service_status(payload: Dict[str, Any]) -> str: def _device_names(payload: Dict[str, Any]) -> List[str]: devices = payload.get("devices", []) if isinstance(payload, dict) else [] - return [str(item.get("name") or "") for item in devices if isinstance(item, dict) and item.get("name")] + return [str(item.get("name") or "") + for item in devices if isinstance(item, dict) and item.get("name")] def _optical_endpoint_candidates(device_name: str) -> List[str]: @@ -275,7 +360,11 @@ async def _resolve_optical_endpoint_device( "requested_device": requested_device, "resolved_device": candidate, "inferred": candidate != requested_device, - "inference_rule": "datacenter_to_transponder" if candidate != requested_device else "exact_optical_endpoint", + "inference_rule": ( + "datacenter_to_transponder" + if candidate != requested_device + else "exact_optical_endpoint" + ), "owner": owner, "checked_candidates": checked_candidates, } @@ -307,17 +396,23 @@ def _default_endpoint_for_device(device_name: str, direction: str) -> str: def _candidate_ranges_from_tfs(reply: Dict[str, Any]) -> List[Dict[str, int]]: ranges: List[Dict[str, int]] = [] seen: set[tuple[int, int]] = set() - for candidate in reply.get("candidates", []) if isinstance(reply, dict) else []: + for candidate in reply.get( + "candidates", + []) if isinstance( + reply, + dict) else []: if not isinstance(candidate, dict): continue candidate_ranges = candidate.get("available_slot_ranges") or [ - {"n_start": candidate.get("n_start"), "n_end": candidate.get("n_end")} + {"n_start": candidate.get("n_start"), + "n_end": candidate.get("n_end")} ] for candidate_range in candidate_ranges: if not isinstance(candidate_range, dict): continue try: - item = (int(candidate_range["n_start"]), int(candidate_range["n_end"])) + item = (int(candidate_range["n_start"]), int( + candidate_range["n_end"])) except (KeyError, TypeError, ValueError): continue if item in seen: @@ -327,19 +422,25 @@ def _candidate_ranges_from_tfs(reply: Dict[str, Any]) -> List[Dict[str, int]]: return ranges -def _candidate_for_selected_range(candidate_reply: Dict[str, Any], selected_range: Dict[str, int]) -> Dict[str, Any]: - """Return the TFS candidate whose common available range contains selected_range.""" +def _candidate_for_selected_range( + candidate_reply: Dict[str, Any], + selected_range: Dict[str, int], +) -> Dict[str, Any]: + """Return the TFS candidate containing selected_range.""" try: selected_start = int(selected_range["n_start"]) selected_end = int(selected_range["n_end"]) except (KeyError, TypeError, ValueError): return {} - for candidate in candidate_reply.get("candidates", []) if isinstance(candidate_reply, dict) else []: + for candidate in candidate_reply.get( + "candidates", []) if isinstance( + candidate_reply, dict) else []: if not isinstance(candidate, dict): continue candidate_ranges = candidate.get("available_slot_ranges") or [ - {"n_start": candidate.get("n_start"), "n_end": candidate.get("n_end")} + {"n_start": candidate.get("n_start"), + "n_end": candidate.get("n_end")} ] for candidate_range in candidate_ranges: if not isinstance(candidate_range, dict): @@ -354,9 +455,15 @@ def _candidate_for_selected_range(candidate_reply: Dict[str, Any], selected_rang return {} -def _optical_link_ids_from_candidate(candidate: Dict[str, Any]) -> List[Dict[str, Any]]: +def _optical_link_ids_from_candidate( + candidate: Dict[str, Any], +) -> List[Dict[str, Any]]: link_ids = [] - for link_uuid in candidate.get("optical_link_ids", []) if isinstance(candidate, dict) else []: + for link_uuid in candidate.get( + "optical_link_ids", + []) if isinstance( + candidate, + dict) else []: if isinstance(link_uuid, dict): link_uuid = link_uuid.get("link_uuid", {}).get("uuid", "") link_uuid = str(link_uuid or "").strip() @@ -366,9 +473,14 @@ def _optical_link_ids_from_candidate(candidate: Dict[str, Any]) -> List[Dict[str def _reservation_uuid(service_id: str, role: str) -> str: - safe_service = re.sub(r"[^A-Za-z0-9_.-]+", "-", service_id.strip())[:48].strip("-") - safe_role = re.sub(r"[^A-Za-z0-9_.-]+", "-", role.strip() or "segment")[:16].strip("-") - return f"{safe_service or 'service'}-{safe_role or 'segment'}-res-{uuid.uuid4().hex[:8]}" + safe_service = re.sub(r"[^A-Za-z0-9_.-]+", "-", + service_id.strip())[:48].strip("-") + safe_role = re.sub(r"[^A-Za-z0-9_.-]+", "-", + role.strip() or "segment")[:16].strip("-") + return ( + f"{safe_service or 'service'}-{safe_role or 'segment'}-res-" + f"{uuid.uuid4().hex[:8]}" + ) def _reservation_uuid_from_create_reply(reply: Any, fallback: str) -> str: @@ -397,12 +509,15 @@ async def create_tfs_spectrum_reservation( candidate_bundle: Dict[str, Any], metadata: Dict[str, Any] | None = None, ) -> Dict[str, Any]: - """Create a controller-authoritative spectrum hold for the selected segment.""" + """Create a controller-authoritative spectrum hold.""" metadata = metadata or {} - context_uuid = str(candidate_bundle.get("context_uuid") or DEFAULT_CONTEXT_UUID) - topology_uuid = str(candidate_bundle.get("topology_uuid") or DEFAULT_TOPOLOGY_UUID) - candidate_reply = candidate_bundle.get("tfs_candidate_reply", candidate_bundle) + context_uuid = str(candidate_bundle.get( + "context_uuid") or DEFAULT_CONTEXT_UUID) + topology_uuid = str(candidate_bundle.get( + "topology_uuid") or DEFAULT_TOPOLOGY_UUID) + candidate_reply = candidate_bundle.get( + "tfs_candidate_reply", candidate_bundle) candidate = _candidate_for_selected_range(candidate_reply, selected_range) if not candidate: return { @@ -424,8 +539,13 @@ async def create_tfs_spectrum_reservation( n_start = int(selected_range["n_start"]) n_end = int(selected_range["n_end"]) except (KeyError, TypeError, ValueError): - return {"ok": False, "error": "invalid_selected_range", "selected_range": selected_range} - requested_reservation_uuid = _reservation_uuid(service_id, str(metadata.get("role", "segment"))) + return { + "ok": False, + "error": "invalid_selected_range", + "selected_range": selected_range, + } + requested_reservation_uuid = _reservation_uuid( + service_id, str(metadata.get("role", "segment"))) reservation = { "reservation_id": { "context_id": {"context_uuid": {"uuid": context_uuid}}, @@ -460,7 +580,8 @@ async def create_tfs_spectrum_reservation( "reservation": reservation, "reply": reply, } - canonical_reservation_uuid = _reservation_uuid_from_create_reply(reply, requested_reservation_uuid) + canonical_reservation_uuid = _reservation_uuid_from_create_reply( + reply, requested_reservation_uuid) return { "ok": True, "operation": "reserve", @@ -470,9 +591,12 @@ async def create_tfs_spectrum_reservation( "domain_id": DOMAIN_ID, "context_uuid": context_uuid, "topology_uuid": topology_uuid, - "selected_range": {"n_start": n_start, "n_end": n_end}, + "selected_range": { + "n_start": n_start, + "n_end": n_end}, "band": reservation["band"], - "optical_link_ids": [item["link_uuid"]["uuid"] for item in optical_link_ids], + "optical_link_ids": [ + item["link_uuid"]["uuid"] for item in optical_link_ids], "status": "reserved", "reservation": reservation, "reply": reply, @@ -493,20 +617,24 @@ async def consume_tfs_spectrum_reservation( {"context_uuid": context_uuid, "service_uuid": service_id}, ) canonical_service_uuid = ( - service.get("service_id", {}).get("service_uuid", {}).get("uuid", service_id) + service.get("service_id", {}).get( + "service_uuid", {}).get("uuid", service_id) if isinstance(service, dict) else service_id ) canonical_context_uuid = ( - service.get("service_id", {}).get("context_id", {}).get("context_uuid", {}).get("uuid", context_uuid) + service.get("service_id", {}).get("context_id", {}).get( + "context_uuid", {}).get("uuid", context_uuid) if isinstance(service, dict) else context_uuid ) payload = dict(reservation) if isinstance(reservation, dict) else {} - payload["service_id"] = _service_id(canonical_context_uuid, canonical_service_uuid) + payload["service_id"] = _service_id( + canonical_context_uuid, canonical_service_uuid) reply = await call_mcp_tool( "tfs_consume_optical_spectrum_reservation", - {"context_uuid": context_uuid, "reservation_uuid": reservation_id, "reservation": payload}, + {"context_uuid": context_uuid, + "reservation_uuid": reservation_id, "reservation": payload}, ) return { "ok": not _tool_failed(reply), @@ -554,7 +682,10 @@ def _reservation_service_uuid(reservation: Dict[str, Any]) -> str: ) -def _reservation_context_uuid(reservation: Dict[str, Any], fallback: str = DEFAULT_CONTEXT_UUID) -> str: +def _reservation_context_uuid( + reservation: Dict[str, Any], + fallback: str = DEFAULT_CONTEXT_UUID, +) -> str: return str( reservation.get("reservation_id", {}) .get("context_id", {}) @@ -570,7 +701,11 @@ def _reservation_status(reservation: Dict[str, Any]) -> str: def _reservation_is_active(reservation: Dict[str, Any]) -> bool: status = _reservation_status(reservation) - return status.endswith("_RESERVED") or status.endswith("_CONSUMED") or status in {"RESERVED", "CONSUMED"} + return ( + status.endswith("_RESERVED") + or status.endswith("_CONSUMED") + or status in {"RESERVED", "CONSUMED"} + ) async def release_matching_tfs_spectrum_reservations( @@ -578,12 +713,17 @@ async def release_matching_tfs_spectrum_reservations( service_ids: List[str] | None = None, context_uuid: str = DEFAULT_CONTEXT_UUID, ) -> Dict[str, Any]: - """Release controller reservations matching request correlation or service IDs.""" + """Release reservations matching request correlation or service IDs.""" - correlation_set = {str(item).strip() for item in (correlation_ids or []) if str(item).strip()} - service_set = {str(item).strip() for item in (service_ids or []) if str(item).strip()} + correlation_set = {str(item).strip() for item in ( + correlation_ids or []) if str(item).strip()} + service_set = {str(item).strip() + for item in (service_ids or []) if str(item).strip()} context_uuid = (context_uuid or DEFAULT_CONTEXT_UUID).strip() - reply = await call_mcp_tool("tfs_list_optical_spectrum_reservations", {"context_uuid": context_uuid}) + reply = await call_mcp_tool( + "tfs_list_optical_spectrum_reservations", + {"context_uuid": context_uuid}, + ) if _tool_failed(reply): return { "ok": False, @@ -596,7 +736,8 @@ async def release_matching_tfs_spectrum_reservations( "matches": [], "failures": [reply], } - reservations = reply.get("reservations", []) if isinstance(reply, dict) else [] + reservations = reply.get( + "reservations", []) if isinstance(reply, dict) else [] matches: List[Dict[str, Any]] = [] releases: List[Dict[str, Any]] = [] failures: List[Dict[str, Any]] = [] @@ -625,8 +766,12 @@ async def release_matching_tfs_spectrum_reservations( matches.append(match) if not reservation_uuid or not _reservation_is_active(reservation): continue - release_context_uuid = _reservation_context_uuid(reservation, context_uuid) - release = await release_tfs_spectrum_reservation(reservation_uuid, release_context_uuid) + release_context_uuid = _reservation_context_uuid( + reservation, context_uuid) + release = await release_tfs_spectrum_reservation( + reservation_uuid, + release_context_uuid, + ) releases.append({**match, "release": release}) if not release.get("ok"): failures.append({**match, "release": release}) @@ -638,7 +783,13 @@ async def release_matching_tfs_spectrum_reservations( "service_ids": sorted(service_set), "matches": matches, "releases": releases, - "released": len([item for item in releases if item.get("release", {}).get("ok")]), + "released": len( + [ + item + for item in releases + if item.get("release", {}).get("ok") + ] + ), "failures": failures, } @@ -674,7 +825,8 @@ def _candidate_modulation_formats(candidate_reply: Dict[str, Any]) -> set[str]: if not isinstance(candidate_reply, dict): return set() modulations: set[str] = set() - summary_modulation = candidate_reply.get("request_summary", {}).get("requested_modulation_format") + summary_modulation = candidate_reply.get( + "request_summary", {}).get("requested_modulation_format") if summary_modulation: modulations.add(str(summary_modulation).strip().lower()) for candidate in candidate_reply.get("candidates", []): @@ -686,7 +838,8 @@ def _candidate_modulation_formats(candidate_reply: Dict[str, Any]) -> set[str]: return {item for item in modulations if item} -def _tfs_candidate_required_slots(candidate_reply: Dict[str, Any]) -> int | None: +def _tfs_candidate_required_slots( + candidate_reply: Dict[str, Any]) -> int | None: if not isinstance(candidate_reply, dict): return None for value in (candidate_reply.get("required_slots"),): @@ -710,7 +863,8 @@ def _tfs_candidate_required_slots(candidate_reply: Dict[str, Any]) -> int | None return None -def _tfs_candidate_slot_width_ghz(candidate_reply: Dict[str, Any], required_slots: int) -> float | None: +def _tfs_candidate_slot_width_ghz( + candidate_reply: Dict[str, Any], required_slots: int) -> float | None: if required_slots <= 0 or not isinstance(candidate_reply, dict): return None try: @@ -727,12 +881,19 @@ def _controller_sizing_from_candidates( local_candidates: Dict[str, Any], peer_candidates: Dict[str, Any], ) -> Dict[str, Any]: - """Use TFS candidate sizing as authoritative once the controller returns it.""" - - local_reply = local_candidates.get("tfs_candidate_reply", {}) if isinstance(local_candidates, dict) else {} - peer_reply = peer_candidates.get("tfs_candidate_reply", {}) if isinstance(peer_candidates, dict) else {} - modulation_formats = _candidate_modulation_formats(local_reply) | _candidate_modulation_formats(peer_reply) - requested_modulation = str(sizing.get("modulation_format") or "").strip().lower() + """Use TFS candidate sizing as authoritative.""" + + local_reply = local_candidates.get( + "tfs_candidate_reply", + {}) if isinstance( + local_candidates, + dict) else {} + peer_reply = peer_candidates.get("tfs_candidate_reply", {}) if isinstance( + peer_candidates, dict) else {} + modulation_formats = _candidate_modulation_formats( + local_reply) | _candidate_modulation_formats(peer_reply) + requested_modulation = str(sizing.get( + "modulation_format") or "").strip().lower() if requested_modulation: modulation_formats.add(requested_modulation) if len(modulation_formats) > 1: @@ -746,7 +907,11 @@ def _controller_sizing_from_candidates( peer_slots = _tfs_candidate_required_slots(peer_reply) if local_slots is None and peer_slots is None: return dict(sizing) - if local_slots is not None and peer_slots is not None and local_slots != peer_slots: + if ( + local_slots is not None + and peer_slots is not None + and local_slots != peer_slots + ): return { **sizing, "ok": False, @@ -755,7 +920,8 @@ def _controller_sizing_from_candidates( "peer_required_slots": peer_slots, } - required_slots = int(local_slots if local_slots is not None else peer_slots) + required_slots = int( + local_slots if local_slots is not None else peer_slots) slot_width = ( _tfs_candidate_slot_width_ghz(local_reply, required_slots) or _tfs_candidate_slot_width_ghz(peer_reply, required_slots) @@ -779,23 +945,30 @@ async def compute_tfs_optical_connectivity_candidates( preferred_band: str = "c_slots", max_candidates: int = 8, ) -> Dict[str, Any]: - """Ask local TFS optical controller for path/spectrum candidates through MCP.""" + """Ask local TFS optical controller for candidates through MCP.""" ids = await _default_context_and_topology() details = await call_mcp_tool("tfs_get_topology_details", ids) devices = details.get("devices", []) if isinstance(details, dict) else [] src_device_uuid = _resolve_device_uuid_from_details(devices, source_device) - dst_device_uuid = _resolve_device_uuid_from_details(devices, destination_device) + dst_device_uuid = _resolve_device_uuid_from_details( + devices, destination_device) src_endpoint_uuid = _resolve_endpoint_uuid_from_details( - devices, src_device_uuid, _default_endpoint_for_device(source_device, "out") + devices, src_device_uuid, _default_endpoint_for_device( + source_device, "out") ) dst_endpoint_uuid = _resolve_endpoint_uuid_from_details( - devices, dst_device_uuid, _default_endpoint_for_device(destination_device, "in") + devices, dst_device_uuid, _default_endpoint_for_device( + destination_device, "in") ) request: Dict[str, Any] = { "src_endpoint_id": _endpoint_id(src_device_uuid, src_endpoint_uuid), "dst_endpoint_id": _endpoint_id(dst_device_uuid, dst_endpoint_uuid), - "preferred_band": "c_slots" if not preferred_band or "-" in preferred_band else preferred_band, + "preferred_band": ( + "c_slots" + if not preferred_band or "-" in preferred_band + else preferred_band + ), "max_candidates": int(max_candidates), } if channel_width_ghz is not None: @@ -804,8 +977,11 @@ async def compute_tfs_optical_connectivity_candidates( request["capacity_gbps"] = float(capacity_gbps) if modulation_format: request["modulation_format"] = modulation_format - if preferred_band and re.fullmatch(r"\d+\s*-\s*\d+", preferred_band.strip()): - start, end = [int(item.strip()) for item in preferred_band.split("-", 1)] + if preferred_band and re.fullmatch( + r"\d+\s*-\s*\d+", + preferred_band.strip()): + start, end = [int(item.strip()) + for item in preferred_band.split("-", 1)] request["preferred_n_start"] = start request["preferred_n_end"] = end reply = await call_mcp_tool("tfs_compute_optical_connectivity_candidates", { @@ -831,7 +1007,10 @@ def _resolve_spectrum_sizing( modulation_format: str = "", channel_width_ghz: float | None = None, ) -> Dict[str, Any]: - return spectrum.spectrum_request(capacity_gbps, modulation_format, channel_width_ghz) + return spectrum.spectrum_request( + capacity_gbps, + modulation_format, + channel_width_ghz) def _parse_ghz_value(value: Any) -> float | None: @@ -881,25 +1060,31 @@ async def create_optical_connectivity_service( poll_attempts: int = 30, poll_interval_seconds: float = 2.0, ) -> Dict[str, Any]: - """Create one unidirectional optical connectivity service and verify ACTIVE.""" + """Create one unidirectional optical service and verify ACTIVE.""" - trace = WorkflowTrace() trace = WorkflowTrace() source_device = source_device.strip() destination_device = destination_device.strip() context_uuid = (context_uuid or DEFAULT_CONTEXT_UUID).strip() service_name = (service_name or f"adk-opt-{uuid.uuid4().hex[:10]}").strip() - source_endpoint = (source_endpoint or _default_endpoint_for_device(source_device, "out")).strip() - destination_endpoint = (destination_endpoint or _default_endpoint_for_device(destination_device, "in")).strip() + source_endpoint = (source_endpoint or _default_endpoint_for_device( + source_device, "out")).strip() + destination_endpoint = ( + destination_endpoint or _default_endpoint_for_device( + destination_device, "in")).strip() poll_attempts = max(1, min(int(poll_attempts or 30), 60)) - poll_interval_seconds = max(0.2, min(float(poll_interval_seconds or 2.0), 10.0)) + poll_interval_seconds = max( + 0.2, min(float(poll_interval_seconds or 2.0), 10.0)) planning_started = time.perf_counter() - preferred_band, channel_width_ghz, normalized_spectrum_inputs = _normalize_spectrum_inputs( + ( preferred_band, channel_width_ghz, - ) - sizing = _resolve_spectrum_sizing(capacity_gbps, modulation_format, channel_width_ghz) - trace.mark("planning", "local_agent", "normalize_and_size_spectrum", "ok" if sizing.get("ok") else "failed", planning_started) + normalized_spectrum_inputs, + ) = _normalize_spectrum_inputs(preferred_band, channel_width_ghz) + sizing = _resolve_spectrum_sizing( + capacity_gbps, modulation_format, channel_width_ghz) + trace.mark("planning", "local_agent", "normalize_and_size_spectrum", + "ok" if sizing.get("ok") else "failed", planning_started) if not sizing.get("ok"): return { "ok": False, @@ -918,9 +1103,15 @@ async def create_optical_connectivity_service( if slot_width_ghz_override is not None: sizing = {**sizing, "slot_width_ghz": float(slot_width_ghz_override)} - inventory = await trace.timed("planning", "local_mcp", "tfs_list_devices", call_mcp_tool("tfs_list_devices", {})) + inventory = await trace.timed( + "planning", + "local_mcp", + "tfs_list_devices", + call_mcp_tool("tfs_list_devices", {}), + ) names = set(_device_names(inventory)) - missing = [name for name in (source_device, destination_device) if name not in names] + missing = [name for name in ( + source_device, destination_device) if name not in names] if missing: return { "ok": False, @@ -932,22 +1123,36 @@ async def create_optical_connectivity_service( } sid = _service_id(context_uuid, service_name) - service_shell = {"service_id": sid, "name": service_name, "service_type": 6} + service_shell = { + "service_id": sid, + "name": service_name, + "service_type": 6, + } constraints = [ _constraint("type", "flexi_grid"), _constraint("bidirectionality", "0"), - _constraint("optical-band-width[GHz]", str(sizing["tfs_optical_band_width_ghz"])), - _constraint("spectrum-slot-width[GHz]", str(sizing["slot_width_ghz"])), + _constraint("optical-band-width[GHz]", + str(sizing["tfs_optical_band_width_ghz"])), + _constraint( + "spectrum-slot-width[GHz]", + str(sizing["slot_width_ghz"]), + ), _constraint("spectrum-slots", str(sizing["required_slots"])), ] if sizing.get("capacity_gbps") is not None: - constraints.append(_constraint("bandwidth[gbps]", str(sizing["capacity_gbps"]))) + constraints.append(_constraint( + "bandwidth[gbps]", str(sizing["capacity_gbps"]))) if sizing.get("modulation_format"): - constraints.append(_constraint("modulation-format", str(sizing["modulation_format"]))) + constraints.append(_constraint("modulation-format", + str(sizing["modulation_format"]))) if preferred_band.strip(): - constraints.append(_constraint("preferred_band", preferred_band.strip())) + constraints.append(_constraint( + "preferred_band", preferred_band.strip())) if re.fullmatch(r"\d+\s*-\s*\d+", preferred_band.strip()): - constraints.append(_constraint("optical-spectrum-reservation", f"c_slots:{preferred_band.strip()}")) + constraints.append( + _constraint( + "optical-spectrum-reservation", + f"c_slots:{preferred_band.strip()}")) service_update = { "service_id": sid, @@ -965,7 +1170,13 @@ async def create_optical_connectivity_service( "provisioning", "local_mcp", "tfs_create_services", - call_mcp_tool("tfs_create_services", {"context_uuid": context_uuid, "services": [service_shell]}), + call_mcp_tool( + "tfs_create_services", + { + "context_uuid": context_uuid, + "services": [service_shell], + }, + ), ) if _tool_failed(create_result): return { @@ -980,14 +1191,27 @@ async def create_optical_connectivity_service( "provisioning", "local_mcp", "tfs_update_service", - call_mcp_tool("tfs_update_service", {"context_uuid": context_uuid, "service_uuid": service_name, "service": service_update}), + call_mcp_tool( + "tfs_update_service", + { + "context_uuid": context_uuid, + "service_uuid": service_name, + "service": service_update, + }, + ), ) if _tool_failed(update_result): delete_result = await trace.timed( "rollback", "local_mcp", "tfs_delete_service", - call_mcp_tool("tfs_delete_service", {"context_uuid": context_uuid, "service_uuid": service_name}), + call_mcp_tool( + "tfs_delete_service", + { + "context_uuid": context_uuid, + "service_uuid": service_name, + }, + ), ) return { "ok": False, @@ -1006,14 +1230,21 @@ async def create_optical_connectivity_service( "verification", "local_mcp", f"tfs_get_service_attempt_{attempt}", - call_mcp_tool("tfs_get_service", {"context_uuid": context_uuid, "service_uuid": service_name}), + call_mcp_tool( + "tfs_get_service", + { + "context_uuid": context_uuid, + "service_uuid": service_name, + }, + ), ) status = _service_status(service) observations.append({"attempt": attempt, "status": status}) if status == "ACTIVE": allocation_verification: Dict[str, Any] | None = None if re.fullmatch(r"\d+\s*-\s*\d+", preferred_band.strip()): - n_start, n_end = [int(item.strip()) for item in preferred_band.split("-", 1)] + n_start, n_end = [int(item.strip()) + for item in preferred_band.split("-", 1)] allocation_verification = await trace.timed( "verification", "local_mcp", @@ -1030,7 +1261,13 @@ async def create_optical_connectivity_service( "rollback", "local_mcp", "tfs_delete_service", - call_mcp_tool("tfs_delete_service", {"context_uuid": context_uuid, "service_uuid": service_name}), + call_mcp_tool( + "tfs_delete_service", + { + "context_uuid": context_uuid, + "service_uuid": service_name, + }, + ), ) return { "ok": False, @@ -1060,13 +1297,21 @@ async def create_optical_connectivity_service( "rollback", "local_mcp", "tfs_delete_service", - call_mcp_tool("tfs_delete_service", {"context_uuid": context_uuid, "service_uuid": service_name}), + call_mcp_tool( + "tfs_delete_service", + { + "context_uuid": context_uuid, + "service_uuid": service_name, + }, + ), ) return { "ok": False, "phase": "verify_active", "service_uuid": service_name, - "observed_status": observations[-1]["status"] if observations else "UNKNOWN", + "observed_status": ( + observations[-1]["status"] if observations else "UNKNOWN" + ), "observations": observations, "rollback_result": delete_result, "workflow_trace": trace.export(), @@ -1088,18 +1333,31 @@ async def delete_service( service_uuid = service_uuid.strip() context_uuid = (context_uuid or DEFAULT_CONTEXT_UUID).strip() max_attempts = max(1, min(int(max_attempts or 3), 5)) - poll_interval_seconds = max(0.2, min(float(poll_interval_seconds or 2.0), 10.0)) + poll_interval_seconds = max( + 0.2, min(float(poll_interval_seconds or 2.0), 10.0)) attempts = [] release_service_ids = {service_uuid, *(candidate_service_ids or [])} existing_service = await trace.timed( "deletion", "local_mcp", "tfs_get_service_before_delete", - call_mcp_tool("tfs_get_service", {"context_uuid": context_uuid, "service_uuid": service_uuid}), - input_payload={"context_uuid": context_uuid, "service_uuid": service_uuid}, + call_mcp_tool( + "tfs_get_service", + { + "context_uuid": context_uuid, + "service_uuid": service_uuid, + }, + ), + input_payload={ + "context_uuid": context_uuid, + "service_uuid": service_uuid, + }, ) - if isinstance(existing_service, dict) and not _tool_failed(existing_service): - canonical_uuid = existing_service.get("service_id", {}).get("service_uuid", {}).get("uuid", "") + if isinstance( + existing_service, + dict) and not _tool_failed(existing_service): + canonical_uuid = existing_service.get("service_id", {}).get( + "service_uuid", {}).get("uuid", "") if canonical_uuid: release_service_ids.add(str(canonical_uuid)) @@ -1108,8 +1366,17 @@ async def delete_service( "deletion", "local_mcp", f"tfs_delete_service_attempt_{attempt}", - call_mcp_tool("tfs_delete_service", {"context_uuid": context_uuid, "service_uuid": service_uuid}), - input_payload={"context_uuid": context_uuid, "service_uuid": service_uuid}, + call_mcp_tool( + "tfs_delete_service", + { + "context_uuid": context_uuid, + "service_uuid": service_uuid, + }, + ), + input_payload={ + "context_uuid": context_uuid, + "service_uuid": service_uuid, + }, ) await trace.timed( "deletion", @@ -1121,12 +1388,23 @@ async def delete_service( "verification", "local_mcp", f"tfs_get_service_after_delete_attempt_{attempt}", - call_mcp_tool("tfs_get_service", {"context_uuid": context_uuid, "service_uuid": service_uuid}), - input_payload={"context_uuid": context_uuid, "service_uuid": service_uuid}, + call_mcp_tool( + "tfs_get_service", + { + "context_uuid": context_uuid, + "service_uuid": service_uuid, + }, + ), + input_payload={ + "context_uuid": context_uuid, + "service_uuid": service_uuid, + }, ) exists = not _tool_failed(observed) status = _service_status(observed) if exists else "NOT_FOUND" - attempts.append({"attempt": attempt, "delete_result": delete_result, "observed_status": status}) + attempts.append({"attempt": attempt, + "delete_result": delete_result, + "observed_status": status}) if not exists or status in {"UNKNOWN", "NOT_FOUND"}: reservation_release = ( await trace.timed( @@ -1134,18 +1412,26 @@ async def delete_service( "local_mcp", "release_matching_tfs_spectrum_reservations", release_matching_tfs_spectrum_reservations( - correlation_ids=[correlation_id] if correlation_id else [], + correlation_ids=[ + correlation_id] if correlation_id else [], service_ids=sorted(release_service_ids), context_uuid=context_uuid, ), input_payload={ - "correlation_ids": [correlation_id] if correlation_id else [], + "correlation_ids": ( + [correlation_id] if correlation_id else [] + ), "service_ids": sorted(release_service_ids), "context_uuid": context_uuid, }, ) if release_reservations - else {"ok": True, "operation": "release_matching", "released": 0, "skipped": True} + else { + "ok": True, + "operation": "release_matching", + "released": 0, + "skipped": True, + } ) return { "ok": bool(reservation_release.get("ok", True)), @@ -1163,18 +1449,27 @@ async def delete_service( "local_mcp", "release_matching_tfs_spectrum_reservations", release_matching_tfs_spectrum_reservations( - correlation_ids=[correlation_id] if correlation_id else [], + correlation_ids=[ + correlation_id + ] if correlation_id else [], service_ids=sorted(release_service_ids), context_uuid=context_uuid, ), input_payload={ - "correlation_ids": [correlation_id] if correlation_id else [], + "correlation_ids": ( + [correlation_id] if correlation_id else [] + ), "service_ids": sorted(release_service_ids), "context_uuid": context_uuid, }, ) if release_reservations - else {"ok": True, "operation": "release_matching", "released": 0, "skipped": True} + else { + "ok": True, + "operation": "release_matching", + "released": 0, + "skipped": True, + } ) return { "ok": False and bool(reservation_release.get("ok", True)), @@ -1194,7 +1489,11 @@ def _service_identity_values(service: Dict[str, Any]) -> List[str]: service_id = service.get("service_id", {}) if isinstance(service_id, dict): service_uuid = service_id.get("service_uuid", {}) - if isinstance(service_uuid, dict) and service_uuid.get("uuid") not in (None, ""): + if isinstance( + service_uuid, + dict) and service_uuid.get("uuid") not in ( + None, + ""): values.append(str(service_uuid["uuid"])) for key in ("name", "uuid"): value = service.get(key) @@ -1215,7 +1514,8 @@ def _service_matches_request( """Return whether a service belongs to a request-scoped workflow.""" request_id = request_id.strip() - candidates = {item.strip() for item in (candidate_service_ids or []) if item and item.strip()} + candidates = {item.strip() for item in ( + candidate_service_ids or []) if item and item.strip()} if request_id: candidates.add(request_id) for identity in _service_identity_values(service): @@ -1242,12 +1542,14 @@ async def find_local_services_for_request( call_mcp_tool("tfs_list_services", {"context_uuid": context_uuid}), input_payload={"context_uuid": context_uuid, "request_id": request_id}, ) - services = list_result.get("services", []) if isinstance(list_result, dict) else [] + services = list_result.get("services", []) if isinstance( + list_result, dict) else [] matches: List[Dict[str, Any]] = [] for service in services: if not isinstance(service, dict): continue - if not _service_matches_request(service, request_id, candidate_service_ids): + if not _service_matches_request( + service, request_id, candidate_service_ids): continue identities = _service_identity_values(service) matches.append({ @@ -1274,22 +1576,26 @@ async def delete_local_services_for_request( max_attempts: int = 3, poll_interval_seconds: float = 2.0, ) -> Dict[str, Any]: - """Delete local TFS services matching a request id after source-of-truth discovery.""" + """Delete local TFS services matching a request id.""" trace = WorkflowTrace() discovery = await trace.timed( "deletion_discovery", "local_mcp", "discover_matching_services", - find_local_services_for_request(request_id, candidate_service_ids, context_uuid), + find_local_services_for_request( + request_id, candidate_service_ids, context_uuid), input_payload={"context_uuid": context_uuid, "request_id": request_id}, ) - matches = discovery.get("matches", []) if isinstance(discovery, dict) else [] + matches = discovery.get("matches", []) if isinstance( + discovery, dict) else [] delete_tasks = [] seen: set[str] = set() for match in matches: - identities = [str(item) for item in match.get("identities", []) if item] - service_uuid = str(match.get("service_uuid", "") or (identities[0] if identities else "")) + identities = [str(item) + for item in match.get("identities", []) if item] + service_uuid = str(match.get("service_uuid", "") + or (identities[0] if identities else "")) if not service_uuid or service_uuid in seen: continue seen.add(service_uuid) @@ -1307,7 +1613,8 @@ async def delete_local_services_for_request( candidate_service_ids=identities, ), parallel_group="delete_matching_services", - input_payload={"context_uuid": context_uuid, "service_uuid": service_uuid}, + input_payload={"context_uuid": context_uuid, + "service_uuid": service_uuid}, ) ) delete_results = await asyncio.gather(*delete_tasks) if delete_tasks else [] @@ -1315,7 +1622,8 @@ async def delete_local_services_for_request( for item in delete_results: if isinstance(item, dict): nested_workflow_trace.extend(item.get("workflow_trace", []) or []) - failures = [item for item in delete_results if isinstance(item, dict) and not item.get("ok")] + failures = [item for item in delete_results if isinstance( + item, dict) and not item.get("ok")] return { "ok": not failures, "phase": "deleted", @@ -1341,7 +1649,7 @@ def _service_uuid_for_domain(domain_id: str, service_name: str) -> str: def _stored_service_targets_for_request(request_id: str) -> Dict[str, Any]: - """Return segment domains and service IDs from the local persisted create event.""" + """Return segment domains and service IDs from a create event.""" events = find_events_by_request_id( request_id, @@ -1349,28 +1657,40 @@ def _stored_service_targets_for_request(request_id: str) -> Dict[str, Any]: limit=20, ) if not events: - return {"ok": False, "request_id": request_id, "domains": {}, "source": "not_found"} + return { + "ok": False, + "request_id": request_id, + "domains": {}, + "source": "not_found", + } for event in events: payload = event.get("payload", {}) if not isinstance(payload, dict) or not payload.get("ok", False): continue - domain_path = [str(item).upper() for item in payload.get("domain_path", []) if str(item).strip()] - services = [str(item) for item in payload.get("segment_service_uuids", []) if str(item).strip()] + domain_path = [str(item).upper() for item in payload.get( + "domain_path", []) if str(item).strip()] + services = [str(item) for item in payload.get( + "segment_service_uuids", []) if str(item).strip()] domains: Dict[str, List[str]] = {} if domain_path and len(domain_path) == len(services): for domain_id, service_id in zip(domain_path, services): domains.setdefault(domain_id, []).append(service_id) else: for service_id in services: - match = re.match(r"^([a-z])-", service_id.strip(), flags=re.IGNORECASE) + match = re.match( + r"^([a-z])-", service_id.strip(), flags=re.IGNORECASE) if match: - domains.setdefault(match.group(1).upper(), []).append(service_id) + domains.setdefault(match.group( + 1).upper(), []).append(service_id) for key in ("local_service_uuid", "peer_service_uuid"): service_id = str(payload.get(key, "") or "") - match = re.match(r"^([a-z])-", service_id.strip(), flags=re.IGNORECASE) + match = re.match( + r"^([a-z])-", service_id.strip(), flags=re.IGNORECASE) if service_id and match: - domains.setdefault(match.group(1).upper(), []).append(service_id) - domains = {domain_id: sorted(set(values)) for domain_id, values in domains.items() if values} + domains.setdefault(match.group(1).upper(), + []).append(service_id) + domains = {domain_id: sorted(set(values)) + for domain_id, values in domains.items() if values} if domains: return { "ok": True, @@ -1380,7 +1700,12 @@ def _stored_service_targets_for_request(request_id: str) -> Dict[str, Any]: "event_id": event.get("event_id"), "source": "session_event", } - return {"ok": False, "request_id": request_id, "domains": {}, "source": "no_successful_create_event"} + return { + "ok": False, + "request_id": request_id, + "domains": {}, + "source": "no_successful_create_event", + } def _remote_abstraction_domains(devices: List[Dict[str, Any]]) -> set[str]: @@ -1410,7 +1735,8 @@ async def _select_next_hop_domain( "local_adjacent_domains": sorted(local_adjacent), } checked = [] - candidates = [candidate for candidate in sorted(local_adjacent) if candidate in peers] + candidates = [candidate for candidate in sorted( + local_adjacent) if candidate in peers] peer_results = await asyncio.gather( *[ trace.timed( @@ -1425,11 +1751,14 @@ async def _select_next_hop_domain( ) for candidate, peer_inventory in zip(candidates, peer_results): if isinstance(peer_inventory, Exception): - checked.append({"domain_id": candidate, "error": str(peer_inventory)}) + checked.append( + {"domain_id": candidate, "error": str(peer_inventory)}) continue - peer_devices = peer_inventory.get("devices", []) if isinstance(peer_inventory, dict) else [] + peer_devices = peer_inventory.get( + "devices", []) if isinstance(peer_inventory, dict) else [] peer_adjacent = _remote_abstraction_domains(peer_devices) - checked.append({"domain_id": candidate, "adjacent_domains": sorted(peer_adjacent)}) + checked.append({"domain_id": candidate, + "adjacent_domains": sorted(peer_adjacent)}) if destination_domain in peer_adjacent: return { "ok": True, @@ -1449,7 +1778,10 @@ async def _select_next_hop_domain( } -def _intersect_range_sets(range_sets: List[List[Dict[str, int]]], required_slots: int) -> List[Dict[str, int]]: +def _intersect_range_sets( + range_sets: List[List[Dict[str, int]]], + required_slots: int, +) -> List[Dict[str, int]]: """Return slot ranges available in every candidate set.""" if not range_sets: @@ -1518,7 +1850,8 @@ def _controller_sizing_from_candidate_list( slot_width = _tfs_candidate_slot_width_ghz(reply, int(slots)) if slot_width is not None: slot_width_values.add(float(slot_width)) - requested_modulation = str(sizing.get("modulation_format") or "").strip().lower() + requested_modulation = str(sizing.get( + "modulation_format") or "").strip().lower() if requested_modulation: modulation_formats.add(requested_modulation) if len(modulation_formats) > 1: @@ -1538,7 +1871,8 @@ def _controller_sizing_from_candidate_list( if not required_slot_values: return dict(sizing) required_slots = next(iter(required_slot_values)) - slot_width = next(iter(slot_width_values)) if slot_width_values else sizing.get("slot_width_ghz") + slot_width = next(iter(slot_width_values) + ) if slot_width_values else sizing.get("slot_width_ghz") return { **sizing, "required_slots": required_slots, @@ -1559,16 +1893,25 @@ def _path_wide_segments( segments: List[Dict[str, Any]] = [] for index, domain_id in enumerate(domain_path): previous_domain = domain_path[index - 1] if index > 0 else "" - next_domain = domain_path[index + 1] if index + 1 < len(domain_path) else "" - segment_source = source_device if index == 0 else _remote_domain_device(previous_domain) - segment_destination = destination_device if index == len(domain_path) - 1 else _remote_domain_device(next_domain) + next_domain = domain_path[index + + 1] if index + 1 < len(domain_path) else "" + segment_source = source_device if index == 0 else _remote_domain_device( + previous_domain) + segment_destination = destination_device if index == len( + domain_path) - 1 else _remote_domain_device(next_domain) service_id = _service_uuid_for_domain(domain_id, service_name) segments.append({ "domain_id": domain_id, "source_device": segment_source, "destination_device": segment_destination, "service_id": service_id, - "role": "origin" if index == 0 else "terminal" if index == len(domain_path) - 1 else "transit", + "role": ( + "origin" + if index == 0 + else "terminal" + if index == len(domain_path) - 1 + else "transit" + ), }) return segments @@ -1665,10 +2008,19 @@ async def _segment_release( reservation_id = str(reservation.get("reservation_id", "")) context_uuid = str(reservation.get("context_uuid") or DEFAULT_CONTEXT_UUID) if not reservation_id: - return {"segment": segment, "result": {"ok": True, "status": "not_required"}} + return { + "segment": segment, + "result": { + "ok": True, + "status": "not_required", + }, + } domain_id = str(segment["domain_id"]) if domain_id == DOMAIN_ID: - result = await release_tfs_spectrum_reservation(reservation_id, context_uuid) + result = await release_tfs_spectrum_reservation( + reservation_id, + context_uuid, + ) else: result = await peer_post( domain_id, @@ -1738,7 +2090,11 @@ async def _segment_delete( result = await peer_post( domain_id, "/services/delete", - {"service_uuid": service_id, "user_id": user_id, "session_id": session_id}, + { + "service_uuid": service_id, + "user_id": user_id, + "session_id": session_id, + }, ) return {"segment": segment, "result": result} @@ -1753,10 +2109,20 @@ async def _segment_commit( context_uuid = str(reservation.get("context_uuid") or DEFAULT_CONTEXT_UUID) service_id = str(segment["service_id"]) if not reservation_id: - return {"segment": segment, "result": {"ok": True, "status": "not_required"}} + return { + "segment": segment, + "result": { + "ok": True, + "status": "not_required", + }, + } domain_id = str(segment["domain_id"]) if domain_id == DOMAIN_ID: - result = await consume_tfs_spectrum_reservation(reservation_id, service_id, context_uuid) + result = await consume_tfs_spectrum_reservation( + reservation_id, + service_id, + context_uuid, + ) else: result = await peer_post( domain_id, @@ -1774,7 +2140,12 @@ async def _segment_commit( def _segment_map(rows: List[Dict[str, Any]]) -> Dict[str, Dict[str, Any]]: - return {str(row.get("segment", {}).get("domain_id", "")): row.get("result", {}) for row in rows} + return { + str(row.get("segment", {}).get("domain_id", "")): row.get( + "result", {} + ) + for row in rows + } async def _create_path_wide_cross_domain_optical_service( @@ -1797,12 +2168,14 @@ async def _create_path_wide_cross_domain_optical_service( user_id: str, session_id: str, ) -> Dict[str, Any]: - """Provision all segments in a discovered domain path without recursive delegation.""" + """Provision all path segments without recursive delegation.""" - domain_path = [str(item).upper() for item in route.get("domain_path", []) if str(item).strip()] + domain_path = [str(item).upper() for item in route.get( + "domain_path", []) if str(item).strip()] if not domain_path and route.get("next_hop_domain"): next_hop = str(route.get("next_hop_domain", "")).upper() - final_domain = str(route.get("destination_domain") or destination_domain or next_hop).upper() + final_domain = str(route.get("destination_domain") + or destination_domain or next_hop).upper() domain_path = [DOMAIN_ID, next_hop] if final_domain and final_domain != next_hop: domain_path.append(final_domain) @@ -1815,7 +2188,8 @@ async def _create_path_wide_cross_domain_optical_service( "route": route, "workflow_trace": trace.export(), } - segments = _path_wide_segments(domain_path, source_device, destination_device, service_name) + segments = _path_wide_segments( + domain_path, source_device, destination_device, service_name) candidate_rows = await asyncio.gather( *[ @@ -1823,16 +2197,25 @@ async def _create_path_wide_cross_domain_optical_service( "negotiation", f"domain_{segment['domain_id']}", "path_wide_compute_optical_connectivity_candidates", - _segment_candidates(segment, sizing, required_slots, preferred_band, user_id, session_id), + _segment_candidates( + segment, + sizing, + required_slots, + preferred_band, + user_id, + session_id, + ), parallel_group="path_candidate_collection", ) for segment in segments ] ) failed_candidates = [ - row for row in candidate_rows - if not isinstance(row.get("result"), dict) or not row["result"].get("ok", True) - ] + row for row in candidate_rows if not isinstance( + row.get("result"), + dict) or not row["result"].get( + "ok", + True)] if failed_candidates: result = { "ok": False, @@ -1842,7 +2225,8 @@ async def _create_path_wide_cross_domain_optical_service( "failed_candidates": failed_candidates, "workflow_trace": trace.export(), } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result candidate_results = [row["result"] for row in candidate_rows] @@ -1856,7 +2240,8 @@ async def _create_path_wide_cross_domain_optical_service( "candidate_results": candidate_rows, "workflow_trace": trace.export(), } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result required_slots = int(sizing["required_slots"]) @@ -1869,7 +2254,11 @@ async def _create_path_wide_cross_domain_optical_service( required_slots, ) selection_started = time.perf_counter() - selected = _select_from_ranges(common_ranges, required_slots, minimum_slot=minimum_slot, preferred_band=preferred_band) + selected = _select_from_ranges( + common_ranges, + required_slots, + minimum_slot=minimum_slot, + preferred_band=preferred_band) trace.mark( "negotiation", "negotiation_policy_agent", @@ -1887,7 +2276,8 @@ async def _create_path_wide_cross_domain_optical_service( "selection": selected, "workflow_trace": trace.export(), } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result selected_band = selected["preferred_band"] @@ -1916,9 +2306,9 @@ async def _create_path_wide_cross_domain_optical_service( ] ) failed_reservations = [ - row for row in reservation_rows - if not isinstance(row.get("result"), dict) or not row["result"].get("ok") - ] + row for row in reservation_rows if not isinstance( + row.get("result"), + dict) or not row["result"].get("ok")] if failed_reservations: release_rows = await asyncio.gather( *[ @@ -1926,11 +2316,15 @@ async def _create_path_wide_cross_domain_optical_service( "rollback", f"domain_{row['segment']['domain_id']}", "path_wide_release_spectrum_after_reservation_failure", - _segment_release(row["segment"], row["result"], user_id, session_id), + _segment_release( + row["segment"], row["result"], user_id, session_id), parallel_group="path_reservation_failure_rollback", ) for row in reservation_rows - if isinstance(row.get("result"), dict) and row["result"].get("ok") + if ( + isinstance(row.get("result"), dict) + and row["result"].get("ok") + ) ], return_exceptions=True, ) @@ -1948,7 +2342,8 @@ async def _create_path_wide_cross_domain_optical_service( row for row in release_rows if isinstance(row, dict) ]) - first_peer_domain = next((domain for domain in domain_path if domain != DOMAIN_ID), "") + first_peer_domain = next( + (domain for domain in domain_path if domain != DOMAIN_ID), "") result = { "ok": False, "phase": phase, @@ -1959,12 +2354,22 @@ async def _create_path_wide_cross_domain_optical_service( "failed_reservations": failed_reservations, "release_results": release_rows, "local_reservation": reservations_by_domain.get(DOMAIN_ID, {}), - "peer_reservation": reservations_by_domain.get(first_peer_domain, {}), - "local_release": releases_by_domain.get(DOMAIN_ID, {"ok": True, "status": "not_required"}), - "peer_release": releases_by_domain.get(first_peer_domain, {"ok": True, "status": "not_required"}), + "peer_reservation": reservations_by_domain.get( + first_peer_domain, + {}, + ), + "local_release": releases_by_domain.get( + DOMAIN_ID, + {"ok": True, "status": "not_required"}, + ), + "peer_release": releases_by_domain.get( + first_peer_domain, + {"ok": True, "status": "not_required"}, + ), "workflow_trace": trace.export(), } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result provision_rows = await asyncio.gather( @@ -1973,17 +2378,19 @@ async def _create_path_wide_cross_domain_optical_service( "provisioning", f"domain_{segment['domain_id']}", "path_wide_provision_optical_segment", - _segment_provision(segment, selected_band, sizing, user_id, session_id), + _segment_provision(segment, selected_band, + sizing, user_id, session_id), parallel_group="path_service_provisioning", ) for segment in segments ] ) failed_provisions = [ - row for row in provision_rows - if not isinstance(row.get("result"), dict) or not row["result"].get("ok") - ] + row for row in provision_rows if not isinstance( + row.get("result"), + dict) or not row["result"].get("ok")] reservations_by_domain = _segment_map(reservation_rows) + provisions_by_domain = _segment_map(provision_rows) if failed_provisions: rollback_rows = await asyncio.gather( *[ @@ -1995,14 +2402,19 @@ async def _create_path_wide_cross_domain_optical_service( parallel_group="path_provision_failure_rollback", ) for segment in segments - if _segment_map(provision_rows).get(str(segment["domain_id"]), {}).get("ok") + if ( + provisions_by_domain + .get(str(segment["domain_id"]), {}) + .get("ok") + ) ], *[ trace.timed( "rollback", f"domain_{segment['domain_id']}", "path_wide_release_spectrum_after_provision_failure", - _segment_release(segment, reservations_by_domain[str(segment["domain_id"])], user_id, session_id), + _segment_release(segment, reservations_by_domain[str( + segment["domain_id"])], user_id, session_id), parallel_group="path_provision_failure_rollback", ) for segment in segments @@ -2021,7 +2433,8 @@ async def _create_path_wide_cross_domain_optical_service( "rollback_results": rollback_rows, "workflow_trace": trace.export(), } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result commit_rows = await asyncio.gather( @@ -2030,16 +2443,19 @@ async def _create_path_wide_cross_domain_optical_service( "verification", f"domain_{segment['domain_id']}", "path_wide_commit_spectrum_reservation", - _segment_commit(segment, reservations_by_domain[str(segment["domain_id"])], user_id, session_id), + _segment_commit(segment, reservations_by_domain[str( + segment["domain_id"])], user_id, session_id), parallel_group="path_reservation_commit", ) for segment in segments ] ) failed_commits = [ - row for row in commit_rows - if not isinstance(row.get("result"), dict) or not row["result"].get("ok", True) - ] + row for row in commit_rows if not isinstance( + row.get("result"), + dict) or not row["result"].get( + "ok", + True)] if failed_commits: rollback_rows = await asyncio.gather( *[ @@ -2057,7 +2473,8 @@ async def _create_path_wide_cross_domain_optical_service( "rollback", f"domain_{segment['domain_id']}", "path_wide_release_spectrum_after_commit_failure", - _segment_release(segment, reservations_by_domain[str(segment["domain_id"])], user_id, session_id), + _segment_release(segment, reservations_by_domain[str( + segment["domain_id"])], user_id, session_id), parallel_group="path_commit_failure_rollback", ) for segment in segments @@ -2077,13 +2494,27 @@ async def _create_path_wide_cross_domain_optical_service( "rollback_results": rollback_rows, "workflow_trace": trace.export(), } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result - segment_service_uuids = [str(segment["service_id"]) for segment in segments] + segment_service_uuids = [str(segment["service_id"]) + for segment in segments] 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", @@ -2120,14 +2551,21 @@ async def _create_path_wide_cross_domain_optical_service( "workflow_trace": trace.export(), "path_wide_orchestration": True, } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result def _summarize_devices(devices: List[Dict[str, Any]]) -> Dict[str, Any]: - names = sorted(str(item.get("name")) for item in devices if isinstance(item, dict) and item.get("name")) - endpoint_devices = [name for name in names if name.startswith("DC") and "-TP" in name] - abstraction_devices = [name for name in names if name.startswith("DOMAIN-") or name == "REMOTE-NET"] + names = sorted( + str(item.get("name")) + for item in devices + if isinstance(item, dict) and item.get("name") + ) + endpoint_devices = [ + name for name in names if name.startswith("DC") and "-TP" in name] + abstraction_devices = [name for name in names if name.startswith( + "DOMAIN-") or name == "REMOTE-NET"] return { "device_count": len(names), "devices": names, @@ -2136,7 +2574,7 @@ def _summarize_devices(devices: List[Dict[str, Any]]) -> Dict[str, Any]: } -def _device_name(device: Dict[str, Any]) -> str: +def _inventory_device_name(device: Dict[str, Any]) -> str: return str(device.get("name") or device.get("device_uuid") or "").strip() @@ -2147,9 +2585,14 @@ def _device_type(device: Dict[str, Any]) -> str: def _resource_name(resource: Dict[str, Any], id_field: str) -> str: if resource.get("name"): return str(resource.get("name")) - nested_id = resource.get(id_field, {}) if isinstance(resource, dict) else {} + nested_id = resource.get(id_field, {}) if isinstance( + resource, dict) else {} if isinstance(nested_id, dict): - for key in ("device_uuid", "link_uuid", "service_uuid", "connection_uuid"): + for key in ( + "device_uuid", + "link_uuid", + "service_uuid", + "connection_uuid"): value = nested_id.get(key, {}) if isinstance(value, dict) and value.get("uuid"): return str(value.get("uuid")) @@ -2165,7 +2608,10 @@ def _uuid_from_path(payload: Dict[str, Any], path: List[str]) -> str: def _endpoint_id_summary(endpoint_id: Dict[str, Any]) -> Dict[str, str]: return { - "device_uuid": _uuid_from_path(endpoint_id, ["device_id", "device_uuid"]), + "device_uuid": _uuid_from_path( + endpoint_id, + ["device_id", "device_uuid"], + ), "endpoint_uuid": _uuid_from_path(endpoint_id, ["endpoint_uuid"]), } @@ -2183,10 +2629,14 @@ def _config_rule_count(resource: Dict[str, Any], config_key: str) -> int: return len(rules) if isinstance(rules, list) else 0 -def _compact_device(device: Dict[str, Any], include_config_rules: bool, include_ids: bool) -> Dict[str, Any]: +def _compact_device( + device: Dict[str, Any], + include_config_rules: bool, + include_ids: bool, +) -> Dict[str, Any]: endpoints = device.get("device_endpoints", []) or [] compact: Dict[str, Any] = { - "name": _device_name(device), + "name": _inventory_device_name(device), "device_type": device.get("device_type", ""), "operational_status": device.get("device_operational_status", ""), "drivers": device.get("device_drivers", []), @@ -2197,11 +2647,16 @@ def _compact_device(device: Dict[str, Any], include_config_rules: bool, include_ if include_config_rules: compact["device_config"] = device.get("device_config", {}) else: - compact["config_rule_count"] = _config_rule_count(device, "device_config") + compact["config_rule_count"] = _config_rule_count( + device, "device_config") return compact -def _compact_endpoint(endpoint: Dict[str, Any], device_name: str, include_ids: bool) -> Dict[str, Any]: +def _compact_endpoint( + endpoint: Dict[str, Any], + device_name: str, + include_ids: bool, +) -> Dict[str, Any]: compact = { "device_name": device_name, "endpoint_name": endpoint.get("name", ""), @@ -2215,17 +2670,21 @@ def _compact_endpoint(endpoint: Dict[str, Any], device_name: str, include_ids: b def _compact_link(link: Dict[str, Any], include_ids: bool) -> Dict[str, Any]: endpoints = link.get("link_endpoint_ids", []) or [] compact = { - "name": link.get("name", ""), - "link_type": link.get("link_type", ""), - "endpoints": [_endpoint_ref(endpoint) for endpoint in endpoints if isinstance(endpoint, dict)], - } + "name": link.get( + "name", ""), "link_type": link.get( + "link_type", ""), "endpoints": [ + _endpoint_ref(endpoint) for endpoint in endpoints if isinstance( + endpoint, dict)], } if include_ids: compact["link_id"] = link.get("link_id", {}) compact["link_endpoint_ids"] = endpoints return compact -def _compact_optical_link(link: Dict[str, Any], include_ids: bool) -> Dict[str, Any]: +def _compact_optical_link( + link: Dict[str, Any], + include_ids: bool, +) -> Dict[str, Any]: compact = _compact_link(link, include_ids) details = link.get("optical_details", {}) if isinstance(link, dict) else {} if isinstance(details, dict): @@ -2233,16 +2692,26 @@ def _compact_optical_link(link: Dict[str, Any], include_ids: bool) -> Dict[str, return compact -def _compact_service(service: Dict[str, Any], include_config_rules: bool, include_ids: bool) -> Dict[str, Any]: +def _compact_service( + service: Dict[str, Any], + include_config_rules: bool, + include_ids: bool, +) -> Dict[str, Any]: endpoints = service.get("service_endpoint_ids", []) or [] constraints = service.get("service_constraints", []) or [] - status = service.get("service_status", {}) if isinstance(service.get("service_status", {}), dict) else {} + status = service.get("service_status", {}) if isinstance( + service.get("service_status", {}), dict) else {} compact: Dict[str, Any] = { "name": service.get("name", ""), "service_type": service.get("service_type", ""), - "status": status.get("service_status", service.get("service_status", "")), + "status": status.get( + "service_status", + service.get("service_status", ""), + ), "endpoint_count": len(endpoints) if isinstance(endpoints, list) else 0, - "constraint_count": len(constraints) if isinstance(constraints, list) else 0, + "constraint_count": ( + len(constraints) if isinstance(constraints, list) else 0 + ), } if include_ids: compact["service_id"] = service.get("service_id", {}) @@ -2250,11 +2719,15 @@ def _compact_service(service: Dict[str, Any], include_config_rules: bool, includ if include_config_rules: compact["service_config"] = service.get("service_config", {}) else: - compact["config_rule_count"] = _config_rule_count(service, "service_config") + compact["config_rule_count"] = _config_rule_count( + service, "service_config") return compact -def _compact_connection(connection: Dict[str, Any], include_ids: bool) -> Dict[str, Any]: +def _compact_connection( + connection: Dict[str, Any], + include_ids: bool, +) -> Dict[str, Any]: path_hops = connection.get("path_hops_endpoint_ids", []) or [] compact = { "name": connection.get("name", ""), @@ -2275,7 +2748,7 @@ def _normalize_detail_level(detail_level: str) -> str: def _device_matches_filter(device: Dict[str, Any], device_filter: str) -> bool: - name = _device_name(device) + name = _inventory_device_name(device) upper = name.upper() dtype = _device_type(device) if device_filter in {"all", "devices"}: @@ -2283,7 +2756,8 @@ def _device_matches_filter(device: Dict[str, Any], device_filter: str) -> bool: if device_filter == "datacenters": return upper.startswith("DC") and "-TP" not in upper if device_filter == "transponders": - return "transponder" in dtype or (upper.startswith("DC") and "-TP" in upper) + return "transponder" in dtype or ( + upper.startswith("DC") and "-TP" in upper) if device_filter == "roadms": return "roadm" in dtype or upper.startswith("MGON") if device_filter == "routers": @@ -2293,15 +2767,18 @@ def _device_matches_filter(device: Dict[str, Any], device_filter: str) -> bool: return device_filter in dtype or device_filter in upper.lower() -def _filter_device_records(devices: List[Dict[str, Any]], device_filter: str) -> List[Dict[str, Any]]: +def _filter_device_records( + devices: List[Dict[str, Any]], + device_filter: str, +) -> List[Dict[str, Any]]: filtered = [] for device in devices: if not isinstance(device, dict): continue - name = _device_name(device) + name = _inventory_device_name(device) if name and _device_matches_filter(device, device_filter): filtered.append(device) - return sorted(filtered, key=_device_name) + return sorted(filtered, key=_inventory_device_name) def _endpoint_records( @@ -2311,16 +2788,22 @@ def _endpoint_records( ) -> List[Dict[str, Any]]: records = [] for device in _filter_device_records(devices, device_filter): - device_name = _device_name(device) + device_name = _inventory_device_name(device) for endpoint in device.get("device_endpoints", []) or []: if not isinstance(endpoint, dict): continue - records.append(_compact_endpoint(endpoint, device_name, include_ids)) - return sorted(records, key=lambda item: (item["device_name"], item["endpoint_name"])) + records.append(_compact_endpoint( + endpoint, device_name, include_ids)) + return sorted( + records, + key=lambda item: ( + item["device_name"], + item["endpoint_name"])) def _normalize_resource_kind(resource_kind: str) -> str: - kind = (resource_kind or "devices").strip().lower().replace("_", "-").replace(" ", "-") + kind = (resource_kind or "devices").strip( + ).lower().replace("_", "-").replace(" ", "-") aliases = { "all": "all", "inventory": "all", @@ -2368,12 +2851,29 @@ def _normalize_resource_kind(resource_kind: str) -> str: def _resource_plan(resource_kind: str) -> Dict[str, Any]: normalized = _normalize_resource_kind(resource_kind) - device_filters = {"datacenters", "transponders", "roadms", "routers", "remote_abstractions"} + device_filters = {"datacenters", "transponders", + "roadms", "routers", "remote_abstractions"} if normalized in device_filters: - return {"resource_family": "devices", "device_filter": normalized, "resource_kind": normalized} - if normalized in {"all", "devices", "endpoints", "links", "optical_links", "services", "connections"}: - return {"resource_family": normalized, "device_filter": "all", "resource_kind": normalized} - return {"resource_family": "devices", "device_filter": normalized, "resource_kind": normalized} + return { + "resource_family": "devices", + "device_filter": normalized, + "resource_kind": normalized} + if normalized in { + "all", + "devices", + "endpoints", + "links", + "optical_links", + "services", + "connections"}: + return { + "resource_family": normalized, + "device_filter": "all", + "resource_kind": normalized} + return { + "resource_family": "devices", + "device_filter": normalized, + "resource_kind": normalized} def _normalize_device_filter(device_filter: str) -> str: @@ -2387,7 +2887,13 @@ def _normalize_device_filter(device_filter: str) -> str: async def _local_resource_payload(resource_family: str) -> Dict[str, Any]: if resource_family == "all": - devices, links, optical_links, services, connections = await asyncio.gather( + ( + devices, + links, + optical_links, + services, + connections, + ) = await asyncio.gather( call_mcp_tool("tfs_list_devices", {}), call_mcp_tool("tfs_list_links", {}), call_mcp_tool("tfs_list_optical_links", {}), @@ -2395,11 +2901,31 @@ async def _local_resource_payload(resource_family: str) -> Dict[str, Any]: call_mcp_tool("tfs_list_all_connections", {}), ) return { - "devices": devices.get("devices", []) if isinstance(devices, dict) else [], - "links": links.get("links", []) if isinstance(links, dict) else [], - "optical_links": optical_links.get("optical_links", []) if isinstance(optical_links, dict) else [], - "services": services.get("services", []) if isinstance(services, dict) else [], - "connections": connections.get("connections", []) if isinstance(connections, dict) else [], + "devices": devices.get( + "devices", + []) if isinstance( + devices, + dict) else [], + "links": links.get( + "links", + []) if isinstance( + links, + dict) else [], + "optical_links": optical_links.get( + "optical_links", + []) if isinstance( + optical_links, + dict) else [], + "services": services.get( + "services", + []) if isinstance( + services, + dict) else [], + "connections": connections.get( + "connections", + []) if isinstance( + connections, + dict) else [], } if resource_family in {"devices", "endpoints"}: return await call_mcp_tool("tfs_list_devices", {}) @@ -2457,7 +2983,8 @@ async def _domain_inventory_entry( include_ids, ) ) - if role != "local" and isinstance(payload, dict) and payload.get("resource_family"): + if role != "local" and isinstance(payload, + dict) and payload.get("resource_family"): return {**payload, "domain_id": domain_id, "role": role} entry: Dict[str, Any] = { "domain_id": domain_id, @@ -2469,51 +2996,64 @@ async def _domain_inventory_entry( if resource_family in {"all", "devices"}: filtered_devices = _filter_device_records(devices, device_filter) entry["devices"] = ( - filtered_devices - if detail_level == "full" - else [_compact_device(device, include_config_rules, include_ids) for device in filtered_devices] - ) - entry["device_names"] = [_device_name(device) for device in filtered_devices] + filtered_devices if detail_level == "full" else [ + _compact_device( + device, + include_config_rules, + include_ids) for device in filtered_devices]) + entry["device_names"] = [ + _inventory_device_name(device) for device in filtered_devices + ] entry["device_count"] = len(filtered_devices) if resource_family in {"all", "endpoints"}: - source_devices = devices or (payload.get("devices", []) if isinstance(payload, dict) else []) - endpoints = _endpoint_records(source_devices, device_filter, include_ids) + source_devices = devices or (payload.get( + "devices", []) if isinstance(payload, dict) else []) + endpoints = _endpoint_records( + source_devices, device_filter, include_ids) entry["endpoints"] = endpoints entry["endpoint_count"] = len(endpoints) if resource_family in {"all", "links"}: links = payload.get("links", []) if isinstance(payload, dict) else [] - entry["links"] = links if detail_level == "full" else [_compact_link(link, include_ids) for link in links] + entry["links"] = links if detail_level == "full" else [ + _compact_link(link, include_ids) for link in links] entry["link_count"] = len(links) if resource_family in {"all", "optical_links"}: - optical_links = payload.get("optical_links", []) if isinstance(payload, dict) else [] + optical_links = payload.get( + "optical_links", []) if isinstance(payload, dict) else [] entry["optical_links"] = ( - optical_links - if detail_level == "full" - else [_compact_optical_link(link, include_ids) for link in optical_links] - ) + optical_links if detail_level == "full" else [ + _compact_optical_link( + link, include_ids) for link in optical_links]) entry["optical_link_count"] = len(optical_links) if resource_family in {"all", "services"}: - services = payload.get("services", []) if isinstance(payload, dict) else [] + services = payload.get("services", []) if isinstance( + payload, dict) else [] entry["services"] = ( - services - if detail_level == "full" - else [_compact_service(service, include_config_rules, include_ids) for service in services] - ) + services if detail_level == "full" else [ + _compact_service( + service, + include_config_rules, + include_ids) for service in services]) entry["service_count"] = len(services) if resource_family in {"all", "connections"}: - connections = payload.get("connections", []) if isinstance(payload, dict) else [] + connections = payload.get( + "connections", []) if isinstance(payload, dict) else [] entry["connections"] = ( - connections - if detail_level == "full" - else [_compact_connection(connection, include_ids) for connection in connections] - ) + connections if detail_level == "full" else [ + _compact_connection( + connection, + include_ids) for connection in connections]) entry["connection_count"] = len(connections) if include_optical_links: if role == "local": links_payload = await call_mcp_tool("tfs_list_optical_links", {}) else: - links_payload = await peer_get(domain_id, "/inventory/optical-links") - entry["optical_links"] = links_payload.get("optical_links", []) if isinstance(links_payload, dict) else [] + links_payload = await peer_get( + domain_id, + "/inventory/optical-links", + ) + entry["optical_links"] = links_payload.get( + "optical_links", []) if isinstance(links_payload, dict) else [] entry["optical_link_count"] = len(entry["optical_links"]) return entry @@ -2590,14 +3130,33 @@ async def list_domain_inventory( "domains_checked": sorted(domains), "domains": domains, "failures": failures, - "total_device_count": sum(entry.get("device_count", 0) for entry in domains.values()), - "total_endpoint_count": sum(entry.get("endpoint_count", 0) for entry in domains.values()), - "total_link_count": sum(entry.get("link_count", 0) for entry in domains.values()), - "total_optical_link_count": sum(entry.get("optical_link_count", 0) for entry in domains.values()), - "total_service_count": sum(entry.get("service_count", 0) for entry in domains.values()), - "total_connection_count": sum(entry.get("connection_count", 0) for entry in domains.values()), + "total_device_count": sum( + entry.get( + "device_count", + 0) for entry in domains.values()), + "total_endpoint_count": sum( + entry.get( + "endpoint_count", + 0) for entry in domains.values()), + "total_link_count": sum( + entry.get( + "link_count", + 0) for entry in domains.values()), + "total_optical_link_count": sum( + entry.get( + "optical_link_count", + 0) for entry in domains.values()), + "total_service_count": sum( + entry.get( + "service_count", + 0) for entry in domains.values()), + "total_connection_count": sum( + entry.get( + "connection_count", + 0) for entry in domains.values()), } - record_event("domain_inventory", result, user_id=user_id, session_id=session_id) + record_event("domain_inventory", result, + user_id=user_id, session_id=session_id) return result @@ -2654,13 +3213,21 @@ async def _local_device_connections( matches: List[Dict[str, Any]] = [] if include_packet_links: packet_payload = await call_mcp_tool("tfs_list_links", {}) - packet_links = packet_payload.get("links", []) if isinstance(packet_payload, dict) else [] - matches.extend(_connection_matches_from_links(device_name, DOMAIN_ID, packet_links, "packet")) + packet_links = packet_payload.get( + "links", []) if isinstance(packet_payload, dict) else [] + matches.extend(_connection_matches_from_links( + device_name, DOMAIN_ID, packet_links, "packet")) if include_optical_links: optical_payload = await call_mcp_tool("tfs_list_optical_links", {}) - optical_links = optical_payload.get("optical_links", []) if isinstance(optical_payload, dict) else [] - matches.extend(_connection_matches_from_links(device_name, DOMAIN_ID, optical_links, "optical")) - return {"ok": True, "domain_id": DOMAIN_ID, "device_name": device_name.strip(), "connections": matches} + optical_links = optical_payload.get( + "optical_links", []) if isinstance(optical_payload, dict) else [] + matches.extend(_connection_matches_from_links( + device_name, DOMAIN_ID, optical_links, "optical")) + return { + "ok": True, + "domain_id": DOMAIN_ID, + "device_name": device_name.strip(), + "connections": matches} async def list_device_connections( @@ -2670,10 +3237,14 @@ async def list_device_connections( user_id: str = "default", session_id: str = "default", ) -> Dict[str, Any]: - """Return direct packet/optical neighbors for a device across MD-NAF domains.""" + """Return direct packet/optical neighbors for a device.""" target = device_name.strip() - local_result = await _local_device_connections(target, include_packet_links, include_optical_links) + local_result = await _local_device_connections( + target, + include_packet_links, + include_optical_links, + ) all_connections = list(local_result.get("connections", [])) domains_checked = [DOMAIN_ID] peer_results = {} @@ -2688,9 +3259,16 @@ async def list_device_connections( ), ) peer_results[peer_domain] = peer_result - all_connections.extend(peer_result.get("connections", []) if isinstance(peer_result, dict) else []) + all_connections.extend(peer_result.get( + "connections", []) if isinstance(peer_result, dict) else []) - neighbor_names = sorted({item["neighbor_device"] for item in all_connections if item.get("neighbor_device")}) + neighbor_names = sorted( + { + item["neighbor_device"] + for item in all_connections + if item.get("neighbor_device") + } + ) result = { "ok": bool(all_connections), "device_name": target, @@ -2700,7 +3278,8 @@ async def list_device_connections( "peer_results": peer_results, "error": None if all_connections else "connections_not_found", } - record_event("list_device_connections", result, user_id=user_id, session_id=session_id) + record_event("list_device_connections", result, + user_id=user_id, session_id=session_id) return result @@ -2712,12 +3291,17 @@ async def list_mdnaf_domains( """Return the MD-NAF domain view, not the TFS context list.""" local_payload = await call_mcp_tool("tfs_list_devices", {}) - local_devices = local_payload.get("devices", []) if isinstance(local_payload, dict) else [] + local_devices = local_payload.get( + "devices", []) if isinstance(local_payload, dict) else [] domains: Dict[str, Any] = { DOMAIN_ID: { "domain_id": DOMAIN_ID, "role": "local", - **(_summarize_devices(local_devices) if include_devices else {"device_count": len(local_devices)}), + **( + _summarize_devices(local_devices) + if include_devices + else {"device_count": len(local_devices)} + ), } } peer_domains = sorted(parse_peers()) @@ -2737,16 +3321,24 @@ async def list_mdnaf_domains( domains[peer_domain] = { "domain_id": peer_domain, "role": "peer", - **(_summarize_devices(peer_devices) if include_devices else {"device_count": len(peer_devices)}), + **( + _summarize_devices(peer_devices) + if include_devices + else {"device_count": len(peer_devices)} + ), } result = { "ok": True, "local_domain": DOMAIN_ID, "domain_count": len(domains), "domains": domains, - "note": "These are MD-NAF controller domains. TFS contexts are separate; the default TFS context is normally admin.", + "note": ( + "These are MD-NAF controller domains. TFS contexts are separate; " + "the default TFS context is normally admin." + ), } - record_event("mdnaf_domain_summary", result, user_id=user_id, session_id=session_id) + record_event("mdnaf_domain_summary", result, + user_id=user_id, session_id=session_id) return result @@ -2759,9 +3351,14 @@ async def locate_device( target = device_name.strip() local_payload = await call_mcp_tool("tfs_list_devices", {}) - local_devices = local_payload.get("devices", []) if isinstance(local_payload, dict) else [] + local_devices = local_payload.get( + "devices", []) if isinstance(local_payload, dict) else [] domains: Dict[str, List[str]] = { - DOMAIN_ID: sorted(str(item.get("name")) for item in local_devices if isinstance(item, dict) and item.get("name")) + DOMAIN_ID: sorted( + str(item.get("name")) + for item in local_devices + if isinstance(item, dict) and item.get("name") + ) } peer_domains = sorted(parse_peers()) peer_results = await asyncio.gather( @@ -2773,14 +3370,20 @@ async def locate_device( domains[peer_domain] = [] continue domains[peer_domain] = sorted( - str(item.get("name")) for item in peer_payload.get("devices", []) if isinstance(item, dict) and item.get("name") + str(item.get("name")) + for item in peer_payload.get("devices", []) + if isinstance(item, dict) and item.get("name") ) matches = [] target_upper = target.upper() for domain_id, names in domains.items(): for name in names: upper = name.upper() - if upper == target_upper or upper.startswith(f"{target_upper}-") or target_upper in upper: + if ( + upper == target_upper + or upper.startswith(f"{target_upper}-") + or target_upper in upper + ): matches.append({"domain_id": domain_id, "device_name": name}) result = { "ok": bool(matches), @@ -2789,7 +3392,8 @@ async def locate_device( "domains_checked": sorted(domains), "error": None if matches else "device_not_found", } - record_event("locate_device", result, user_id=user_id, session_id=session_id) + record_event("locate_device", result, + user_id=user_id, session_id=session_id) return result @@ -2805,7 +3409,7 @@ async def create_cross_domain_optical_service( user_id: str = "default", session_id: str = "default", ) -> Dict[str, Any]: - """Create a two-domain optical service using static peer negotiation.""" + """Create a path-wide optical service using peer-domain negotiation.""" trace = WorkflowTrace() source_device = source_device.strip() @@ -2813,12 +3417,15 @@ async def create_cross_domain_optical_service( service_name = (service_name or f"xdom-{uuid.uuid4().hex[:10]}").strip() minimum_slot = int(minimum_slot) if minimum_slot is not None else None planning_started = time.perf_counter() - preferred_band, channel_width_ghz, normalized_spectrum_inputs = _normalize_spectrum_inputs( + ( preferred_band, channel_width_ghz, - ) - sizing = _resolve_spectrum_sizing(capacity_gbps, modulation_format, channel_width_ghz) - trace.mark("planning", "domain_agent", "normalize_and_size_spectrum", "ok" if sizing.get("ok") else "failed", planning_started) + normalized_spectrum_inputs, + ) = _normalize_spectrum_inputs(preferred_band, channel_width_ghz) + sizing = _resolve_spectrum_sizing( + capacity_gbps, modulation_format, channel_width_ghz) + trace.mark("planning", "domain_agent", "normalize_and_size_spectrum", + "ok" if sizing.get("ok") else "failed", planning_started) if not sizing.get("ok"): result = { "ok": False, @@ -2827,12 +3434,19 @@ async def create_cross_domain_optical_service( "normalized_spectrum_inputs": normalized_spectrum_inputs, "workflow_trace": trace.export(), } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result required_slots = int(sizing["required_slots"]) - local_inventory = await trace.timed("planning", "local_mcp", "tfs_list_devices", call_mcp_tool("tfs_list_devices", {})) - local_devices = local_inventory.get("devices", []) if isinstance(local_inventory, dict) else [] + local_inventory = await trace.timed( + "planning", + "local_mcp", + "tfs_list_devices", + call_mcp_tool("tfs_list_devices", {}), + ) + local_devices = local_inventory.get( + "devices", []) if isinstance(local_inventory, dict) else [] source_resolution, destination_resolution = await asyncio.gather( trace.timed( "planning", @@ -2845,7 +3459,8 @@ async def create_cross_domain_optical_service( "planning", "endpoint_resolver_agent", "resolve_destination_endpoint_owner", - _resolve_optical_endpoint_device(destination_device, local_devices), + _resolve_optical_endpoint_device( + destination_device, local_devices), parallel_group="endpoint_resolution", ), ) @@ -2858,7 +3473,8 @@ async def create_cross_domain_optical_service( "destination_resolution": destination_resolution, "workflow_trace": trace.export(), } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result requested_source_device = source_device @@ -2902,9 +3518,14 @@ async def create_cross_domain_optical_service( "destination": destination_resolution, }, "workflow_trace": trace.export(), - "delegated_workflow_trace": delegated.get("workflow_trace", []) if isinstance(delegated, dict) else [], + "delegated_workflow_trace": delegated.get( + "workflow_trace", + []) if isinstance( + delegated, + dict) else [], } - record_event("cross_domain_create_delegated", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create_delegated", result, + user_id=user_id, session_id=session_id) return result if destination_domain == DOMAIN_ID: @@ -2920,13 +3541,23 @@ async def create_cross_domain_optical_service( }, "workflow_trace": trace.export(), } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result route_started = time.perf_counter() - route = await _select_next_hop_domain(destination_domain, local_devices, trace) + route = await _select_next_hop_domain( + destination_domain, + local_devices, + trace, + ) if route.get("direct"): - trace.mark("planning", "domain_agent", f"select_next_hop_to_{destination_domain}", "ok", route_started) + trace.mark( + "planning", + "domain_agent", + f"select_next_hop_to_{destination_domain}", + "ok", + route_started) if not route.get("ok"): result = { "ok": False, @@ -2938,7 +3569,8 @@ async def create_cross_domain_optical_service( }, "workflow_trace": trace.export(), } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_create", result, + user_id=user_id, session_id=session_id) return result return await _create_path_wide_cross_domain_optical_service( @@ -2961,591 +3593,6 @@ async def create_cross_domain_optical_service( session_id=session_id, ) - peer_domain = route["next_hop_domain"] - local_candidate_destination = _remote_domain_device(peer_domain) - peer_candidate_source = _remote_domain_device(DOMAIN_ID) - peer_candidate_destination = ( - destination_device if route.get("direct") else _remote_domain_device(destination_domain) - ) - local_candidates, peer_candidates = await asyncio.gather( - trace.timed( - "negotiation", - "local_mcp", - "tfs_compute_local_optical_connectivity_candidates", - compute_tfs_optical_connectivity_candidates( - source_device, - local_candidate_destination, - channel_width_ghz=sizing.get("channel_width_ghz"), - capacity_gbps=sizing.get("capacity_gbps"), - modulation_format=sizing.get("modulation_format", ""), - preferred_band=preferred_band or "c_slots", - ), - parallel_group="candidate_collection", - ), - trace.timed( - "negotiation", - f"domain_{peer_domain}", - "request_peer_tfs_optical_connectivity_candidates", - peer_post( - peer_domain, - "/spectrum/candidates", - { - "source_device": peer_candidate_source, - "destination_device": peer_candidate_destination, - "required_slots": required_slots, - "preferred_band": preferred_band or "c_slots", - "capacity_gbps": sizing.get("capacity_gbps"), - "modulation_format": sizing.get("modulation_format", ""), - "channel_width_ghz": sizing.get("channel_width_ghz"), - "user_id": user_id, - "session_id": session_id, - }, - ), - parallel_group="candidate_collection", - ), - ) - sizing = _controller_sizing_from_candidates(sizing, local_candidates, peer_candidates) - if not sizing.get("ok", True): - result = { - "ok": False, - "phase": "controller_candidate_sizing", - "spectrum_sizing": sizing, - "local_candidates": local_candidates, - "peer_candidates": peer_candidates, - "workflow_trace": trace.export(), - } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) - return result - required_slots = int(sizing["required_slots"]) - selection_started = time.perf_counter() - selected = spectrum.intersect_and_select( - local_candidates.get("candidate_ranges", []), - peer_candidates.get("candidate_ranges", []), - required_slots, - minimum_slot=minimum_slot, - preferred_band=preferred_band, - ) - trace.mark("negotiation", "domain_agent", "intersect_and_select_spectrum", "ok" if selected.get("ok") else "failed", selection_started) - if not selected.get("ok"): - result = { - "ok": False, - "phase": "spectrum_intersection", - "local_candidates": local_candidates, - "peer_candidates": peer_candidates, - "selection": selected, - "workflow_trace": trace.export(), - } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) - return result - - selected_band = selected["preferred_band"] - local_service = _service_uuid_for_domain(DOMAIN_ID, service_name) - peer_service = _service_uuid_for_domain(peer_domain, service_name) - if route.get("direct"): - local_reservation, peer_reservation = await asyncio.gather( - trace.timed( - "negotiation", - "local_mcp", - "tfs_create_local_spectrum_reservation", - create_tfs_spectrum_reservation( - local_service, - selected["selected_range"], - local_candidates, - { - "role": "local", - "peer_domain": peer_domain, - "preferred_band": selected_band, - "requested_preferred_band": preferred_band, - "requested_minimum_slot": minimum_slot, - "correlation_id": service_name, - "spectrum_sizing": sizing, - }, - ), - parallel_group="spectrum_reservation", - ), - trace.timed( - "negotiation", - f"domain_{peer_domain}", - "tfs_create_peer_spectrum_reservation", - peer_post( - peer_domain, - "/spectrum/reserve", - { - "service_id": peer_service, - "selected_range": selected["selected_range"], - "candidate_bundle": peer_candidates, - "metadata": { - "role": "peer", - "requesting_domain": DOMAIN_ID, - "preferred_band": selected_band, - "requested_preferred_band": preferred_band, - "requested_minimum_slot": minimum_slot, - "correlation_id": service_name, - "spectrum_sizing": sizing, - }, - "user_id": user_id, - "session_id": session_id, - }, - ), - parallel_group="spectrum_reservation", - ), - ) - else: - local_reservation = await trace.timed( - "negotiation", - "local_mcp", - "tfs_create_local_spectrum_reservation", - create_tfs_spectrum_reservation( - local_service, - selected["selected_range"], - local_candidates, - { - "role": "local", - "peer_domain": peer_domain, - "preferred_band": selected_band, - "requested_preferred_band": preferred_band, - "requested_minimum_slot": minimum_slot, - "correlation_id": service_name, - "spectrum_sizing": sizing, - }, - ), - ) - peer_reservation = { - "ok": True, - "status": "delegated", - "reservation_id": "", - "domain_id": peer_domain, - "note": "next-hop workflow owns downstream reservations", - } - if not local_reservation.get("ok") or not peer_reservation.get("ok"): - local_release = {"ok": True, "status": "not_required"} - peer_release = {"ok": True, "status": "not_required"} - release_tasks = [] - if local_reservation.get("ok") and local_reservation.get("reservation_id"): - release_tasks.append(( - "local", - trace.timed( - "rollback", - "local_mcp", - "tfs_release_local_spectrum_reservation_after_reserve_failure", - release_tfs_spectrum_reservation( - local_reservation["reservation_id"], - local_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), - ), - parallel_group="reservation_failure_rollback", - ), - )) - if peer_reservation.get("ok") and peer_reservation.get("reservation_id"): - release_tasks.append(( - "peer", - trace.timed( - "rollback", - f"domain_{peer_domain}", - "tfs_release_peer_spectrum_reservation_after_reserve_failure", - peer_post( - peer_domain, - "/spectrum/update", - { - "reservation_id": peer_reservation.get("reservation_id", ""), - "context_uuid": peer_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), - "status": "rolled_back", - "user_id": user_id, - "session_id": session_id, - }, - ), - parallel_group="reservation_failure_rollback", - ), - )) - if release_tasks: - release_results = await asyncio.gather(*(task for _, task in release_tasks), return_exceptions=True) - for (scope, _), release_result in zip(release_tasks, release_results): - normalized_release = ( - {"ok": False, "phase": "release_exception", "error": str(release_result)} - if isinstance(release_result, Exception) - else release_result - ) - if scope == "local": - local_release = normalized_release - else: - peer_release = normalized_release - result = { - "ok": False, - "phase": ( - "reserve_peer" - if local_reservation.get("ok") and not peer_reservation.get("ok") - else "reserve_local" - if not local_reservation.get("ok") and peer_reservation.get("ok") - else "reserve_spectrum" - ), - "local_reservation": local_reservation, - "peer_reservation": peer_reservation, - "local_release": local_release, - "peer_release": peer_release, - "workflow_trace": trace.export(), - } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) - return result - - peer_create_awaitable = ( - peer_post( - peer_domain, - "/services/optical", - { - "source_device": _remote_domain_device(DOMAIN_ID), - "destination_device": destination_device, - "service_name": peer_service, - "preferred_band": selected_band, - "capacity_gbps": sizing.get("capacity_gbps"), - "modulation_format": sizing.get("modulation_format", ""), - "channel_width_ghz": sizing.get("channel_width_ghz"), - "required_slots_override": sizing.get("required_slots"), - "slot_width_ghz_override": sizing.get("slot_width_ghz"), - "user_id": user_id, - "session_id": session_id, - }, - ) - if route.get("direct") - else peer_post( - peer_domain, - "/workflows/cross-domain-optical", - { - "source_device": _remote_domain_device(DOMAIN_ID), - "destination_device": destination_device, - "service_name": service_name, - "minimum_slot": minimum_slot, - "preferred_band": selected_band, - "capacity_gbps": sizing.get("capacity_gbps"), - "modulation_format": sizing.get("modulation_format", ""), - "channel_width_ghz": sizing.get("channel_width_ghz"), - "user_id": user_id, - "session_id": session_id, - }, - ) - ) - peer_operation = "provision_peer_optical_segment" if route.get("direct") else "delegate_multihop_peer_workflow" - local_create, peer_create = await asyncio.gather( - trace.timed( - "local_provisioning", - "domain_local", - "provision_local_optical_segment", - create_optical_connectivity_service( - source_device, - _remote_domain_device(peer_domain), - service_name=local_service, - preferred_band=selected_band, - capacity_gbps=sizing.get("capacity_gbps"), - modulation_format=sizing.get("modulation_format", ""), - channel_width_ghz=sizing.get("channel_width_ghz"), - required_slots_override=sizing.get("required_slots"), - slot_width_ghz_override=sizing.get("slot_width_ghz"), - ), - parallel_group="service_provisioning", - ), - trace.timed( - "remote_provisioning", - f"domain_{peer_domain}", - peer_operation, - peer_create_awaitable, - parallel_group="service_provisioning", - ), - ) - if not local_create.get("ok") or not peer_create.get("ok"): - rollback_tasks = [ - ( - "local_release", - trace.timed( - "rollback", - "local_mcp", - "tfs_release_local_spectrum_reservation_after_create_failure", - release_tfs_spectrum_reservation( - local_reservation["reservation_id"], - local_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), - ), - parallel_group="create_failure_rollback", - ), - ) - ] - if local_create.get("ok"): - rollback_tasks.append(( - "local_delete", - trace.timed( - "rollback", - "local_mcp", - "tfs_delete_local_service_after_create_failure", - delete_service(local_service), - parallel_group="create_failure_rollback", - ), - )) - if peer_create.get("ok"): - peer_delete_awaitable = ( - peer_post( - peer_domain, - "/services/delete", - {"service_uuid": peer_service, "user_id": user_id, "session_id": session_id}, - ) - if route.get("direct") - else peer_post( - peer_domain, - "/workflows/cross-domain-optical/delete", - {"request_id": service_name, "user_id": user_id, "session_id": session_id}, - ) - ) - rollback_tasks.append(( - "peer_delete", - trace.timed( - "rollback", - f"domain_{peer_domain}", - "tfs_delete_peer_service_after_create_failure", - peer_delete_awaitable, - parallel_group="create_failure_rollback", - ), - )) - if peer_reservation.get("reservation_id"): - rollback_tasks.append(( - "peer_release", - trace.timed( - "rollback", - f"domain_{peer_domain}", - "tfs_release_peer_spectrum_reservation_after_create_failure", - peer_post( - peer_domain, - "/spectrum/update", - { - "reservation_id": peer_reservation.get("reservation_id", ""), - "context_uuid": peer_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), - "status": "rolled_back", - "user_id": user_id, - "session_id": session_id, - }, - ), - parallel_group="create_failure_rollback", - ), - )) - rollback_results = await asyncio.gather(*(task for _, task in rollback_tasks), return_exceptions=True) - rollback = { - name: ( - {"ok": False, "phase": "rollback_exception", "error": str(value)} - if isinstance(value, Exception) - else value - ) - for (name, _), value in zip(rollback_tasks, rollback_results) - } - result = { - "ok": False, - "phase": "create_segments", - "local_reservation": local_reservation, - "peer_reservation": peer_reservation, - "local_release": rollback.get("local_release", {"ok": True, "status": "not_required"}), - "peer_release": rollback.get("peer_release", {"ok": True, "status": "not_required"}), - "local_delete": rollback.get("local_delete", {"ok": True, "status": "not_required"}), - "peer_delete": rollback.get("peer_delete", {"ok": True, "status": "not_required"}), - "peer_create": peer_create, - "local_create": local_create, - "workflow_trace": trace.export(), - } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) - return result - - if peer_reservation.get("reservation_id"): - local_commit, peer_commit = await asyncio.gather( - trace.timed( - "verification", - "local_mcp", - "tfs_consume_local_spectrum_reservation", - consume_tfs_spectrum_reservation( - local_reservation["reservation_id"], - local_service, - local_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), - ), - parallel_group="reservation_commit", - ), - trace.timed( - "verification", - f"domain_{peer_domain}", - "tfs_consume_peer_spectrum_reservation", - peer_post( - peer_domain, - "/spectrum/update", - { - "reservation_id": peer_reservation["reservation_id"], - "context_uuid": peer_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), - "status": "committed", - "service_id": peer_service, - "user_id": user_id, - "session_id": session_id, - }, - ), - parallel_group="reservation_commit", - ), - ) - else: - local_commit = await trace.timed( - "verification", - "local_mcp", - "tfs_consume_local_spectrum_reservation", - consume_tfs_spectrum_reservation( - local_reservation["reservation_id"], - local_service, - local_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), - ), - ) - peer_commit = {"ok": True, "status": "delegated", "domain_id": peer_domain} - if not local_commit.get("ok") or not peer_commit.get("ok", True): - peer_delete_awaitable = ( - peer_post( - peer_domain, - "/services/delete", - {"service_uuid": peer_service, "user_id": user_id, "session_id": session_id}, - ) - if route.get("direct") - else peer_post( - peer_domain, - "/workflows/cross-domain-optical/delete", - {"request_id": service_name, "user_id": user_id, "session_id": session_id}, - ) - ) - rollback_tasks = [ - ( - "local_delete", - trace.timed( - "rollback", - "local_mcp", - "tfs_delete_local_service_after_commit_failure", - delete_service(local_service), - parallel_group="commit_failure_rollback", - ), - ), - ( - "peer_delete", - trace.timed( - "rollback", - f"domain_{peer_domain}", - "tfs_delete_peer_service_after_commit_failure", - peer_delete_awaitable, - parallel_group="commit_failure_rollback", - ), - ), - ( - "local_release", - trace.timed( - "rollback", - "local_mcp", - "tfs_release_local_reservation_after_commit_failure", - release_tfs_spectrum_reservation( - local_reservation["reservation_id"], - local_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), - ), - parallel_group="commit_failure_rollback", - ), - ), - ] - if peer_reservation.get("reservation_id"): - rollback_tasks.append(( - "peer_release", - trace.timed( - "rollback", - f"domain_{peer_domain}", - "tfs_release_peer_reservation_after_commit_failure", - peer_post( - peer_domain, - "/spectrum/update", - { - "reservation_id": peer_reservation.get("reservation_id", ""), - "context_uuid": peer_reservation.get("context_uuid", DEFAULT_CONTEXT_UUID), - "status": "rolled_back", - "user_id": user_id, - "session_id": session_id, - }, - ), - parallel_group="commit_failure_rollback", - ), - )) - rollback_results = await asyncio.gather(*(task for _, task in rollback_tasks), return_exceptions=True) - rollback = { - name: ( - {"ok": False, "phase": "rollback_exception", "error": str(value)} - if isinstance(value, Exception) - else value - ) - for (name, _), value in zip(rollback_tasks, rollback_results) - } - result = { - "ok": False, - "phase": "commit_reservations", - "request_id": service_name, - "local_service_uuid": local_service, - "peer_service_uuid": peer_service, - "local_create": local_create, - "peer_create": peer_create, - "local_reservation": local_reservation, - "peer_reservation": peer_reservation, - "local_commit": local_commit, - "peer_commit": peer_commit, - "local_delete": rollback.get("local_delete", {"ok": True, "status": "not_required"}), - "peer_delete": rollback.get("peer_delete", {"ok": True, "status": "not_required"}), - "local_release": rollback.get("local_release", {"ok": True, "status": "not_required"}), - "peer_release": rollback.get("peer_release", {"ok": True, "status": "not_required"}), - "workflow_trace": trace.export(), - "peer_workflow_trace": peer_create.get("workflow_trace", []) if isinstance(peer_create, dict) else [], - "local_workflow_trace": local_create.get("workflow_trace", []) if isinstance(local_create, dict) else [], - } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) - return result - result = { - "ok": True, - "phase": "active", - "request_id": service_name, - "requested_source_device": requested_source_device, - "requested_destination_device": requested_destination_device, - "source_device": source_device, - "destination_device": destination_device, - "endpoint_resolution": { - "source": source_resolution, - "destination": destination_resolution, - }, - "local_domain": DOMAIN_ID, - "peer_domain": peer_domain, - "destination_domain": destination_domain, - "domain_path": route.get("domain_path", [DOMAIN_ID, peer_domain]), - "route": route, - "preferred_band": selected_band, - "requested_preferred_band": preferred_band, - "requested_minimum_slot": minimum_slot, - "spectrum_sizing": sizing, - "normalized_spectrum_inputs": normalized_spectrum_inputs, - "selection": selected, - "local_service_uuid": local_service, - "peer_service_uuid": peer_service, - "downstream_service_uuids": ( - 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] - ), - "segment_service_uuids": [ - local_service, - *( - 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] - ), - ], - "local_create": local_create, - "peer_create": peer_create, - "local_reservation": local_reservation, - "peer_reservation": peer_reservation, - "local_commit": local_commit, - "peer_commit": peer_commit, - "workflow_trace": trace.export(), - "peer_workflow_trace": peer_create.get("workflow_trace", []) if isinstance(peer_create, dict) else [], - "local_workflow_trace": local_create.get("workflow_trace", []) if isinstance(local_create, dict) else [], - } - record_event("cross_domain_create", result, user_id=user_id, session_id=session_id) - return result - async def inspect_cross_domain_inventory( include_optical_links: bool = True, @@ -3561,7 +3608,8 @@ async def inspect_cross_domain_inventory( user_id=user_id, session_id=session_id, ) - record_event("cross_domain_inventory", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_inventory", result, + user_id=user_id, session_id=session_id) return result @@ -3592,14 +3640,26 @@ async def delete_cross_domain_optical_service( for domain_id in domain_ids } if stored_targets.get("ok"): - for domain_id, service_ids in stored_targets.get("domains", {}).items(): + for domain_id, service_ids in stored_targets.get( + "domains", {}).items(): hints = set(service_candidates_by_domain.get(domain_id, [])) - hints.update(str(item) for item in service_ids if str(item).strip()) + hints.update(str(item) + for item in service_ids if str(item).strip()) service_candidates_by_domain[domain_id] = sorted(hints) local_service = _service_uuid_for_domain(DOMAIN_ID, request_id) - peer_service = _service_uuid_for_domain(peer_domain, request_id) if peer_domain else "" - local_candidate_services = service_candidates_by_domain.get(DOMAIN_ID, [local_service]) - all_candidate_services = sorted({item for values in service_candidates_by_domain.values() for item in values}) + peer_service = _service_uuid_for_domain( + peer_domain, request_id) if peer_domain else "" + local_candidate_services = service_candidates_by_domain.get( + DOMAIN_ID, + [local_service], + ) + all_candidate_services = sorted( + { + item + for values in service_candidates_by_domain.values() + for item in values + } + ) async def bounded_local_delete_by_request() -> Dict[str, Any]: try: @@ -3624,8 +3684,11 @@ async def delete_cross_domain_optical_service( "delete": delete_result, } - async def bounded_peer_delete_by_request(target_peer: str) -> Dict[str, Any]: - peer_candidate_services = service_candidates_by_domain.get(target_peer, []) + async def bounded_peer_delete_by_request( + target_peer: str, + ) -> Dict[str, Any]: + peer_candidate_services = service_candidates_by_domain.get( + target_peer, []) try: peer_result = await asyncio.wait_for( peer_post( @@ -3655,15 +3718,32 @@ async def delete_cross_domain_optical_service( "request_id": request_id, "error": str(exc), } - return {"scope": "peer", "domain_id": target_peer, "request_id": request_id, "delete": peer_result} + return { + "scope": "peer", + "domain_id": target_peer, + "request_id": request_id, + "delete": peer_result, + } if stored_targets.get("ok"): stored_domains = set(stored_targets.get("domains", {})) - target_peers = sorted(domain for domain in stored_domains if domain != DOMAIN_ID and domain in parse_peers()) - if peer_domain and peer_domain in parse_peers() and peer_domain not in target_peers: + target_peers = sorted( + domain + for domain in stored_domains + if domain != DOMAIN_ID and domain in parse_peers() + ) + if ( + peer_domain + and peer_domain in parse_peers() + and peer_domain not in target_peers + ): target_peers.append(peer_domain) else: - target_peers = [peer_domain] if peer_domain and peer_domain in parse_peers() else sorted(parse_peers()) + target_peers = ( + [peer_domain] + if peer_domain and peer_domain in parse_peers() + else sorted(parse_peers()) + ) local_delete_tasks = [ trace.timed( "deletion", @@ -3681,19 +3761,27 @@ async def delete_cross_domain_optical_service( "path_wide_delete_peer_by_request", bounded_peer_delete_by_request(target_peer), parallel_group="path_wide_delete_by_request", - input_payload={"domain_id": target_peer, "request_id": request_id}, + input_payload={ + "domain_id": target_peer, + "request_id": request_id, + }, ) for target_peer in target_peers ] - delete_results = await asyncio.gather(*(local_delete_tasks + peer_delete_tasks)) - local_deletes = [item for item in delete_results if item.get("scope") == "local"] - peer_deletes = [item for item in delete_results if item.get("scope") == "peer"] + delete_results = await asyncio.gather( + *(local_delete_tasks + peer_delete_tasks) + ) + local_deletes = [ + item for item in delete_results if item.get("scope") == "local"] + peer_deletes = [ + item for item in delete_results if item.get("scope") == "peer"] nested_workflow_trace = [] for item in delete_results: delete_result = item.get("delete", {}) if isinstance(delete_result, dict): - nested_workflow_trace.extend(delete_result.get("workflow_trace", []) or []) + nested_workflow_trace.extend( + delete_result.get("workflow_trace", []) or []) if nested_workflow_trace: trace.mark( "deletion", @@ -3728,5 +3816,6 @@ async def delete_cross_domain_optical_service( "nested_workflow_trace": nested_workflow_trace, "failures": failures, } - record_event("cross_domain_delete", result, user_id=user_id, session_id=session_id) + record_event("cross_domain_delete", result, + user_id=user_id, session_id=session_id) return result diff --git a/src/agentic/tests/test_unitary.py b/src/agentic/tests/test_unitary.py index f021cd789..03edf69b4 100644 --- a/src/agentic/tests/test_unitary.py +++ b/src/agentic/tests/test_unitary.py @@ -23,6 +23,10 @@ from agentic.Config import ( ) from agentic.service import peer_client from agentic.service.spectrum import spectrum_request +from agentic.service.tools.service_workflow import ( + _resolve_device_uuid_from_details, + _resolve_endpoint_uuid_from_details, +) def test_agentic_default_config(monkeypatch) -> None: @@ -56,3 +60,33 @@ def test_required_slots_from_capacity_and_modulation() -> None: result = spectrum_request(capacity_gbps=100.0, modulation_format="dp-qpsk") assert result["slot_width_ghz"] == 6.25 assert result["required_slots"] > 0 + + +def test_tfs_device_detail_endpoint_resolution() -> None: + devices = [ + { + "name": "DC1-TP1", + "device_id": {"device_uuid": {"uuid": "device-uuid-1"}}, + "device_endpoints": [ + { + "name": "CHANNEL", + "endpoint_id": { + "endpoint_uuid": {"uuid": "endpoint-uuid-1"}, + }, + }, + ], + }, + ] + + assert ( + _resolve_device_uuid_from_details(devices, "DC1-TP1") + == "device-uuid-1" + ) + assert ( + _resolve_endpoint_uuid_from_details( + devices, + "device-uuid-1", + "CHANNEL", + ) + == "endpoint-uuid-1" + ) -- GitLab From 90b13e7b0b8998615162ac856b48e2c381f6491b Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Tue, 21 Jul 2026 17:55:25 +0000 Subject: [PATCH 3/4] Document agentic and MCP server components --- src/agentic/Config.py | 29 ++ src/agentic/README.md | 207 +++++++++- src/agentic/service/a2a_transport.py | 42 +- src/agentic/service/agent.py | 2 +- src/agentic/service/agents/granular_root.py | 6 +- src/agentic/service/agents/inventory.py | 2 +- src/agentic/service/agents/retrieval.py | 2 +- src/agentic/service/agents/root.py | 12 +- src/agentic/service/agents/service.py | 12 +- src/agentic/service/agents/single.py | 6 +- src/agentic/service/domain_api.py | 105 ++++- src/agentic/service/settings.py | 2 + src/agentic/service/tools/granular.py | 12 +- src/agentic/service/tools/service.py | 16 +- src/agentic/service/tools/service_roles.py | 2 +- src/agentic/service/tools/service_workflow.py | 12 +- src/agentic/tests/test_unitary.py | 21 + src/mcp_server/README.md | 371 ++++++++++++++++++ 18 files changed, 785 insertions(+), 76 deletions(-) create mode 100644 src/mcp_server/README.md diff --git a/src/agentic/Config.py b/src/agentic/Config.py index c1651eccf..818cfcc12 100644 --- a/src/agentic/Config.py +++ b/src/agentic/Config.py @@ -38,6 +38,13 @@ DEFAULT_OLLAMA_PROXY_LISTEN_PORT = "11434" DEFAULT_OLLAMA_PROXY_TARGET_PORT = "11434" DEFAULT_TFS_CONTEXT_UUID = "admin" DEFAULT_TFS_TOPOLOGY_UUID = "admin" +DUMMY_DETERMINISTIC_MODELS = { + "ci", + "deterministic", + "dummy", + "dummy-deterministic", + "none", +} ENVVAR_ADK_A2A_PUBLIC_URL = "ADK_A2A_PUBLIC_URL" ENVVAR_ADK_A2A_TIMEOUT_SECONDS = "ADK_A2A_TIMEOUT_SECONDS" @@ -101,6 +108,28 @@ def get_agentic_model() -> str: return get_setting(ENVVAR_ADK_MODEL, default=DEFAULT_AGENTIC_MODEL) +def get_openai_api_key() -> str: + return get_setting(ENVVAR_OPENAI_API_KEY, default="").strip() + + +def is_agentic_dummy_deterministic_mode() -> bool: + return get_agentic_model().strip().lower() in DUMMY_DETERMINISTIC_MODELS + + +def validate_agentic_llm_configuration() -> None: + model_name = get_agentic_model().strip() + if not model_name: + raise RuntimeError("ADK_MODEL must be configured") + if is_agentic_dummy_deterministic_mode(): + return + if model_name.startswith("openai/") and not get_openai_api_key(): + raise RuntimeError( + "OPENAI_API_KEY must be configured for OpenAI-backed " + "Agentic models. Set ADK_MODEL=dummy-deterministic only " + "for CI tests that must not contact an LLM provider." + ) + + def get_agentic_graph() -> str: return get_setting(ENVVAR_ADK_AGENT_GRAPH, default=DEFAULT_AGENTIC_GRAPH) diff --git a/src/agentic/README.md b/src/agentic/README.md index af8da584e..208904ae6 100644 --- a/src/agentic/README.md +++ b/src/agentic/README.md @@ -1,9 +1,56 @@ # TFS Agentic Component -The `agentic` component exposes a per-domain MD-NAF Agentic Module as a -TeraFlowSDN service. It runs the Google ADK based runtime, accesses the local -TeraFlowSDN controller through the `mcp_server` component, and exposes HTTP and -A2A endpoints for operator requests and peer-domain coordination. +The `agentic` component exposes a TeraFlowSDN Agentic Module as a controller +service. One instance is normally co-located with one local TFS controller and +is responsible for that controller domain. It uses MCP to access the local +controller and standardized A2A to coordinate with peer Agentic instances when +a request spans multiple controller domains. + +The runtime combines: + +- LLM-based intent interpretation and answer composition. +- Deterministic workflows for controller-facing operations. +- MCP tools for local TFS inventory, service, connection, and optical resource + operations. +- A2A peer actions for cross-domain inventory, spectrum negotiation, + provisioning, teardown, and rollback. + +The controller remains the source of truth. Agentic does not invent topology, +endpoint, spectrum, service, or connection state. + +## Deployment Flavor + +Deploy Agentic after the core controller, Optical Controller, NBI, and MCP +Server components are available: + +```bash +cd ~/tfs-ctrl +TFS_COMPONENTS="context device pathcomp opticalcontroller service nbi webui mcp_server agentic" \ +CRDB_DROP_DATABASE_IF_EXISTS=YES \ +./deploy/all.sh +``` + +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`. + +Do not commit real LLM API keys, MCP tokens, SSH keys, or controller +credentials. Patch secrets at deployment time: + +```bash +kubectl create secret generic agentic-secrets \ + --namespace tfs \ + --from-literal=OPENAI_API_KEY="$OPENAI_API_KEY" \ + --from-literal=TFS_MCP_AUTH_TOKEN="${TFS_MCP_AUTH_TOKEN:-}" \ + --dry-run=client -o yaml | kubectl apply -f - + +kubectl rollout restart deployment/agenticservice -n tfs +kubectl rollout status deployment/agenticservice -n tfs +``` ## Runtime Configuration @@ -27,6 +74,46 @@ Main settings: - `OPENAI_API_KEY`: LLM provider secret, supplied through Kubernetes Secret. - `TFS_MCP_AUTH_TOKEN`: optional MCP authentication token, supplied through 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_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. +- `ADK_AGENT_STARTUP_WARMUP_PROMPTS`: `||`-separated warm-up prompts. + +## LLM Configuration And Fail-Fast Checks + +Agentic validates the LLM configuration during FastAPI 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. + +For CI or offline tests that must not contact any LLM provider, set: + +```yaml +ADK_MODEL: "dummy-deterministic" +``` + +This mode bypasses LLM warm-up and exposes a narrow deterministic path for +basic device-inventory prompts. It is intended for component and deployment +tests only, not for research experiments or production-like validation. + +Valid examples: + +```yaml +ADK_MODEL: "openai/gpt-4.1-mini" +OPENAI_API_KEY: "" +``` + +```yaml +ADK_MODEL: "ollama_chat/qwen2.5:7b-instruct" +OLLAMA_API_BASE: "http://:11434" +``` + +```yaml +ADK_MODEL: "dummy-deterministic" +``` ## Source Layout @@ -41,12 +128,101 @@ The component source is split into: ## Exposed Endpoints -- `GET /health`: component health and configured peer summary. -- `POST /agent/run`: operator-facing natural-language request endpoint. -- `GET /.well-known/agent-card.json`: A2A Agent Card. -- `POST /a2a`: A2A JSON-RPC endpoint for peer MD-NAF modules. -- Inventory, spectrum, and optical service workflow helper endpoints are - exposed for diagnostics and integration testing. +`GET /health` returns component health, local domain ID, peer summary, session +database health, and startup warm-up results. + +`POST /agent/run` receives operator-facing natural-language requests: + +```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" + }' +``` + +`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: + +- `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` + +Prefer `/agent/run` for operator-facing validation. Use direct diagnostic +endpoints only for integration tests and failure isolation. + +## Smoke Tests + +After deployment, check health: + +```bash +curl http:///agentic/health +``` + +Load an emulated scenario into the local controller: + +```bash +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" + }' +``` + +With the example descriptor, the expected answer reports seven local devices: +`R1`, `R2`, `R3`, `R4`, `R5`, `R6`, and `R7`. + +For optical service tuning tests, use the OFC24 optical descriptor: + +```bash +PYENV_VERSION=tfs ./src/tests/tools/load_scenario/run.sh \ + src/tests/ofc24/descriptors/topology.json +``` + +The companion payloads under `src/tests/ofc24/descriptors/` are useful as +controller-facing references: + +- `service-unidir.json`: unidirectional optical connectivity service. +- `service-bidir.json`: bidirectional optical connectivity service example. + +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" + }' +``` + +A successful mutating workflow must report the created TFS service UUIDs and +verify that every segment reaches `SERVICESTATUS_ACTIVE`. ## Tests @@ -54,3 +230,14 @@ Unit tests validate configuration parsing, peer parsing, and deterministic spectrum sizing. Integration tests use an in-process mocked MCP session, mocked MCP workflow replies, and a mocked ADK runner so CI can validate the Agentic component without a live controller or LLM API key. + +Useful local checks: + +```bash +PYTHONPATH=src python -m compileall -q src/agentic +PYTHONPATH=src python -m pytest -q src/agentic/tests/test_*.py +``` + +The CI-safe no-LLM configuration path is validated through +`ADK_MODEL=dummy-deterministic`. Normal deployment with OpenAI-backed models +must provide `OPENAI_API_KEY`; otherwise startup fails intentionally. diff --git a/src/agentic/service/a2a_transport.py b/src/agentic/service/a2a_transport.py index 9f4568f15..5ccfec282 100644 --- a/src/agentic/service/a2a_transport.py +++ b/src/agentic/service/a2a_transport.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""Standards-based A2A transport for MD-NAF ADK domain workflows.""" +"""Standards-based A2A transport for TFS Agentic domain workflows.""" from __future__ import annotations @@ -84,13 +84,13 @@ def _domain_api_base_url() -> str: def build_agent_card() -> AgentCard: - """Build the standards-compliant A2A Agent Card for one MD-NAF domain.""" + """Build the standards-compliant A2A Agent Card for one domain.""" base_url = _domain_api_base_url() return AgentCard( - name=f"md-naf-domain-{DOMAIN_ID.lower()}", + name=f"tfs-agentic-domain-{DOMAIN_ID.lower()}", description=( - "MD-NAF domain agent exposing TFS inventory, flex-grid spectrum " + "TFS Agentic domain agent exposing TFS inventory, flex-grid " "negotiation, optical service provisioning, and service teardown " "through the A2A protocol." ), @@ -106,46 +106,46 @@ def build_agent_card() -> AgentCard: defaultOutputModes=["application/json"], skills=[ AgentSkill( - id="mdnaf.inventory", + id="tfs.inventory", name="Remote domain inventory interrogation", description=( "List devices, endpoints, links, optical links, services, " "and connections in this domain." ), - tags=["md-naf", "inventory", "tfs", "mcp"], + tags=["tfs", "agentic", "inventory", "mcp"], inputModes=["application/json"], outputModes=["application/json"], ), AgentSkill( - id="mdnaf.spectrum", + id="tfs.spectrum", name="Flex-grid spectrum negotiation", description=( "Compute candidates, reserve spectrum, and update " "spectrum reservation state." ), - tags=["md-naf", "optical", "spectrum", "flex-grid"], + tags=["tfs", "agentic", "optical", "spectrum", "flex-grid"], inputModes=["application/json"], outputModes=["application/json"], ), AgentSkill( - id="mdnaf.optical-service", + id="tfs.optical-service", name="Optical connectivity service lifecycle", description=( "Create and delete TFS optical connectivity service " "segments in this domain." ), - tags=["md-naf", "optical", "service", "tfs"], + tags=["tfs", "agentic", "optical", "service"], inputModes=["application/json"], outputModes=["application/json"], ), AgentSkill( - id="mdnaf.cross-domain-workflow", + id="tfs.cross-domain-workflow", name="Cross-domain optical workflow delegation", description=( "Execute or continue a cross-domain optical service " "workflow through this domain." ), - tags=["md-naf", "a2a", "cross-domain", "workflow"], + tags=["tfs", "agentic", "a2a", "cross-domain", "workflow"], inputModes=["application/json"], outputModes=["application/json"], ), @@ -158,7 +158,7 @@ def install_a2a_routes(app: Any) -> None: """Mount A2A SDK routes onto an existing FastAPI/Starlette app.""" request_handler = DefaultRequestHandler( - agent_executor=MdNafDomainA2AExecutor(), + agent_executor=TfsAgenticDomainA2AExecutor(), task_store=InMemoryTaskStore(), ) a2a_app = A2AStarletteApplication( @@ -338,7 +338,7 @@ async def call_a2a_action( data={ "action": action, "payload": payload or {}, - "protocol": "md-naf-a2a-v1", + "protocol": "tfs-agentic-a2a-v1", "requesting_domain": DOMAIN_ID, } ) @@ -427,8 +427,8 @@ def _extract_message_data(message: Message) -> Dict[str, Any] | None: return None -class MdNafDomainA2AExecutor(AgentExecutor): - """A2A SDK executor exposing deterministic MD-NAF domain skills.""" +class TfsAgenticDomainA2AExecutor(AgentExecutor): + """A2A SDK executor exposing deterministic TFS Agentic domain skills.""" async def execute( self, @@ -448,7 +448,7 @@ class MdNafDomainA2AExecutor(AgentExecutor): if isinstance(result, dict) else True ), - "protocol": "md-naf-a2a-v1", + "protocol": "tfs-agentic-a2a-v1", "domain_id": DOMAIN_ID, "domain_name": DOMAIN_NAME, "action": action, @@ -462,7 +462,7 @@ class MdNafDomainA2AExecutor(AgentExecutor): ) response = { "ok": False, - "protocol": "md-naf-a2a-v1", + "protocol": "tfs-agentic-a2a-v1", "domain_id": DOMAIN_ID, "domain_name": DOMAIN_NAME, "action": action, @@ -480,7 +480,7 @@ class MdNafDomainA2AExecutor(AgentExecutor): _response_message( { "ok": False, - "protocol": "md-naf-a2a-v1", + "protocol": "tfs-agentic-a2a-v1", "domain_id": DOMAIN_ID, "error": "cancel_not_supported", }, @@ -508,7 +508,7 @@ async def dispatch_a2a_action( delete_local_services_for_request, delete_service, list_domain_inventory, - list_mdnaf_domains, + list_controller_domains, locate_device, release_tfs_spectrum_reservation, ) @@ -564,7 +564,7 @@ async def dispatch_a2a_action( bool(payload.get("include_optical_links", True)), ) if action == "domains.list": - return await list_mdnaf_domains( + return await list_controller_domains( include_devices=bool(payload.get("include_devices", True)), user_id=str(payload.get("user_id", "a2a")), session_id=str(payload.get("session_id", "a2a")), diff --git a/src/agentic/service/agent.py b/src/agentic/service/agent.py index 7a40ab0dc..0d7a6d7a4 100644 --- a/src/agentic/service/agent.py +++ b/src/agentic/service/agent.py @@ -12,7 +12,7 @@ # See the License for the specific language governing permissions and # limitations under the License. -"""ADK entrypoint for the isolated MD-NAF runtime.""" +"""ADK entrypoint for the TFS Agentic runtime.""" from agentic.service.settings import AGENT_GRAPH diff --git a/src/agentic/service/agents/granular_root.py b/src/agentic/service/agents/granular_root.py index 633b73a10..2962e49b0 100644 --- a/src/agentic/service/agents/granular_root.py +++ b/src/agentic/service/agents/granular_root.py @@ -25,7 +25,7 @@ from agentic.service.llm import build_litellm GRANULAR_ROOT_ROUTER_INSTRUCTION = """ -You are the first MD-NAF request router. +You are the first TFS Agentic request router. Choose only between: - retrieval_router_agent for read-only retrieval, listing, inspection, @@ -43,8 +43,8 @@ LiteLlm.model_rebuild(force=True) root_agent = Agent( model=build_litellm(), - name="md_naf_granular_root_router", - description="Minimal MD-NAF root router for granular specialist agents.", + name="tfs_agentic_granular_root_router", + description="Minimal TFS Agentic root router for specialist agents.", instruction=GRANULAR_ROOT_ROUTER_INSTRUCTION, sub_agents=[retrieval_router_agent, mutation_router_agent], ) diff --git a/src/agentic/service/agents/inventory.py b/src/agentic/service/agents/inventory.py index 51effcffd..71f52b596 100644 --- a/src/agentic/service/agents/inventory.py +++ b/src/agentic/service/agents/inventory.py @@ -26,7 +26,7 @@ from agentic.service.graphs import graph_summary INVENTORY_AGENT_INSTRUCTION = f""" -You are the MD-NAF TFS inventory specialist. +You are the TFS Agentic inventory specialist. Use only the exposed MCP tools to answer controller inventory questions. The controller is the source of truth. diff --git a/src/agentic/service/agents/retrieval.py b/src/agentic/service/agents/retrieval.py index 7049157d6..aef4d6815 100644 --- a/src/agentic/service/agents/retrieval.py +++ b/src/agentic/service/agents/retrieval.py @@ -92,7 +92,7 @@ Use query_cross_domain_device_headers for normal list/count/summary questions. Use query_cross_domain_detailed_devices only for raw, full, UUID, endpoint ID, config-rule, or debug requests. Use query_device_location for where questions. Use query_device_connections for neighbor or connected-to questions. -Render the final answer grouped by MD-NAF domain. +Render the final answer grouped by controller domain. """ LOCAL_PACKET_LINK_RETRIEVAL_INSTRUCTION = """ diff --git a/src/agentic/service/agents/root.py b/src/agentic/service/agents/root.py index c7e9678a4..71fdd4c40 100644 --- a/src/agentic/service/agents/root.py +++ b/src/agentic/service/agents/root.py @@ -27,7 +27,7 @@ from agentic.service.llm import build_litellm ROOT_AGENT_INSTRUCTION = """ -You are the root coordinator for the MD-NAF TFS-Agentic ADK runtime. +You are the root coordinator for the TFS Agentic ADK runtime. Current executable scope: - Read-only TFS controller inventory through the inventory_agent and MCP. @@ -46,9 +46,9 @@ If the user asks for remote/all-domain datacenters, transponders, ROADMs, routers, links, optical links, endpoints, services, or connections, delegate to service_agent so it can use the parallel local-MCP plus peer-API inventory workflow. Do not answer those requests with local-only MCP inventory. -In this project, "domain" normally means an MD-NAF controller domain such as -A, B, or C. It does not mean a TFS context unless the user explicitly asks for -TFS contexts. +In this component, "domain" normally means a controller/agent domain such as +A, B, or C. It does not mean a TFS context unless the user explicitly asks +for TFS contexts. Keep replies concise and controller-grounded. """ @@ -63,9 +63,9 @@ from agentic.service.agents.service import service_agent inventory_service_root_agent = Agent( model=build_litellm(), - name="md_naf_inventory_service_root_agent", + name="tfs_agentic_inventory_service_root_agent", description=( - "MD-NAF TFS-Agentic ADK coordinator with bounded inventory and " + "TFS Agentic ADK coordinator with bounded inventory and " "service sub-agents." ), instruction=ROOT_AGENT_INSTRUCTION, diff --git a/src/agentic/service/agents/service.py b/src/agentic/service/agents/service.py index c239d25cd..7512d8c94 100644 --- a/src/agentic/service/agents/service.py +++ b/src/agentic/service/agents/service.py @@ -23,17 +23,17 @@ from agentic.service.tools.service import SERVICE_WORKFLOW_TOOLS SERVICE_AGENT_INSTRUCTION = """ -You are the MD-NAF TFS service specialist. +You are the TFS Agentic service specialist. Use deterministic service workflow tools for service creation and removal. Do not compose raw TFS service payloads in the LLM. Do not call raw MCP mutating tools directly. For domain questions: -- Use list_mdnaf_domains. -- Explain that MD-NAF domains are controller/agent domains such as A, B, and C. +- Use list_controller_domains. +- Explain that domains are controller/agent domains such as A, B, and C. - Mention that TFS context `admin` is a controller-internal context and is not - the same thing as an MD-NAF domain. + the same thing as a controller/agent domain. - Do not answer domain questions from TFS contexts unless explicitly asked for TFS contexts. @@ -57,14 +57,14 @@ other domains, every domain, or cross-domain visibility: Examples: for "endpoints of remote transponders", use resource_kind=`endpoints`, device_filter=`transponders`; for "ports on ROADMs", use resource_kind=`endpoints`, device_filter=`roadms`. -- Report results grouped by MD-NAF domain ID. If a remote domain has no +- Report results grouped by controller domain ID. If a remote domain has no matching resources, say that explicitly for that domain. - The workflow fans out local MCP and peer API requests in parallel; do not manually call local-only MCP first and stop there. For device-location questions: - Use locate_device. -- Report the MD-NAF domain ID where the matching device is found. +- Report the controller domain ID where the matching device is found. - If both a packet DC device and transponder device match, list both matches. For direct device connection, neighbor, or link questions: diff --git a/src/agentic/service/agents/single.py b/src/agentic/service/agents/single.py index d2ec44529..e663bf698 100644 --- a/src/agentic/service/agents/single.py +++ b/src/agentic/service/agents/single.py @@ -35,7 +35,7 @@ from agentic.service.tools.granular import ( SINGLE_AGENT_INSTRUCTION = """ -You are the experimental single-agent MD-NAF controller assistant. +You are the experimental single-agent TFS controller assistant. Use exactly one tool call whenever a tool can satisfy the request. Do not delegate to sub-agents. @@ -85,9 +85,9 @@ def _dedupe_tools(*tool_groups): single_root_agent = Agent( model=build_litellm(), - name="md_naf_single_root_agent", + name="tfs_agentic_single_root_agent", description=( - "Experiment-only single MD-NAF LLM agent for latency comparison." + "Experiment-only single TFS Agentic LLM agent for latency comparison." ), instruction=SINGLE_AGENT_INSTRUCTION, tools=_dedupe_tools( diff --git a/src/agentic/service/domain_api.py b/src/agentic/service/domain_api.py index 12ceea0ed..3158d62ec 100644 --- a/src/agentic/service/domain_api.py +++ b/src/agentic/service/domain_api.py @@ -37,6 +37,7 @@ from agentic.service.session_store import ( record_event, session_health, ) +from agentic.Config import validate_agentic_llm_configuration from agentic.service.settings import ( AGENT_STARTUP_WARMUP_ENABLED, AGENT_STARTUP_WARMUP_PROMPTS, @@ -45,6 +46,7 @@ from agentic.service.settings import ( DEFAULT_SLOT_WIDTH, DOMAIN_ID, DOMAIN_NAME, + IS_DUMMY_DETERMINISTIC_MODE, MCP_STARTUP_WARMUP_TIMEOUT_SECONDS, ) from agentic.service.tools.mcp_client import ( @@ -64,7 +66,7 @@ from agentic.service.tools.service_workflow import ( delete_local_services_for_request, delete_service, list_domain_inventory, - list_mdnaf_domains, + list_controller_domains, locate_device, release_tfs_spectrum_reservation, ) @@ -157,7 +159,7 @@ class AgentRunRequest(BaseModel): session_id: str = "" -app = FastAPI(title="MD-NAF ADK Domain API", version="0.1.0") +app = FastAPI(title="TFS Agentic Domain API", version="0.1.0") install_a2a_routes(app) LOGGER = logging.getLogger("uvicorn.error") _agent_session_service = InMemorySessionService() @@ -174,6 +176,7 @@ _startup_warmup_results: list[dict[str, Any]] = [] async def startup() -> None: """Warm long-lived controller-facing sessions before the first request.""" + validate_agentic_llm_configuration() try: started = time.perf_counter() warmup_result = await asyncio.wait_for( @@ -197,6 +200,13 @@ async def startup() -> None: "lazily: %s", exc, ) + if IS_DUMMY_DETERMINISTIC_MODE: + LOGGER.info( + "Skipping LLM startup warmup in dummy deterministic mode " + "domain=%s", + DOMAIN_ID, + ) + return await _warm_agent_runner_on_startup() @@ -232,6 +242,13 @@ async def _run_agent_internal( started = time.perf_counter() events = [] final_text_parts = [] + if IS_DUMMY_DETERMINISTIC_MODE: + return await _run_dummy_deterministic_agent( + user_id=user_id, + session_id=session_id, + prompt=prompt, + record=record, + ) LOGGER.info( "%s start domain=%s user=%s session=%s timeout_s=%.1f prompt=%r", log_prefix, @@ -311,6 +328,8 @@ async def _run_agent_internal( session_id=session_id, ) return result + + except Exception as exc: # pylint: disable=broad-exception-caught result = { "ok": False, @@ -368,6 +387,86 @@ async def _run_agent_internal( return result +async def _run_dummy_deterministic_agent( + *, + user_id: str, + session_id: str, + prompt: str, + record: bool, +) -> Dict[str, Any]: + started = time.perf_counter() + normalized_prompt = prompt.strip().lower() + if "device" not in normalized_prompt and "node" not in normalized_prompt: + result = { + "ok": False, + "domain_id": DOMAIN_ID, + "user_id": user_id, + "session_id": session_id, + "elapsed_ms": round((time.perf_counter() - started) * 1000.0, 3), + "event_count": 0, + "events": [], + "final_answer": "", + "error": "dummy_mode_supports_device_inventory_only", + } + if record: + record_event( + "agent_run", + result, + user_id=user_id, + session_id=session_id, + ) + return result + + inventory = await list_domain_inventory( + scope="local", + resource_kind="devices", + detail_level="headers", + user_id=user_id, + session_id=session_id, + ) + domain_inventory = inventory.get("domains", {}).get(DOMAIN_ID, {}) + device_names = domain_inventory.get("device_names", []) + final_answer = ( + "The local TFS controller reports {:d} devices: {:s}.".format( + len(device_names), + ", ".join(device_names), + ) + if device_names + else "The local TFS controller reports no devices." + ) + result = { + "ok": bool(inventory.get("ok", True)), + "domain_id": DOMAIN_ID, + "user_id": user_id, + "session_id": session_id, + "elapsed_ms": round((time.perf_counter() - started) * 1000.0, 3), + "event_count": 1, + "events": [ + { + "elapsed_ms": 0.0, + "author": "dummy_deterministic_agent", + "model": "dummy-deterministic", + "parts": [ + { + "kind": "function_response", + "name": "list_domain_inventory", + "response": inventory, + } + ], + } + ], + "final_answer": final_answer, + } + if record: + record_event( + "agent_run", + result, + user_id=user_id, + session_id=session_id, + ) + return result + + async def _warm_agent_runner_on_startup() -> None: """Run safe prompts through ADK to pay cold-start cost upfront.""" @@ -467,7 +566,7 @@ async def inventory_devices() -> Dict[str, Any]: @app.get("/domains") async def domains(include_devices: bool = True) -> Dict[str, Any]: - return await list_mdnaf_domains( + return await list_controller_domains( include_devices=include_devices, user_id="api", session_id="domains", diff --git a/src/agentic/service/settings.py b/src/agentic/service/settings.py index eb6f25eec..3c3efd525 100644 --- a/src/agentic/service/settings.py +++ b/src/agentic/service/settings.py @@ -42,6 +42,7 @@ from agentic.Config import ( get_agentic_startup_warmup_timeout_seconds, get_agentic_tool_profile, get_tfs_default_context_uuid, + is_agentic_dummy_deterministic_mode, ) @@ -70,3 +71,4 @@ AGENT_STARTUP_WARMUP_TIMEOUT_SECONDS = ( get_agentic_startup_warmup_timeout_seconds() ) AGENT_STARTUP_WARMUP_PROMPTS = get_agentic_startup_warmup_prompts() +IS_DUMMY_DETERMINISTIC_MODE = is_agentic_dummy_deterministic_mode() diff --git a/src/agentic/service/tools/granular.py b/src/agentic/service/tools/granular.py index 82d257870..c4ef29d60 100644 --- a/src/agentic/service/tools/granular.py +++ b/src/agentic/service/tools/granular.py @@ -136,7 +136,7 @@ async def query_detailed_devices_or_endpoints( async def query_device_location(device_name: str) -> dict: - """Locate a device or endpoint-like resource across MD-NAF domains.""" + """Locate a device or endpoint-like resource across controller domains.""" return await locate_device(device_name=device_name) @@ -162,7 +162,7 @@ async def query_packet_links(scope: str = "all") -> dict: async def query_detailed_packet_links(scope: str = "all") -> dict: - """Query detailed packet links in local, remote, or all MD-NAF domains.""" + """Query detailed packet links in local, remote, or all domains.""" return await _list_domain_inventory( scope=scope, @@ -184,7 +184,7 @@ async def query_optical_links(scope: str = "all") -> dict: async def query_detailed_optical_links(scope: str = "all") -> dict: - """Query detailed optical links in local, remote, or all MD-NAF domains.""" + """Query detailed optical links in local, remote, or all domains.""" return await _list_domain_inventory( scope=scope, @@ -195,7 +195,7 @@ async def query_detailed_optical_links(scope: str = "all") -> dict: async def query_services(scope: str = "all") -> dict: - """Query compact service headers in local, remote, or all MD-NAF domains.""" + """Query compact service headers in local, remote, or all domains.""" inventory = await _list_domain_inventory( scope=scope, @@ -206,7 +206,7 @@ async def query_services(scope: str = "all") -> dict: async def query_detailed_services(scope: str = "all") -> dict: - """Query detailed services in local, remote, or all MD-NAF domains.""" + """Query detailed services in local, remote, or all domains.""" return await _list_domain_inventory( scope=scope, @@ -229,7 +229,7 @@ async def query_connections(scope: str = "all") -> dict: async def query_detailed_connections(scope: str = "all") -> dict: - """Query detailed connections in local, remote, or all MD-NAF domains.""" + """Query detailed connections in local, remote, or all domains.""" return await _list_domain_inventory( scope=scope, diff --git a/src/agentic/service/tools/service.py b/src/agentic/service/tools/service.py index 2f176f1f0..d0f7b8bee 100644 --- a/src/agentic/service/tools/service.py +++ b/src/agentic/service/tools/service.py @@ -27,7 +27,7 @@ from .service_workflow import ( delete_service as _delete_service, list_device_connections as _list_device_connections, list_domain_inventory as _list_domain_inventory, - list_mdnaf_domains as _list_mdnaf_domains, + list_controller_domains as _list_controller_domains, locate_device as _locate_device, ) @@ -232,7 +232,7 @@ async def list_domain_inventory( resource_kind: str, detail_level: str = "summary", ) -> dict: - """List MD-NAF inventory in local, remote, or all domains.""" + """List TFS inventory in local, remote, or all controller domains.""" return await _list_domain_inventory( scope=scope, @@ -242,7 +242,7 @@ async def list_domain_inventory( async def locate_device(device_name: str) -> dict: - """Locate the MD-NAF domain containing a device or endpoint-like device.""" + """Locate the controller domain containing a device or endpoint.""" return await _locate_device(device_name=device_name) @@ -253,10 +253,10 @@ async def list_device_connections(device_name: str, scope: str = "all") -> dict: return await _list_device_connections(device_name=device_name, scope=scope) -async def list_mdnaf_domains() -> dict: - """Summarize configured MD-NAF domains and peer agents.""" +async def list_controller_domains() -> dict: + """Summarize configured controller domains and peer agents.""" - return await _list_mdnaf_domains() + return await _list_controller_domains() async def request_optical_connectivity( @@ -337,7 +337,7 @@ async def delete_service(service_uuid: str) -> dict: list_domain_inventory_tool = FunctionTool(list_domain_inventory) locate_device_tool = FunctionTool(locate_device) list_device_connections_tool = FunctionTool(list_device_connections) -list_mdnaf_domains_tool = FunctionTool(list_mdnaf_domains) +list_controller_domains_tool = FunctionTool(list_controller_domains) request_optical_connectivity_tool = FunctionTool( request_optical_connectivity ) @@ -349,7 +349,7 @@ INVENTORY_WORKFLOW_TOOLS = [ list_domain_inventory_tool, locate_device_tool, list_device_connections_tool, - list_mdnaf_domains_tool, + list_controller_domains_tool, ] OPTICAL_CREATE_WORKFLOW_TOOLS = [ diff --git a/src/agentic/service/tools/service_roles.py b/src/agentic/service/tools/service_roles.py index 0ea8fa739..17cd99901 100644 --- a/src/agentic/service/tools/service_roles.py +++ b/src/agentic/service/tools/service_roles.py @@ -76,7 +76,7 @@ def controller_domain_from_actor(actor: str) -> str: def role_for_step(phase: str, actor: str, operation: str) -> ServiceAgentRole: - """Map low-level workflow trace fields to bounded MD-NAF agent roles.""" + """Map low-level workflow trace fields to bounded TFS Agentic roles.""" normalized = operation.lower() if "normalize" in normalized or "size_spectrum" in normalized: diff --git a/src/agentic/service/tools/service_workflow.py b/src/agentic/service/tools/service_workflow.py index 45e0f8924..94fc82fd1 100644 --- a/src/agentic/service/tools/service_workflow.py +++ b/src/agentic/service/tools/service_workflow.py @@ -560,7 +560,7 @@ async def create_tfs_spectrum_reservation( "n_start": n_start, "n_end": n_end, "required_slots": n_end - n_start + 1, - "owner_id": f"md-naf-domain-{DOMAIN_ID}", + "owner_id": f"tfs-agentic-domain-{DOMAIN_ID}", "correlation_id": str(metadata.get("correlation_id") or service_id), "status": 1, } @@ -3283,12 +3283,12 @@ async def list_device_connections( return result -async def list_mdnaf_domains( +async def list_controller_domains( include_devices: bool = True, user_id: str = "default", session_id: str = "default", ) -> Dict[str, Any]: - """Return the MD-NAF domain view, not the TFS context list.""" + """Return the controller/agent domain view, not the TFS context list.""" local_payload = await call_mcp_tool("tfs_list_devices", {}) local_devices = local_payload.get( @@ -3333,11 +3333,11 @@ async def list_mdnaf_domains( "domain_count": len(domains), "domains": domains, "note": ( - "These are MD-NAF controller domains. TFS contexts are separate; " + "These are controller/agent domains. TFS contexts are separate; " "the default TFS context is normally admin." ), } - record_event("mdnaf_domain_summary", result, + record_event("controller_domain_summary", result, user_id=user_id, session_id=session_id) return result @@ -3347,7 +3347,7 @@ async def locate_device( user_id: str = "default", session_id: str = "default", ) -> Dict[str, Any]: - """Locate a device or endpoint-like device across MD-NAF domains.""" + """Locate a device or endpoint-like device across controller domains.""" target = device_name.strip() local_payload = await call_mcp_tool("tfs_list_devices", {}) diff --git a/src/agentic/tests/test_unitary.py b/src/agentic/tests/test_unitary.py index 03edf69b4..92f41dd81 100644 --- a/src/agentic/tests/test_unitary.py +++ b/src/agentic/tests/test_unitary.py @@ -20,6 +20,8 @@ from agentic.Config import ( get_agentic_mcp_url, get_agentic_model, get_agentic_port, + is_agentic_dummy_deterministic_mode, + validate_agentic_llm_configuration, ) from agentic.service import peer_client from agentic.service.spectrum import spectrum_request @@ -40,6 +42,25 @@ def test_agentic_default_config(monkeypatch) -> None: assert get_agentic_mcp_url() == DEFAULT_AGENTIC_MCP_URL +def test_openai_model_requires_api_key(monkeypatch) -> None: + monkeypatch.setenv("ADK_MODEL", "openai/gpt-4.1-mini") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + try: + validate_agentic_llm_configuration() + assert False, "missing OPENAI_API_KEY should fail validation" + except RuntimeError as exc: + assert "OPENAI_API_KEY" in str(exc) + + +def test_dummy_deterministic_model_does_not_require_api_key(monkeypatch) -> None: + monkeypatch.setenv("ADK_MODEL", "dummy-deterministic") + monkeypatch.delenv("OPENAI_API_KEY", raising=False) + + assert is_agentic_dummy_deterministic_mode() is True + validate_agentic_llm_configuration() + + 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/mcp_server/README.md b/src/mcp_server/README.md new file mode 100644 index 000000000..dbbbb6317 --- /dev/null +++ b/src/mcp_server/README.md @@ -0,0 +1,371 @@ +# TFS MCP Server + +The TFS MCP Server is an optional TeraFlowSDN component that exposes selected +TFS REST NBI operations through the Model Context Protocol (MCP). It is meant +for agentic clients that need a bounded, tool-oriented interface to a local TFS +controller. + +## Deployment Flavor + +Enable the component in the deployment flavor that you use for your testbed. +For optical experiments, enable both the Optical Controller and the MCP Server. +The MCP Server should be deployed after the core controller components so it +can reach the NBI service. + +Typical local deployment flow with the MCP Server and Optical Controller: + +```bash +cd ~/tfs-ctrl +TFS_COMPONENTS="context device pathcomp opticalcontroller service nbi webui mcp_server" \ +CRDB_DROP_DATABASE_IF_EXISTS=YES \ +./deploy/all.sh +``` + +The deployment manifest configures the MCP Server through environment-backed +settings: + +- `TFS_MCP_TRANSPORT`: MCP transport, normally `sse`. +- `TFS_MCP_MODE`: `normal` for controller-backed mode. +- `TFS_NBI_URL`: optional explicit TFS NBI URL. +- `TFS_NBI_PREFIX`: REST API prefix, normally `/tfs-api`. +- `TFS_NBI_USER` and `TFS_NBI_PASS`: optional NBI credentials. +- `TFS_NBI_VERIFY_TLS`: set to `true` only when TLS verification is required. + +The Kubernetes service exposes the MCP Server HTTP port registered for +`ServiceNameEnum.MCP_SERVER`. The ingress path is `/mcp`. + +## Health And Connectivity + +Check that the pod is running: + +```bash +kubectl get pods -n tfs | grep mcp +kubectl logs -n tfs deploy/mcp-serverservice +``` + +Check the HTTP health endpoint: + +```bash +curl http:///mcp/health +``` + +The SSE endpoint is long lived, so a plain curl call will normally keep the +connection open: + +```bash +curl -N http:///mcp/sse +``` + +When using ingress path prefixes, the SSE stream must advertise +`/mcp/messages/`. If the client posts to `/messages/`, the ingress prefix is +not being preserved and MCP requests will fail. + +## Minimal Python Client + +The example below lists the tool catalog and calls a simple read-only tool. + +```python +import asyncio + +from mcp import ClientSession +from mcp.client.sse import sse_client + + +MCP_URL = "http:///mcp/sse" + + +async def main(): + async with sse_client(MCP_URL) as (read_stream, write_stream): + async with ClientSession(read_stream, write_stream) as session: + await session.initialize() + + tools = await session.list_tools() + print([tool.name for tool in tools.tools]) + + result = await session.call_tool("tfs_list_context_ids", {}) + print(result) + + +asyncio.run(main()) +``` + +## Common Tool Families + +The MCP Server exposes controller inventory, service lifecycle, optical +resource, connection, policy, health, and selected optical RESTCONF helpers. +Tool names keep the `tfs_` prefix in the MCP catalog. + +Context tools: list context IDs, list contexts, set contexts, get dummy +contexts, get, set, and delete context. + +Topology tools: list topology IDs, list topologies, set topologies, get, set, +delete topology, and get topology details. + +Device tools: list device IDs, list devices, add devices, get, configure, and +delete device. + +Link tools: list link IDs, list links, set links, get, set, and delete link. +Optical link tools add list optical link IDs, list optical links, and get +optical link. + +Service tools: list service IDs, list services, create, get, update, delete +service, get optical service allocation, and verify optical service +allocation. + +Optical spectrum tools: list, get, create, update, consume, release, and +delete optical spectrum reservation. + +Optical candidate tools: compute optical connectivity candidates for a context +and topology. + +Connection tools: list connection IDs for a service, list service connections, +list all connections, and get connection. + +Policy tools: list policy rule IDs, list policy rules, and get policy rule. + +Health tool: call the TFS NBI health endpoint. + +Optical RESTCONF helper tools: create and delete optical slices, allocate and +delete media channels, and configure DSCM OpenConfig resources. + +## Read-Only Examples + +List contexts: + +```python +await session.call_tool("tfs_list_contexts", {}) +``` + +List devices: + +```python +await session.call_tool("tfs_list_devices", {}) +``` + +List links: + +```python +await session.call_tool("tfs_list_links", {}) +``` + +List optical links: + +```python +await session.call_tool("tfs_list_optical_links", {}) +``` + +List services: + +```python +await session.call_tool("tfs_list_services", {}) +``` + +List all connections: + +```python +await session.call_tool("tfs_list_connections", {}) +``` + +Get topology details for the default TFS context and topology: + +```python +await session.call_tool( + "tfs_get_topology_details", + { + "context_uuid": "admin", + "topology_uuid": "admin", + }, +) +``` + +Get one service: + +```python +await session.call_tool( + "tfs_get_service", + { + "context_uuid": "admin", + "service_uuid": "example-service", + }, +) +``` + +Compute optical connectivity candidates: + +```python +await session.call_tool( + "tfs_compute_optical_connectivity_candidates", + { + "context_uuid": "admin", + "topology_uuid": "admin", + "request": { + "service_endpoint_ids": [ + { + "device_id": {"device_uuid": {"uuid": "DC1-TP1"}}, + "endpoint_uuid": {"uuid": "CHANNEL"}, + }, + { + "device_id": {"device_uuid": {"uuid": "DC2-TP1"}}, + "endpoint_uuid": {"uuid": "CHANNEL"}, + }, + ], + "capacity_gbps": 50, + "modulation_format": "dp-qpsk", + "preferred_band": "c_slots", + }, + }, +) +``` + +## Service Lifecycle Examples + +TFS service provisioning is controller-owned. The MCP Server forwards the +same lifecycle used by the TFS API: reserve a service UUID with CreateService, +then update the service with endpoints, constraints, and config rules. The +controller computes and provisions the path; clients must verify the final +service state through TFS. + +Create/reserve a service UUID: + +```python +await session.call_tool( + "tfs_create_service", + { + "service": { + "service_id": { + "context_id": { + "context_uuid": {"uuid": "admin"} + }, + "service_uuid": {"uuid": "demo-optical-service"}, + }, + "service_type": "SERVICETYPE_OPTICAL_CONNECTIVITY", + } + }, +) +``` + +Update the service with endpoints and optical constraints: + +```python +await session.call_tool( + "tfs_update_service", + { + "service": { + "service_id": { + "context_id": { + "context_uuid": {"uuid": "admin"} + }, + "service_uuid": {"uuid": "demo-optical-service"}, + }, + "service_type": "SERVICETYPE_OPTICAL_CONNECTIVITY", + "service_endpoint_ids": [ + { + "device_id": {"device_uuid": {"uuid": "DC1-TP1"}}, + "endpoint_uuid": {"uuid": "CHANNEL"}, + }, + { + "device_id": {"device_uuid": {"uuid": "DC2-TP1"}}, + "endpoint_uuid": {"uuid": "CHANNEL"}, + }, + ], + "service_constraints": [ + { + "action": "CONSTRAINTACTION_SET", + "custom": { + "constraint_type": "optical-band-width[GHz]", + "constraint_value": "50", + }, + }, + { + "action": "CONSTRAINTACTION_SET", + "custom": { + "constraint_type": "band", + "constraint_value": "c_slots", + }, + }, + ], + } + }, +) +``` + +Verify that the service became active: + +```python +await session.call_tool( + "tfs_get_service", + { + "context_uuid": "admin", + "service_uuid": "demo-optical-service", + }, +) +``` + +Delete a service: + +```python +await session.call_tool( + "tfs_delete_service", + { + "context_uuid": "admin", + "service_uuid": "demo-optical-service", + }, +) +``` + +Some optical services need bounded verify-and-retry deletion because one +delete can remove the lightpath inside an optical band and a later delete can +remove the band itself. After deletion, list or get the service again to +confirm controller state. + +## Local Smoke Test + +After deploying TFS locally, load an emulated scenario and query it through +MCP: + +```bash +PYENV_VERSION=tfs ./src/tests/tools/load_scenario/run.sh \ + src/tests/tools/load_scenario/example_descriptors.json +``` + +Then run a small MCP probe: + +```python +contexts = await session.call_tool("tfs_list_context_ids", {}) +devices = await session.call_tool("tfs_list_devices", {}) +links = await session.call_tool("tfs_list_links", {}) +``` + +For the bundled example descriptor, the expected inventory is one context, +one topology, seven devices, and nine links. + +## Test Descriptor Selection + +Use `src/tests/tools/load_scenario/example_descriptors.json` for basic MCP +inventory checks. It is small, quick to load, and exercises context, topology, +device, and packet-link retrieval. + +Use `src/tests/ofc24/descriptors/topology.json` for optical MCP tests. It +contains the optical topology needed by optical-link retrieval, optical +candidate computation, spectrum reservation, and optical connectivity service +lifecycle checks. + +Use `src/tests/ofc24/descriptors/service-unidir.json` and +`src/tests/ofc24/descriptors/service-bidir.json` as reference service payloads +when tuning create, retrieve, verify, and delete tests. Prefer the +unidirectional payload for automated smoke checks because bidirectional +optical service behavior has additional controller-side lifecycle details. + +## Troubleshooting + +If tool calls fail with missing context or topology arguments, provide +`context_uuid=admin` and `topology_uuid=admin` explicitly unless your testbed +uses different names. + +If optical tools return empty lists, confirm the Optical Controller component +is deployed and the topology descriptor contains optical links. + +If service provisioning succeeds at CreateService but does not become ACTIVE, +inspect the service, path computation, optical controller, and NBI logs. The +MCP Server only forwards the request; the controller remains the source of +truth for service state and spectrum allocation. -- GitLab From 9995eb779dffdac921d31326d64167549abd78f2 Mon Sep 17 00:00:00 2001 From: gifrerenom Date: Wed, 22 Jul 2026 06:42:55 +0000 Subject: [PATCH 4/4] Agentic component: - Fix unit tests and CI pipeline --- src/agentic/.gitlab-ci.yml | 1 + src/agentic/tests/test_integration.py | 1 + 2 files changed, 2 insertions(+) diff --git a/src/agentic/.gitlab-ci.yml b/src/agentic/.gitlab-ci.yml index a663578a3..a6a852f26 100644 --- a/src/agentic/.gitlab-ci.yml +++ b/src/agentic/.gitlab-ci.yml @@ -74,6 +74,7 @@ unit_test agentic: - > docker run --name $IMAGE_NAME -d -p 8800:8800 --env LOG_LEVEL=INFO + --env ADK_MODEL=dummy-deterministic --env ADK_AGENT_STARTUP_WARMUP_ENABLED=0 --env TFS_MCP_URL=http://127.0.0.1:3002/mcp/sse --volume "$PWD/src/$IMAGE_NAME/tests:/opt/results" diff --git a/src/agentic/tests/test_integration.py b/src/agentic/tests/test_integration.py index 5ce726ac4..efb78720e 100644 --- a/src/agentic/tests/test_integration.py +++ b/src/agentic/tests/test_integration.py @@ -163,6 +163,7 @@ def test_agent_run_uses_mocked_llm_runner(monkeypatch) -> None: monkeypatch.setattr(domain_api, "_agent_runner", FakeRunner()) monkeypatch.setattr(domain_api, "_ensure_agent_session", fake_ensure_agent_session) + monkeypatch.setattr(domain_api, "IS_DUMMY_DETERMINISTIC_MODE", False) reply = asyncio.run( domain_api._run_agent_internal( -- GitLab