Loading src/agentic/Config.py +8 −0 Changes for src/agentic/Config.py: 8 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -82,6 +82,7 @@ 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_ANTHROPIC_API_KEY = "ANTHROPIC_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" Loading Loading @@ -124,6 +125,13 @@ def validate_agentic_llm_configuration() -> None: raise RuntimeError("ADK_MODEL must be configured") if is_agentic_dummy_deterministic_mode(): return if model_name.startswith("anthropic/") and not get_setting( ENVVAR_ANTHROPIC_API_KEY, default="" ).strip(): raise RuntimeError( "ANTHROPIC_API_KEY must be configured for Anthropic-backed " "Agentic models. Use dummy-deterministic only for CI tests." ) if model_name.startswith("openai/") and not get_openai_api_key(): raise RuntimeError( "OPENAI_API_KEY must be configured for OpenAI-backed " Loading src/agentic/README.md +46 −1 Changes for src/agentic/README.md: 46 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -71,7 +71,9 @@ 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_MODEL`: LiteLLM model identifier. The example manifest and code default select `openai/gpt-4.1-mini`. The manifest also includes commented Anthropic and Ollama/Qwen examples. - `ADK_AGENT_GRAPH`: agent graph to run. Default is `single`; `granular` is kept for debugging and fallback. - `ADK_HTTP_ROOT_PATH`: HTTP prefix used when Agentic is exposed behind the Loading @@ -87,6 +89,8 @@ Main settings: - `ADK_SPECTRUM_DB_PATH`: SQLite path for transient agentic spectrum workflow state. - `OPENAI_API_KEY`: LLM provider secret, supplied through Kubernetes Secret. - `ANTHROPIC_API_KEY`: required for Anthropic models, supplied through the same Kubernetes Secret. Keep real keys outside tracked manifests. - `TFS_MCP_AUTH_TOKEN`: optional MCP authentication token, supplied through Kubernetes Secret. - `OLLAMA_API_BASE`: optional Ollama endpoint when using an Ollama LiteLLM Loading @@ -101,6 +105,47 @@ Main settings: ## LLM Configuration And Fail-Fast Checks For the single-domain optical demo, use `ADK_MODEL=openai/gpt-4.1-mini` and populate `OPENAI_API_KEY` in `agentic-secrets` before restarting Agentic. Alternatively, select `ADK_MODEL=anthropic/claude-haiku-4-5-20251001` and populate `ANTHROPIC_API_KEY` in that Secret. Startup rejects an Anthropic model without its API key. Leave `ADK_PEERS` empty for a single-domain deployment. The single agent defaults to local inventory and mutation. Device categories come from controller `device_type`, not naming conventions. Explicit `device/port` references such as `T1.1/1` are resolved to existing controller UUIDs. Unknown or ambiguous endpoints stop before service creation. Capacity in Gbps and spectrum width in GHz are distinct inputs. For a local capacity-only request, the Optical Controller selects a supported transceiver mode; Agentic does not invent a modulation or equate capacity with width. Detailed device queries include endpoints and identifiers without configuration rules by default. Set `include_config_rules=True` only for explicit configuration inspection. Optical-link detail queries omit the large spectrum maps unless `include_spectrum=True` is requested; endpoint ports remain available. Example prompts for the descriptor in `src/tests/ecoc26-hyr-agentic-optical/descriptors/topology.json`: ```text Which devices are in my topology? Include their types. List transponder ports with their names and UUIDs. Show optical links and their endpoint ports. Preview an 800 Gbps unidirectional flexgrid service from T1.1/1 to T2.1/6. Create it with name demo-unidir. List services and their connections. Delete demo-unidir. Create an 800 Gbps bidirectional flexgrid service from T1.2/1 to T2.2/6, named demo-bidir. Delete both directions of demo-bidir. ``` Preview uses read-only inventory and returns the create/update payloads. Bidirectional intent creates two unidirectional services named `<name>-forward` and `<name>-reverse`, verifying each as ACTIVE. If reverse provisioning fails, the forward service is rolled back. Teardown removes both returned service IDs and verifies their absence. Agentic validates the LLM configuration during ADK Web startup. If the model is OpenAI-backed, for example `openai/gpt-4.1-mini`, `OPENAI_API_KEY` must be present and non-empty. Missing credentials cause the pod to fail startup Loading src/agentic/service/agents/single.py +28 −6 Changes for src/agentic/service/agents/single.py: 28 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -38,14 +38,25 @@ from agentic.service.tools.granular import ( SINGLE_AGENT_INSTRUCTION = """ You are the experimental single-agent TFS controller assistant. Use exactly one tool call whenever a tool can satisfy the request. Use the tools needed to satisfy the request, including follow-up retrieval. 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. - Default to create_local_optical_service. Use cross-domain creation only when the request explicitly spans domains. - Gbps denotes capacity_gbps, never channel_width_ghz (GHz). For local capacity-only requests let TFS select its transceiver mode. Never invent modulation or width. If neither capacity nor width was supplied in this request or its session context, ask for it before creation. - Flexgrid/flexi_grid is the optical service type, not preferred_band. Leave preferred_band empty unless the user explicitly provides a band. - T1.1/1 denotes device T1.1, endpoint 1. Pass it intact or use the separate source_endpoint/destination_endpoint arguments. Do not invent endpoints. - Pass directionality explicitly. Bidirectional local requests create two one-way services; retain and report both IDs and delete both on teardown. - For a payload preview use preview_only=True; do not provision until asked. - 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; Loading @@ -56,15 +67,26 @@ For optical service creation: 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. - Default to remove_local_optical_service for local service names or UUIDs. Retrieve services first if the identifier is not known. 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. - Endpoint ports, service paths and connection details also require detailed retrieval. Resolve UUIDs against inventory; do not infer paths from names. - Set include_config_rules or include_spectrum only when the user asks for configuration rules or spectrum maps, respectively. - An empty packet-link inventory does not imply there are no optical links. Include optical links when answering generic link questions in this network. - 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. or all locations. Otherwise use local tools. - Classify devices using returned device_type, never their names: an R1 can be an optical ROADM. Do not guess missing inventory information. - device_filter is a literal filter, not a regular expression or pipe list. - Reuse confirmed session choices, but re-query controller operational state. Do not suggest cross-domain provisioning to bypass a local failure. After the tool returns, answer briefly with the key outcome, selected path or domains when present, service/request IDs, selected spectrum when present, and Loading src/agentic/service/tools/granular.py +117 −16 Changes for src/agentic/service/tools/granular.py: 117 added lines, 16 removed lines. Original line number Diff line number Diff line Loading @@ -16,6 +16,9 @@ from __future__ import annotations import copy import uuid from google.adk.tools import FunctionTool from agentic.service.tools.service import ( Loading @@ -35,19 +38,67 @@ from agentic.service.tools.service_workflow import ( async def create_local_optical_service( source_device: str, destination_device: str, channel_width_ghz: float, channel_width_ghz: float | None = None, preferred_band: str = "", service_name: str = "", capacity_gbps: float | None = None, modulation_format: str = "", source_endpoint: str = "", destination_endpoint: str = "", directionality: str = "unidirectional", preview_only: bool = False, ) -> dict: """Create a local optical service between two local optical endpoints.""" return await create_optical_connectivity_service( """Create or preview local flex-grid connectivity. Gbps is capacity, never GHz. Capacity alone lets TFS select a supported transceiver mode. Accept device/port references or explicit endpoint names. Bidirectional creates two one-way services; return both IDs for deletion. preview_only retrieves inventory and returns payloads without mutation. """ directionality = directionality.strip().lower() if directionality not in {"unidirectional", "bidirectional"}: return {"ok": False, "error": "Invalid directionality"} service_name = service_name or f"opt-{uuid.uuid4().hex[:10]}" kwargs = dict( source_device=source_device, destination_device=destination_device, channel_width_ghz=channel_width_ghz, preferred_band=preferred_band, service_name=service_name, capacity_gbps=capacity_gbps, modulation_format=modulation_format, source_endpoint=source_endpoint, destination_endpoint=destination_endpoint, preview_only=preview_only, ) if directionality == "unidirectional": return await create_optical_connectivity_service( service_name=service_name, **kwargs ) forward = await create_optical_connectivity_service( service_name=f"{service_name}-forward", **kwargs ) if not forward.get("ok"): return {"ok": False, "forward": forward} kwargs.update( source_device=destination_device, destination_device=source_device, source_endpoint=destination_endpoint, destination_endpoint=source_endpoint, ) reverse = await create_optical_connectivity_service( service_name=f"{service_name}-reverse", **kwargs ) result = { "ok": bool(reverse.get("ok")), "directionality": directionality, "forward": forward, "reverse": reverse, } if not reverse.get("ok") and not preview_only: result["rollback"] = await delete_service( service_uuid=forward["service_uuid"] ) return result async def create_cross_domain_optical_service_tool( Loading Loading @@ -122,17 +173,24 @@ async def query_detailed_devices_or_endpoints( scope: str = "all", resource_kind: str = "devices", device_filter: str = "", include_config_rules: bool = False, ) -> dict: """Query detailed device or endpoint inventory.""" """Query device/port IDs; include config only when explicitly requested.""" return await _list_domain_inventory( inventory = await _list_domain_inventory( scope=scope, resource_kind=resource_kind, device_filter=device_filter, detail_level="full", include_config_rules=True, include_config_rules=include_config_rules, include_ids=True, ) if not include_config_rules: inventory = copy.deepcopy(inventory) for domain in inventory.get("domains", {}).values(): for device in domain.get("devices", []): device.pop("device_config", None) return inventory async def query_device_location(device_name: str) -> dict: Loading Loading @@ -183,15 +241,26 @@ async def query_optical_links(scope: str = "all") -> dict: 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.""" async def query_detailed_optical_links( scope: str = "all", include_spectrum: bool = False, ) -> dict: """Query link IDs/ports; include slot maps only on explicit request.""" return await _list_domain_inventory( inventory = await _list_domain_inventory( scope=scope, resource_kind="optical_links", detail_level="full", include_ids=True, ) if not include_spectrum: inventory = copy.deepcopy(inventory) for domain in inventory.get("domains", {}).values(): for link in domain.get("optical_links", []): details = link.get("optical_details", {}) for band in ("c_slots", "l_slots", "s_slots"): details.pop(band, None) inventory["spectrum_included"] = False return inventory async def query_services(scope: str = "all") -> dict: Loading Loading @@ -255,6 +324,7 @@ async def query_local_device_headers( async def query_local_detailed_devices( resource_kind: str = "devices", device_filter: str = "", include_config_rules: bool = False, ) -> dict: """Query detailed local device or endpoint inventory.""" Loading @@ -262,6 +332,7 @@ async def query_local_detailed_devices( scope="local", resource_kind=resource_kind, device_filter=device_filter, include_config_rules=include_config_rules, ) Loading @@ -283,10 +354,14 @@ async def query_local_optical_link_headers() -> dict: return await query_optical_links(scope="local") async def query_local_detailed_optical_links() -> dict: """Query detailed local optical links.""" async def query_local_detailed_optical_links( include_spectrum: bool = False, ) -> dict: """Query local optical ports/IDs; optionally include spectrum slot maps.""" return await query_detailed_optical_links(scope="local") return await query_detailed_optical_links( scope="local", include_spectrum=include_spectrum ) async def query_local_service_headers() -> dict: Loading Loading @@ -329,6 +404,7 @@ async def query_cross_domain_device_headers( async def query_cross_domain_detailed_devices( resource_kind: str = "devices", device_filter: str = "", include_config_rules: bool = False, ) -> dict: """Query detailed all-domain device or endpoint inventory.""" Loading @@ -336,6 +412,7 @@ async def query_cross_domain_detailed_devices( scope="all", resource_kind=resource_kind, device_filter=device_filter, include_config_rules=include_config_rules, ) Loading @@ -357,10 +434,14 @@ async def query_cross_domain_optical_link_headers() -> dict: return await query_optical_links(scope="all") async def query_cross_domain_detailed_optical_links() -> dict: async def query_cross_domain_detailed_optical_links( include_spectrum: bool = False, ) -> dict: """Query detailed all-domain optical links.""" return await query_detailed_optical_links(scope="all") return await query_detailed_optical_links( scope="all", include_spectrum=include_spectrum ) async def query_cross_domain_service_headers() -> dict: Loading Loading @@ -420,6 +501,26 @@ def _compact_inventory_headers(payload: dict) -> dict: entry[key] = domain[key] if "device_names" in domain: entry["device_names"] = domain["device_names"] if "devices" in domain: entry["devices"] = [ {key: item.get(key, "") for key in ( "name", "device_type", "operational_status" )} for item in domain["devices"] if isinstance(item, dict) ] if "services" in domain: entry["services"] = [ {key: item.get(key, "") for key in ( "name", "service_type", "status", "endpoint_count" )} for item in domain["services"] if isinstance(item, dict) ] for family in ("links", "optical_links"): if family in domain: entry[family] = [ {key: item.get(key, "") for key in ("name", "endpoints")} for item in domain[family] if isinstance(item, dict) ] if "endpoints" in domain: entry["endpoint_names"] = [ ( Loading src/agentic/service/tools/local_endpoints.py 0 → 100644 +66 −0 Changes for src/agentic/service/tools/local_endpoints.py: 66 added lines, 0 removed lines. Original line number Diff line number Diff line # 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. """Resolve service endpoints exclusively from controller inventory.""" def resolve_local_endpoint(devices, reference, endpoint="", direction="out"): reference = reference.strip() matches = [] for device in devices: device_uuid = device.get("device_id", {}).get( "device_uuid", {} ).get("uuid", "") for alias in {device.get("name", ""), device_uuid} - {""}: if reference == alias: matches.append((device, endpoint)) elif reference.startswith(alias + "/"): suffix = reference[len(alias) + 1:] if endpoint and endpoint != suffix: raise ValueError("Conflicting explicit endpoint selectors") matches.append((device, suffix)) # A device can have the same name and UUID; aliases above are deduplicated. if len(matches) != 1: raise ValueError(f"Unknown or ambiguous device: {reference}") device, selector = matches[0] endpoints = device.get("device_endpoints", []) if selector: candidates = [item for item in endpoints if selector in { item.get("name", ""), item.get("endpoint_id", {}).get( "endpoint_uuid", {} ).get("uuid", ""), }] else: preferred = "CHANNEL" if str(device.get("name", "")).upper().startswith("DOMAIN-"): preferred = "port-in" if direction == "in" else "port-out" candidates = [item for item in endpoints if item.get("name") == preferred] if not candidates and len(endpoints) == 1: candidates = endpoints if len(candidates) != 1: choices = [item.get("name", "") for item in endpoints] raise ValueError( f"Select an existing endpoint for {reference}: {choices}" ) endpoint_id = candidates[0].get("endpoint_id", {}) endpoint_uuid = endpoint_id.get("endpoint_uuid", {}).get("uuid") device_id = device.get("device_id", {}) if not endpoint_uuid or not device_id.get("device_uuid", {}).get("uuid"): raise ValueError("Controller inventory lacks canonical endpoint IDs") return { "device_id": device_id, "endpoint_uuid": {"uuid": endpoint_uuid}, } Loading
src/agentic/Config.py +8 −0 Changes for src/agentic/Config.py: 8 added lines, 0 removed lines. Original line number Diff line number Diff line Loading @@ -82,6 +82,7 @@ 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_ANTHROPIC_API_KEY = "ANTHROPIC_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" Loading Loading @@ -124,6 +125,13 @@ def validate_agentic_llm_configuration() -> None: raise RuntimeError("ADK_MODEL must be configured") if is_agentic_dummy_deterministic_mode(): return if model_name.startswith("anthropic/") and not get_setting( ENVVAR_ANTHROPIC_API_KEY, default="" ).strip(): raise RuntimeError( "ANTHROPIC_API_KEY must be configured for Anthropic-backed " "Agentic models. Use dummy-deterministic only for CI tests." ) if model_name.startswith("openai/") and not get_openai_api_key(): raise RuntimeError( "OPENAI_API_KEY must be configured for OpenAI-backed " Loading
src/agentic/README.md +46 −1 Changes for src/agentic/README.md: 46 added lines, 1 removed line. Original line number Diff line number Diff line Loading @@ -71,7 +71,9 @@ 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_MODEL`: LiteLLM model identifier. The example manifest and code default select `openai/gpt-4.1-mini`. The manifest also includes commented Anthropic and Ollama/Qwen examples. - `ADK_AGENT_GRAPH`: agent graph to run. Default is `single`; `granular` is kept for debugging and fallback. - `ADK_HTTP_ROOT_PATH`: HTTP prefix used when Agentic is exposed behind the Loading @@ -87,6 +89,8 @@ Main settings: - `ADK_SPECTRUM_DB_PATH`: SQLite path for transient agentic spectrum workflow state. - `OPENAI_API_KEY`: LLM provider secret, supplied through Kubernetes Secret. - `ANTHROPIC_API_KEY`: required for Anthropic models, supplied through the same Kubernetes Secret. Keep real keys outside tracked manifests. - `TFS_MCP_AUTH_TOKEN`: optional MCP authentication token, supplied through Kubernetes Secret. - `OLLAMA_API_BASE`: optional Ollama endpoint when using an Ollama LiteLLM Loading @@ -101,6 +105,47 @@ Main settings: ## LLM Configuration And Fail-Fast Checks For the single-domain optical demo, use `ADK_MODEL=openai/gpt-4.1-mini` and populate `OPENAI_API_KEY` in `agentic-secrets` before restarting Agentic. Alternatively, select `ADK_MODEL=anthropic/claude-haiku-4-5-20251001` and populate `ANTHROPIC_API_KEY` in that Secret. Startup rejects an Anthropic model without its API key. Leave `ADK_PEERS` empty for a single-domain deployment. The single agent defaults to local inventory and mutation. Device categories come from controller `device_type`, not naming conventions. Explicit `device/port` references such as `T1.1/1` are resolved to existing controller UUIDs. Unknown or ambiguous endpoints stop before service creation. Capacity in Gbps and spectrum width in GHz are distinct inputs. For a local capacity-only request, the Optical Controller selects a supported transceiver mode; Agentic does not invent a modulation or equate capacity with width. Detailed device queries include endpoints and identifiers without configuration rules by default. Set `include_config_rules=True` only for explicit configuration inspection. Optical-link detail queries omit the large spectrum maps unless `include_spectrum=True` is requested; endpoint ports remain available. Example prompts for the descriptor in `src/tests/ecoc26-hyr-agentic-optical/descriptors/topology.json`: ```text Which devices are in my topology? Include their types. List transponder ports with their names and UUIDs. Show optical links and their endpoint ports. Preview an 800 Gbps unidirectional flexgrid service from T1.1/1 to T2.1/6. Create it with name demo-unidir. List services and their connections. Delete demo-unidir. Create an 800 Gbps bidirectional flexgrid service from T1.2/1 to T2.2/6, named demo-bidir. Delete both directions of demo-bidir. ``` Preview uses read-only inventory and returns the create/update payloads. Bidirectional intent creates two unidirectional services named `<name>-forward` and `<name>-reverse`, verifying each as ACTIVE. If reverse provisioning fails, the forward service is rolled back. Teardown removes both returned service IDs and verifies their absence. Agentic validates the LLM configuration during ADK Web startup. If the model is OpenAI-backed, for example `openai/gpt-4.1-mini`, `OPENAI_API_KEY` must be present and non-empty. Missing credentials cause the pod to fail startup Loading
src/agentic/service/agents/single.py +28 −6 Changes for src/agentic/service/agents/single.py: 28 added lines, 6 removed lines. Original line number Diff line number Diff line Loading @@ -38,14 +38,25 @@ from agentic.service.tools.granular import ( SINGLE_AGENT_INSTRUCTION = """ You are the experimental single-agent TFS controller assistant. Use exactly one tool call whenever a tool can satisfy the request. Use the tools needed to satisfy the request, including follow-up retrieval. 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. - Default to create_local_optical_service. Use cross-domain creation only when the request explicitly spans domains. - Gbps denotes capacity_gbps, never channel_width_ghz (GHz). For local capacity-only requests let TFS select its transceiver mode. Never invent modulation or width. If neither capacity nor width was supplied in this request or its session context, ask for it before creation. - Flexgrid/flexi_grid is the optical service type, not preferred_band. Leave preferred_band empty unless the user explicitly provides a band. - T1.1/1 denotes device T1.1, endpoint 1. Pass it intact or use the separate source_endpoint/destination_endpoint arguments. Do not invent endpoints. - Pass directionality explicitly. Bidirectional local requests create two one-way services; retain and report both IDs and delete both on teardown. - For a payload preview use preview_only=True; do not provision until asked. - 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; Loading @@ -56,15 +67,26 @@ For optical service creation: 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. - Default to remove_local_optical_service for local service names or UUIDs. Retrieve services first if the identifier is not known. 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. - Endpoint ports, service paths and connection details also require detailed retrieval. Resolve UUIDs against inventory; do not infer paths from names. - Set include_config_rules or include_spectrum only when the user asks for configuration rules or spectrum maps, respectively. - An empty packet-link inventory does not imply there are no optical links. Include optical links when answering generic link questions in this network. - 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. or all locations. Otherwise use local tools. - Classify devices using returned device_type, never their names: an R1 can be an optical ROADM. Do not guess missing inventory information. - device_filter is a literal filter, not a regular expression or pipe list. - Reuse confirmed session choices, but re-query controller operational state. Do not suggest cross-domain provisioning to bypass a local failure. After the tool returns, answer briefly with the key outcome, selected path or domains when present, service/request IDs, selected spectrum when present, and Loading
src/agentic/service/tools/granular.py +117 −16 Changes for src/agentic/service/tools/granular.py: 117 added lines, 16 removed lines. Original line number Diff line number Diff line Loading @@ -16,6 +16,9 @@ from __future__ import annotations import copy import uuid from google.adk.tools import FunctionTool from agentic.service.tools.service import ( Loading @@ -35,19 +38,67 @@ from agentic.service.tools.service_workflow import ( async def create_local_optical_service( source_device: str, destination_device: str, channel_width_ghz: float, channel_width_ghz: float | None = None, preferred_band: str = "", service_name: str = "", capacity_gbps: float | None = None, modulation_format: str = "", source_endpoint: str = "", destination_endpoint: str = "", directionality: str = "unidirectional", preview_only: bool = False, ) -> dict: """Create a local optical service between two local optical endpoints.""" return await create_optical_connectivity_service( """Create or preview local flex-grid connectivity. Gbps is capacity, never GHz. Capacity alone lets TFS select a supported transceiver mode. Accept device/port references or explicit endpoint names. Bidirectional creates two one-way services; return both IDs for deletion. preview_only retrieves inventory and returns payloads without mutation. """ directionality = directionality.strip().lower() if directionality not in {"unidirectional", "bidirectional"}: return {"ok": False, "error": "Invalid directionality"} service_name = service_name or f"opt-{uuid.uuid4().hex[:10]}" kwargs = dict( source_device=source_device, destination_device=destination_device, channel_width_ghz=channel_width_ghz, preferred_band=preferred_band, service_name=service_name, capacity_gbps=capacity_gbps, modulation_format=modulation_format, source_endpoint=source_endpoint, destination_endpoint=destination_endpoint, preview_only=preview_only, ) if directionality == "unidirectional": return await create_optical_connectivity_service( service_name=service_name, **kwargs ) forward = await create_optical_connectivity_service( service_name=f"{service_name}-forward", **kwargs ) if not forward.get("ok"): return {"ok": False, "forward": forward} kwargs.update( source_device=destination_device, destination_device=source_device, source_endpoint=destination_endpoint, destination_endpoint=source_endpoint, ) reverse = await create_optical_connectivity_service( service_name=f"{service_name}-reverse", **kwargs ) result = { "ok": bool(reverse.get("ok")), "directionality": directionality, "forward": forward, "reverse": reverse, } if not reverse.get("ok") and not preview_only: result["rollback"] = await delete_service( service_uuid=forward["service_uuid"] ) return result async def create_cross_domain_optical_service_tool( Loading Loading @@ -122,17 +173,24 @@ async def query_detailed_devices_or_endpoints( scope: str = "all", resource_kind: str = "devices", device_filter: str = "", include_config_rules: bool = False, ) -> dict: """Query detailed device or endpoint inventory.""" """Query device/port IDs; include config only when explicitly requested.""" return await _list_domain_inventory( inventory = await _list_domain_inventory( scope=scope, resource_kind=resource_kind, device_filter=device_filter, detail_level="full", include_config_rules=True, include_config_rules=include_config_rules, include_ids=True, ) if not include_config_rules: inventory = copy.deepcopy(inventory) for domain in inventory.get("domains", {}).values(): for device in domain.get("devices", []): device.pop("device_config", None) return inventory async def query_device_location(device_name: str) -> dict: Loading Loading @@ -183,15 +241,26 @@ async def query_optical_links(scope: str = "all") -> dict: 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.""" async def query_detailed_optical_links( scope: str = "all", include_spectrum: bool = False, ) -> dict: """Query link IDs/ports; include slot maps only on explicit request.""" return await _list_domain_inventory( inventory = await _list_domain_inventory( scope=scope, resource_kind="optical_links", detail_level="full", include_ids=True, ) if not include_spectrum: inventory = copy.deepcopy(inventory) for domain in inventory.get("domains", {}).values(): for link in domain.get("optical_links", []): details = link.get("optical_details", {}) for band in ("c_slots", "l_slots", "s_slots"): details.pop(band, None) inventory["spectrum_included"] = False return inventory async def query_services(scope: str = "all") -> dict: Loading Loading @@ -255,6 +324,7 @@ async def query_local_device_headers( async def query_local_detailed_devices( resource_kind: str = "devices", device_filter: str = "", include_config_rules: bool = False, ) -> dict: """Query detailed local device or endpoint inventory.""" Loading @@ -262,6 +332,7 @@ async def query_local_detailed_devices( scope="local", resource_kind=resource_kind, device_filter=device_filter, include_config_rules=include_config_rules, ) Loading @@ -283,10 +354,14 @@ async def query_local_optical_link_headers() -> dict: return await query_optical_links(scope="local") async def query_local_detailed_optical_links() -> dict: """Query detailed local optical links.""" async def query_local_detailed_optical_links( include_spectrum: bool = False, ) -> dict: """Query local optical ports/IDs; optionally include spectrum slot maps.""" return await query_detailed_optical_links(scope="local") return await query_detailed_optical_links( scope="local", include_spectrum=include_spectrum ) async def query_local_service_headers() -> dict: Loading Loading @@ -329,6 +404,7 @@ async def query_cross_domain_device_headers( async def query_cross_domain_detailed_devices( resource_kind: str = "devices", device_filter: str = "", include_config_rules: bool = False, ) -> dict: """Query detailed all-domain device or endpoint inventory.""" Loading @@ -336,6 +412,7 @@ async def query_cross_domain_detailed_devices( scope="all", resource_kind=resource_kind, device_filter=device_filter, include_config_rules=include_config_rules, ) Loading @@ -357,10 +434,14 @@ async def query_cross_domain_optical_link_headers() -> dict: return await query_optical_links(scope="all") async def query_cross_domain_detailed_optical_links() -> dict: async def query_cross_domain_detailed_optical_links( include_spectrum: bool = False, ) -> dict: """Query detailed all-domain optical links.""" return await query_detailed_optical_links(scope="all") return await query_detailed_optical_links( scope="all", include_spectrum=include_spectrum ) async def query_cross_domain_service_headers() -> dict: Loading Loading @@ -420,6 +501,26 @@ def _compact_inventory_headers(payload: dict) -> dict: entry[key] = domain[key] if "device_names" in domain: entry["device_names"] = domain["device_names"] if "devices" in domain: entry["devices"] = [ {key: item.get(key, "") for key in ( "name", "device_type", "operational_status" )} for item in domain["devices"] if isinstance(item, dict) ] if "services" in domain: entry["services"] = [ {key: item.get(key, "") for key in ( "name", "service_type", "status", "endpoint_count" )} for item in domain["services"] if isinstance(item, dict) ] for family in ("links", "optical_links"): if family in domain: entry[family] = [ {key: item.get(key, "") for key in ("name", "endpoints")} for item in domain[family] if isinstance(item, dict) ] if "endpoints" in domain: entry["endpoint_names"] = [ ( Loading
src/agentic/service/tools/local_endpoints.py 0 → 100644 +66 −0 Changes for src/agentic/service/tools/local_endpoints.py: 66 added lines, 0 removed lines. Original line number Diff line number Diff line # 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. """Resolve service endpoints exclusively from controller inventory.""" def resolve_local_endpoint(devices, reference, endpoint="", direction="out"): reference = reference.strip() matches = [] for device in devices: device_uuid = device.get("device_id", {}).get( "device_uuid", {} ).get("uuid", "") for alias in {device.get("name", ""), device_uuid} - {""}: if reference == alias: matches.append((device, endpoint)) elif reference.startswith(alias + "/"): suffix = reference[len(alias) + 1:] if endpoint and endpoint != suffix: raise ValueError("Conflicting explicit endpoint selectors") matches.append((device, suffix)) # A device can have the same name and UUID; aliases above are deduplicated. if len(matches) != 1: raise ValueError(f"Unknown or ambiguous device: {reference}") device, selector = matches[0] endpoints = device.get("device_endpoints", []) if selector: candidates = [item for item in endpoints if selector in { item.get("name", ""), item.get("endpoint_id", {}).get( "endpoint_uuid", {} ).get("uuid", ""), }] else: preferred = "CHANNEL" if str(device.get("name", "")).upper().startswith("DOMAIN-"): preferred = "port-in" if direction == "in" else "port-out" candidates = [item for item in endpoints if item.get("name") == preferred] if not candidates and len(endpoints) == 1: candidates = endpoints if len(candidates) != 1: choices = [item.get("name", "") for item in endpoints] raise ValueError( f"Select an existing endpoint for {reference}: {choices}" ) endpoint_id = candidates[0].get("endpoint_id", {}) endpoint_uuid = endpoint_id.get("endpoint_uuid", {}).get("uuid") device_id = device.get("device_id", {}) if not endpoint_uuid or not device_id.get("device_uuid", {}).get("uuid"): raise ValueError("Controller inventory lacks canonical endpoint IDs") return { "device_id": device_id, "endpoint_uuid": {"uuid": endpoint_uuid}, }