diff --git a/cli/planoai/config_generator.py b/cli/planoai/config_generator.py index 522968c9..01957ae7 100644 --- a/cli/planoai/config_generator.py +++ b/cli/planoai/config_generator.py @@ -145,6 +145,41 @@ def validate_and_render_schema(): inferred_clusters[name]["port"], ) = get_endpoint_and_port(endpoint, protocol) + # Process routes in listeners and generate clusters for upstream services + for listener in listeners: + for route in listener.get("routes", []): + path_prefix = route.get("path_prefix", "") + upstream = route.get("upstream", "") + if not path_prefix or not upstream: + continue + + urlparse_result = urlparse(upstream) + if not urlparse_result.scheme or not urlparse_result.hostname: + raise Exception( + f"Invalid upstream URL '{upstream}' in route for listener '{listener.get('name')}'. " + f"Must be a valid URL with scheme (http/https) and hostname." + ) + + protocol = urlparse_result.scheme + port = urlparse_result.port + if port is None: + port = 80 if protocol == "http" else 443 + + sanitized_prefix = ( + path_prefix.strip("/").replace("/", "_").replace("-", "_") + ) + listener_name = listener.get("name", "unknown").replace(" ", "_") + cluster_name = f"route_{listener_name}_{sanitized_prefix}" + + route["cluster_name"] = cluster_name + + if cluster_name not in inferred_clusters: + inferred_clusters[cluster_name] = { + "endpoint": urlparse_result.hostname, + "port": port, + "protocol": protocol, + } + print("defined clusters from plano_config.yaml: ", json.dumps(inferred_clusters)) if "prompt_targets" in config_yaml: diff --git a/cli/test/test_config_generator.py b/cli/test/test_config_generator.py index b3e3ab62..a4c90e61 100644 --- a/cli/test/test_config_generator.py +++ b/cli/test/test_config_generator.py @@ -289,6 +289,63 @@ llm_providers: tracing: random_sampling: 100 +""", + }, + { + "id": "routes_with_agents", + "expected_error": None, + "plano_config": """ +version: v0.3.0 + +agents: + - id: test_agent + url: http://localhost:10500 + +listeners: + - type: agent + name: agent_1 + port: 8001 + routes: + - path_prefix: /traces + upstream: http://localhost:16686 + agents: + - id: test_agent + description: a test agent + + - type: model + name: model_listener + port: 12000 + +model_providers: + - model: openai/gpt-4o + access_key: $OPENAI_API_KEY + +""", + }, + { + "id": "routes_only_listener", + "expected_error": None, + "plano_config": """ +version: v0.3.0 + +listeners: + - type: agent + name: observability + port: 8002 + routes: + - path_prefix: /traces + upstream: http://localhost:16686 + - path_prefix: /metrics + upstream: http://localhost:9090 + + - type: model + name: model_listener + port: 12000 + +model_providers: + - model: openai/gpt-4o + access_key: $OPENAI_API_KEY + """, }, ] diff --git a/config/envoy.template.yaml b/config/envoy.template.yaml index a780c3f1..1ad4aca9 100644 --- a/config/envoy.template.yaml +++ b/config/envoy.template.yaml @@ -277,7 +277,7 @@ static_resources: {% for listener in listeners %} - {% if listener.agents %} + {% if listener.agents or listener.routes %} # agent listeners - name: {{ listener.name | replace(" ", "_") }} @@ -330,6 +330,17 @@ static_resources: prefix: "/healthz" direct_response: status: 200 + {% if listener.routes %} + {% for route in listener.routes %} + - match: + prefix: "{{ route.path_prefix }}" + route: + auto_host_rewrite: true + cluster: {{ route.cluster_name }} + timeout: {{ listener.timeout | default('30s') }} + {% endfor %} + {% endif %} + {% if listener.agents %} - match: prefix: "/" route: @@ -337,6 +348,7 @@ static_resources: prefix_rewrite: "/agents/" cluster: bright_staff timeout: {{ listener.timeout | default('30s') }} + {% endif %} http_filters: - name: envoy.filters.http.compressor typed_config: diff --git a/config/plano_config_schema.yaml b/config/plano_config_schema.yaml index b63cb824..eb7418d9 100644 --- a/config/plano_config_schema.yaml +++ b/config/plano_config_schema.yaml @@ -93,6 +93,19 @@ properties: required: - id - description + routes: + type: array + items: + type: object + properties: + path_prefix: + type: string + upstream: + type: string + additionalProperties: false + required: + - path_prefix + - upstream additionalProperties: false required: - type diff --git a/crates/brightstaff/src/handlers/agent_selector.rs b/crates/brightstaff/src/handlers/agent_selector.rs index faa734ee..d3f7767d 100644 --- a/crates/brightstaff/src/handlers/agent_selector.rs +++ b/crates/brightstaff/src/handlers/agent_selector.rs @@ -194,6 +194,7 @@ mod tests { Listener { name: name.to_string(), agents: Some(agents), + routes: None, port: 8080, router: None, } diff --git a/crates/brightstaff/src/handlers/integration_tests.rs b/crates/brightstaff/src/handlers/integration_tests.rs index 70b2999d..dcf81497 100644 --- a/crates/brightstaff/src/handlers/integration_tests.rs +++ b/crates/brightstaff/src/handlers/integration_tests.rs @@ -74,6 +74,7 @@ mod tests { let listener = Listener { name: "test-listener".to_string(), agents: Some(vec![agent_pipeline.clone()]), + routes: None, port: 8080, router: None, }; diff --git a/crates/common/src/configuration.rs b/crates/common/src/configuration.rs index f4e2b7b4..d1b0afed 100644 --- a/crates/common/src/configuration.rs +++ b/crates/common/src/configuration.rs @@ -36,11 +36,18 @@ pub struct AgentFilterChain { pub filter_chain: Option>, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct ListenerRoute { + pub path_prefix: String, + pub upstream: String, +} + #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Listener { pub name: String, pub router: Option, pub agents: Option>, + pub routes: Option>, pub port: u16, } diff --git a/demos/agent_orchestration/travel_agents/README.md b/demos/agent_orchestration/travel_agents/README.md index 731084ba..bc8f1006 100644 --- a/demos/agent_orchestration/travel_agents/README.md +++ b/demos/agent_orchestration/travel_agents/README.md @@ -51,6 +51,7 @@ This starts: - Flight Agent on port 10520 - Open WebUI on port 8080 - Plano Proxy on port 8001 +- Jaeger UI accessible at http://localhost:8001/traces (routed through Plano) ### 4. Test the System @@ -105,9 +106,30 @@ Both agents run as Docker containers and communicate with Plano via `host.docker ## Observability -This demo includes full OpenTelemetry (OTel) compatible distributed tracing to monitor and debug agent interactions: +This demo includes full OpenTelemetry (OTel) compatible distributed tracing to monitor and debug agent interactions. The tracing data provides complete visibility into the multi-agent system, making it easy to identify bottlenecks, debug issues, and optimize performance. +Jaeger UI is accessible through Plano's agent listener using the `routes` config: + +```bash +# Access Jaeger UI through the same Plano port +curl http://localhost:8001/traces +``` + +This is configured in `config.yaml` using the `routes` field on the listener, which proxies `/traces` requests to the Jaeger service: + +```yaml +listeners: + - type: agent + name: travel_booking_service + port: 8001 + routes: + - path_prefix: /traces + upstream: http://jaeger:16686 + agents: + ... +``` + For more details on setting up and using tracing, see the [Plano Observability documentation](https://docs.planoai.dev/guides/observability/tracing.html). ![alt text](tracing.png) diff --git a/demos/agent_orchestration/travel_agents/config.yaml b/demos/agent_orchestration/travel_agents/config.yaml index 911baf89..8f8a5842 100644 --- a/demos/agent_orchestration/travel_agents/config.yaml +++ b/demos/agent_orchestration/travel_agents/config.yaml @@ -18,6 +18,9 @@ listeners: name: travel_booking_service port: 8001 router: plano_orchestrator_v1 + routes: + - path_prefix: /traces + upstream: http://jaeger:16686 agents: - id: weather_agent description: | diff --git a/docs/source/concepts/listeners.rst b/docs/source/concepts/listeners.rst index d8b78e11..7ff191a9 100644 --- a/docs/source/concepts/listeners.rst +++ b/docs/source/concepts/listeners.rst @@ -77,3 +77,33 @@ listener with address, port, and protocol details: When you start Plano, you specify a listener address/port that you want to bind downstream. Plano also exposes a predefined internal listener (``127.0.0.1:12000``) that you can use to proxy egress calls originating from your application to LLMs (API-based or hosted) via prompt targets. + +Internal Service Routes +^^^^^^^^^^^^^^^^^^^^^^^ + +Listeners support an optional ``routes`` block that lets you forward path-prefix traffic to co-located internal +services (for example, a Jaeger tracing UI or a Prometheus dashboard) through the same listener port: + +.. code-block:: yaml + + listeners: + - type: agent + name: agent_1 + port: 8001 + routes: + - path_prefix: /traces + upstream: http://jaeger:16686 + - path_prefix: /metrics + upstream: http://localhost:9090 + agents: + - id: my_agent + description: my agent + +With the configuration above, requests to ``http://localhost:8001/traces`` are proxied to the Jaeger UI, while +all other requests are routed to the agent as usual. Each route entry requires: + +- ``path_prefix`` — the URL prefix to match (e.g., ``/traces``). +- ``upstream`` — the full URL of the internal service to forward to. + +Routes are evaluated before the default agent catch-all, so specific prefixes always take priority. A listener +can also define only ``routes`` (without ``agents``) to act as a lightweight reverse proxy for internal services. diff --git a/docs/source/guides/observability/tracing.rst b/docs/source/guides/observability/tracing.rst index 950befd2..c6c71074 100644 --- a/docs/source/guides/observability/tracing.rst +++ b/docs/source/guides/observability/tracing.rst @@ -453,6 +453,30 @@ Handle incoming requests: print(f"Payment service response: {response.content}") +Exposing a Trace UI via Listener Routes +---------------------------------------- + +If you run a trace collector with a web UI (such as Jaeger) alongside Plano, you can expose it through the same +listener port using the ``routes`` configuration. This avoids publishing extra ports and gives developers a single +endpoint for both agent traffic and trace inspection. + +.. code-block:: yaml + + listeners: + - type: agent + name: agent_1 + port: 8001 + routes: + - path_prefix: /traces + upstream: http://jaeger:16686 + agents: + - id: my_agent + description: my agent + +With this configuration, browsing to ``http://localhost:8001/traces`` opens the Jaeger UI while all other +requests continue to be routed to the agent. See :ref:`plano_overview_listeners` for more details on the +``routes`` block. + Integrating with Tracing Tools ------------------------------ diff --git a/docs/source/resources/includes/plano_config_full_reference.yaml b/docs/source/resources/includes/plano_config_full_reference.yaml index cc3973e0..637c61a2 100644 --- a/docs/source/resources/includes/plano_config_full_reference.yaml +++ b/docs/source/resources/includes/plano_config_full_reference.yaml @@ -55,6 +55,10 @@ listeners: port: 8001 router: plano_orchestrator_v1 address: 0.0.0.0 + # Routes forward path-prefix traffic to internal services (e.g., Jaeger UI) + routes: + - path_prefix: /traces + upstream: http://jaeger:16686 agents: - id: rag_agent description: virtual assistant for retrieval augmented generation tasks diff --git a/docs/source/resources/includes/plano_config_full_reference_rendered.yaml b/docs/source/resources/includes/plano_config_full_reference_rendered.yaml index abd909a0..191b717a 100644 --- a/docs/source/resources/includes/plano_config_full_reference_rendered.yaml +++ b/docs/source/resources/includes/plano_config_full_reference_rendered.yaml @@ -19,6 +19,10 @@ endpoints: mistral_local: endpoint: 127.0.0.1 port: 8001 + route_travel_booking_service_traces: + endpoint: jaeger + port: 16686 + protocol: http weather_agent: endpoint: host.docker.internal port: 10510 @@ -36,6 +40,10 @@ listeners: name: travel_booking_service port: 8001 router: plano_orchestrator_v1 + routes: + - cluster_name: route_travel_booking_service_traces + path_prefix: /traces + upstream: http://jaeger:16686 type: agent - address: 0.0.0.0 model_providers: