diff --git a/.gitlab-ci.yml b/.gitlab-ci.yml index 1b225a0d22e6c6fb4a8912442642fe7f5f4424f4..93f1185e2c3a8f2710d0cdaa6b2ebb8931552b86 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 8ce597ce3a3d4589619fd17c6d6f2a4571788627..f32ad49665a25873b7301cafe600a767755b694d 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 0000000000000000000000000000000000000000..006669f1c82001c7bd21fd37c212469b98d7dd04 --- /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 5cca9a3948a88cb4da421ab82944a96e47331551..cff5b8a12e8fe199d5831eb37968a2d224234152 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 0000000000000000000000000000000000000000..882e376b77b81d0fa6b4c9f519d6b4946c398292 --- /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 0000000000000000000000000000000000000000..a6a852f26d1b3c8dbc1ecbd97ab146b6713ef2e1 --- /dev/null +++ b/src/agentic/.gitlab-ci.yml @@ -0,0 +1,115 @@ +# 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_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" + --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 0000000000000000000000000000000000000000..818cfcc12a64cb16e76e06b83b0403b393d90a51 --- /dev/null +++ b/src/agentic/Config.py @@ -0,0 +1,326 @@ +# 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" +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" +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_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) + + +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 0000000000000000000000000000000000000000..2153a9a4d89bf3f0f2095e0331a127238fcc8108 --- /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 0000000000000000000000000000000000000000..208904ae6512a93c4770d0da7abdaab1486f03b6 --- /dev/null +++ b/src/agentic/README.md @@ -0,0 +1,243 @@ +# TFS Agentic Component + +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 + +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. +- `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 + +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` 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 + +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/__init__.py b/src/agentic/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8f29e6575be55290b5dcc19b406eab18ecca8bf9 --- /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 0000000000000000000000000000000000000000..a93243374f27ddf5f38ef7af4febdafff5b890a5 --- /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 0000000000000000000000000000000000000000..f1d4558195e759ba8003a8c28fd32e830871d9fa --- /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 0000000000000000000000000000000000000000..5ccfec28233121c84a5cf10582f7a6a700ba5f0f --- /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 TFS Agentic 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 domain.""" + + base_url = _domain_api_base_url() + return AgentCard( + name=f"tfs-agentic-domain-{DOMAIN_ID.lower()}", + description=( + "TFS Agentic domain agent exposing TFS inventory, flex-grid " + "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="tfs.inventory", + name="Remote domain inventory interrogation", + description=( + "List devices, endpoints, links, optical links, services, " + "and connections in this domain." + ), + tags=["tfs", "agentic", "inventory", "mcp"], + inputModes=["application/json"], + outputModes=["application/json"], + ), + AgentSkill( + id="tfs.spectrum", + name="Flex-grid spectrum negotiation", + description=( + "Compute candidates, reserve spectrum, and update " + "spectrum reservation state." + ), + tags=["tfs", "agentic", "optical", "spectrum", "flex-grid"], + inputModes=["application/json"], + outputModes=["application/json"], + ), + AgentSkill( + id="tfs.optical-service", + name="Optical connectivity service lifecycle", + description=( + "Create and delete TFS optical connectivity service " + "segments in this domain." + ), + tags=["tfs", "agentic", "optical", "service"], + inputModes=["application/json"], + outputModes=["application/json"], + ), + AgentSkill( + 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=["tfs", "agentic", "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=TfsAgenticDomainA2AExecutor(), + 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": "tfs-agentic-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 TfsAgenticDomainA2AExecutor(AgentExecutor): + """A2A SDK executor exposing deterministic TFS Agentic 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": "tfs-agentic-a2a-v1", + "domain_id": DOMAIN_ID, + "domain_name": DOMAIN_NAME, + "action": action, + "result": result, + } + except Exception as exc: # pylint: disable=broad-exception-caught + LOGGER.exception( + "A2A action failed action=%s domain=%s", + action, + DOMAIN_ID, + ) + response = { + "ok": False, + "protocol": "tfs-agentic-a2a-v1", + "domain_id": DOMAIN_ID, + "domain_name": DOMAIN_NAME, + "action": action, + "error": "a2a_action_failed", + "message": str(exc), + } + 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": "tfs-agentic-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_controller_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_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")), + ) + 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 0000000000000000000000000000000000000000..0d7a6d7a4298f8a9114390e5a3365f775a5833d1 --- /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 TFS Agentic 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 0000000000000000000000000000000000000000..c46447d7d87e4ecf83c23733bf39964e65c491ef --- /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 0000000000000000000000000000000000000000..2962e49b0d733252f6b151ec8ff9493202b5941e --- /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 TFS Agentic 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="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 new file mode 100644 index 0000000000000000000000000000000000000000..71f52b5969564b257e066ba400063ab46f4db43d --- /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 TFS Agentic 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 0000000000000000000000000000000000000000..a066aacff541cdd63aad987c442652305e86fabe --- /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 0000000000000000000000000000000000000000..9cd9cd0e18e24c3afdcd1170d923d2bf8fb3ca45 --- /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 0000000000000000000000000000000000000000..0f19d516ba67aa780fc9af2c8ffd986ad23084c4 --- /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 0000000000000000000000000000000000000000..cef9ca8f67900f9bb29409bf5e09e585e0f65ea1 --- /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 0000000000000000000000000000000000000000..aef4d68157cc45baa4cc167f259163fa4099cfea --- /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 controller 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 0000000000000000000000000000000000000000..71fdd4c408a890d3d44e53a99d03291700608d9d --- /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 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 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. +""" + +# 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="tfs_agentic_inventory_service_root_agent", + description=( + "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 0000000000000000000000000000000000000000..7512d8c94fbc51f7d31c4e88dbcbdc4b42da5542 --- /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 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_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 a controller/agent 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 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 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: +- 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 0000000000000000000000000000000000000000..e663bf6980b158100de0a31a1e966ac7f8adaa86 --- /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 TFS 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="tfs_agentic_single_root_agent", + description=( + "Experiment-only single TFS Agentic 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 0000000000000000000000000000000000000000..3158d62ecdd1fc42d4b638161a6db5d81b843666 --- /dev/null +++ b/src/agentic/service/domain_api.py @@ -0,0 +1,846 @@ +# 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.Config import validate_agentic_llm_configuration +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, + IS_DUMMY_DETERMINISTIC_MODE, + 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_controller_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="TFS Agentic 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.""" + + validate_agentic_llm_configuration() + 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, + ) + 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() + + +@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 = [] + 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, + 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 _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.""" + + 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_controller_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 0000000000000000000000000000000000000000..e072451c3c5a7a985756f7abe0a3ed29a0208867 --- /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 0000000000000000000000000000000000000000..89dd5abebd4b413bacdd42537f91dcb64532a622 --- /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 0000000000000000000000000000000000000000..25ad0556f9dd3e3367ea2ff0958a54be4c1844b5 --- /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 0000000000000000000000000000000000000000..55000b9897d65c4c087c340626d2ef8c365c5797 --- /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 0000000000000000000000000000000000000000..cb3c0d88ee1ef0e851d9319be5084df759b1addf --- /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 0000000000000000000000000000000000000000..8e44d592532701e4b155c7aaef3653efc0443dbc --- /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 0000000000000000000000000000000000000000..8e430473b5959b8212a454dfa09f02c76dda17f6 --- /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 0000000000000000000000000000000000000000..a068955d92072180f2ea63ae533749c69d98214d --- /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 0000000000000000000000000000000000000000..94c04f04c95710d0242ae578278ec08ceced42e1 --- /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 0000000000000000000000000000000000000000..d35714d88c9c7afbcb139f4e4677aa2fd140845c --- /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 0000000000000000000000000000000000000000..3c3efd5257adbf08998aeaaa060ab8a95f85c106 --- /dev/null +++ b/src/agentic/service/settings.py @@ -0,0 +1,74 @@ +# 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, + is_agentic_dummy_deterministic_mode, +) + + +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() +IS_DUMMY_DETERMINISTIC_MODE = is_agentic_dummy_deterministic_mode() diff --git a/src/agentic/service/spectrum.py b/src/agentic/service/spectrum.py new file mode 100644 index 0000000000000000000000000000000000000000..d590f7c82e0bf7a76a74898ac97b245c0a51738e --- /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 0000000000000000000000000000000000000000..9d0c2ed86def8a6388f3e3ed68de8cb919ad1551 --- /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 0000000000000000000000000000000000000000..3fba80ff894fde954af6eec89016041e732b21df --- /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 0000000000000000000000000000000000000000..c4ef29d60c026328de9c2eb655ceca93a2f7855d --- /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 controller 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 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 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 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 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 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 0000000000000000000000000000000000000000..cbd7ed753327236e0ba0ceff35e01cbb96968df7 --- /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 0000000000000000000000000000000000000000..0beca7b264f9aa2d55d63d8f482d7ec52457a52d --- /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 0000000000000000000000000000000000000000..d0f7b8beecd44abb4ad6a094bfbba8b1a923f21a --- /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_controller_domains as _list_controller_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 TFS inventory in local, remote, or all controller 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 controller domain containing a device or endpoint.""" + + 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_controller_domains() -> dict: + """Summarize configured controller domains and peer agents.""" + + return await _list_controller_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_controller_domains_tool = FunctionTool(list_controller_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_controller_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 0000000000000000000000000000000000000000..17cd99901645390193be61e7f593633fe9f3738a --- /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 TFS Agentic 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 0000000000000000000000000000000000000000..94fc82fd1477800bea43bc42bd38d08c9266b089 --- /dev/null +++ b/src/agentic/service/tools/service_workflow.py @@ -0,0 +1,3821 @@ +# 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 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 []: + 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-" + f"{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.""" + + 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"tfs-agentic-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 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.""" + + 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 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 service and verify ACTIVE.""" + + 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.""" + + 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 a 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 path segments 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) + provisions_by_domain = _segment_map(provision_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 ( + 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), + 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) + 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", + "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 _inventory_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": _inventory_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 = _inventory_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 = _inventory_device_name(device) + if name and _device_matches_filter(device, device_filter): + filtered.append(device) + return sorted(filtered, key=_inventory_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 = _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"])) + + +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"] = [ + _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) + 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.""" + + 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_controller_domains( + include_devices: bool = True, + user_id: str = "default", + session_id: str = "default", +) -> Dict[str, Any]: + """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( + "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 controller/agent domains. TFS contexts are separate; " + "the default TFS context is normally admin." + ), + } + record_event("controller_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 controller 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 path-wide optical service using peer-domain 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, + ) + + +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 0000000000000000000000000000000000000000..b53987a4eae1aed245eba5c7ddd8cd10e35919c2 --- /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 0000000000000000000000000000000000000000..2d63b92b0d37eed74c53c86fc14435149896eccc --- /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 0000000000000000000000000000000000000000..efb78720e1d9085918850ed91b98bec4aeda521f --- /dev/null +++ b/src/agentic/tests/test_integration.py @@ -0,0 +1,180 @@ +# 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) + monkeypatch.setattr(domain_api, "IS_DUMMY_DETERMINISTIC_MODE", False) + + 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 0000000000000000000000000000000000000000..92f41dd81121c679eb3b0c5fc1f8cfb139ffa25d --- /dev/null +++ b/src/agentic/tests/test_unitary.py @@ -0,0 +1,113 @@ +# 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, + is_agentic_dummy_deterministic_mode, + validate_agentic_llm_configuration, +) +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: + 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_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) + 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 + + +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" + ) diff --git a/src/common/Constants.py b/src/common/Constants.py index 811baf6bec8feb5ab8f2a341a77737a3b1bd59c6..2bcd49bfecc9c738aa58bd6f3b9d7f94032ddafd 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, diff --git a/src/mcp_server/README.md b/src/mcp_server/README.md new file mode 100644 index 0000000000000000000000000000000000000000..dbbbb63178b76b66bd9b043450b4246e82ca4122 --- /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.