Skip to content

Commit 5492d35

Browse files
authored
Merge branch 'main' into konflux/mintmaker/main/opentelemetry-python-monorepo
2 parents baf7979 + 84d0102 commit 5492d35

11 files changed

Lines changed: 616 additions & 118 deletions

File tree

docs/rate-limiting.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -182,4 +182,5 @@ Rate limiting happens **before** the request is processed (at the middleware lay
182182

183183
- Rate limits are enforced across replicas as long as they share the same Redis instance.
184184
- The service verifies Redis connectivity at startup and fails fast when Redis is unavailable.
185+
- **Fail-open behaviour**: If Redis becomes unreachable at runtime, requests are allowed through without rate limiting (with a warning log). This prevents a Redis outage from causing a self-inflicted denial of service.
185186
- **In-transit encryption (TLS)**: Cloud Memorystore instances are created with `--transit-encryption-mode=SERVER_AUTHENTICATION`. Use the `rediss://` URL scheme and set `RATE_LIMIT_REDIS_CA_CERT` to the path of the mounted server CA certificate. See [Cloud Run Deployment — Redis Setup](../deploy/cloudrun/README.md#4-redis-setup-for-rate-limiting) for setup instructions.

pyproject.toml

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -37,6 +37,8 @@ dependencies = [
3737
# Google Cloud authentication (ADC for Procurement API calls)
3838
"google-auth>=2.0.0",
3939
"requests>=2.20.0", # Required by google.auth.transport.requests
40+
# Redis for distributed rate limiting (used by both agent and marketplace handler)
41+
"redis>=5.0.0",
4042
]
4143

4244
[project.optional-dependencies]
@@ -49,8 +51,6 @@ agent = [
4951
"python-dotenv>=1.0.0",
5052
# Google Cloud integration
5153
"google-cloud-service-control>=1.0.0",
52-
# Redis for distributed rate limiting
53-
"redis>=5.0.0",
5454
# OpenTelemetry for distributed tracing
5555
"opentelemetry-api>=1.20.0",
5656
"opentelemetry-sdk>=1.20.0",

src/lightspeed_agent/api/a2a/a2a_setup.py

Lines changed: 7 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@
2424
from lightspeed_agent.api.a2a.agent_card import build_agent_card
2525
from lightspeed_agent.api.a2a.logging_plugin import AgentLoggingPlugin
2626
from lightspeed_agent.api.a2a.mcp_output_size_guard_plugin import MCPOutputSizeGuardPlugin
27+
from lightspeed_agent.api.a2a.response_formatter_plugin import ResponseFormatterPlugin
2728
from lightspeed_agent.api.a2a.usage_plugin import UsageTrackingPlugin
2829
from lightspeed_agent.config import get_settings
2930
from lightspeed_agent.core import create_agent
@@ -120,7 +121,12 @@ def _create_runner() -> Runner:
120121
app = App(
121122
name=settings.agent_name,
122123
root_agent=agent,
123-
plugins=[AgentLoggingPlugin(), UsageTrackingPlugin(), MCPOutputSizeGuardPlugin()],
124+
plugins=[
125+
AgentLoggingPlugin(),
126+
UsageTrackingPlugin(),
127+
MCPOutputSizeGuardPlugin(),
128+
ResponseFormatterPlugin(),
129+
],
124130
)
125131

126132
# Use database-backed session service for production

src/lightspeed_agent/api/a2a/agent_card.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -179,8 +179,8 @@ def build_agent_card() -> AgentCard:
179179
security=[
180180
{"redhat_sso": ["openid", "api.console", "api.ocm"]},
181181
],
182-
default_input_modes=["text"],
183-
default_output_modes=["text"],
182+
default_input_modes=["text/plain"],
183+
default_output_modes=["text/plain"],
184184
)
185185

186186
return agent_card
Lines changed: 82 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,82 @@
1+
"""Response formatter plugin.
2+
3+
Injects the first-response legal notice and the AI-content disclaimer
4+
footer at the application layer so the LLM does not need to track
5+
conversation state or remember to include verbatim boilerplate.
6+
7+
- The notice is prepended to the first final text response in each session.
8+
- The footer is appended to every final text response.
9+
"""
10+
11+
import logging
12+
13+
from google.adk.agents.invocation_context import InvocationContext
14+
from google.adk.events.event import Event
15+
from google.adk.plugins.base_plugin import BasePlugin
16+
17+
logger = logging.getLogger(__name__)
18+
19+
FIRST_RESPONSE_NOTICE = (
20+
"You are interacting with the Red Hat Lightspeed Agent, which can answer questions "
21+
"about your Red Hat account, subscription, system configuration, and related details. "
22+
"This feature uses AI technology. Interactions may be used to improve Red Hat's "
23+
"products or services.\n\n"
24+
"Always review AI-generated content prior to use.\n\n"
25+
)
26+
27+
RESPONSE_FOOTER = "\n\n---\n*Always review AI-generated content prior to use.*"
28+
29+
30+
class ResponseFormatterPlugin(BasePlugin):
31+
"""ADK plugin that injects the first-response notice and disclaimer footer."""
32+
33+
def __init__(self) -> None:
34+
super().__init__(name="response_formatter")
35+
36+
async def on_event_callback(
37+
self, *, invocation_context: InvocationContext, event: Event
38+
) -> Event | None:
39+
"""Inject the first-response notice and disclaimer footer."""
40+
if not event.is_final_response():
41+
return None
42+
43+
if not event.content or not event.content.parts:
44+
return None
45+
46+
# Locate the first and last text parts
47+
first_text_idx: int | None = None
48+
last_text_idx: int | None = None
49+
for i, part in enumerate(event.content.parts):
50+
if part.text:
51+
if first_text_idx is None:
52+
first_text_idx = i
53+
last_text_idx = i
54+
55+
if first_text_idx is None or last_text_idx is None:
56+
return None
57+
58+
# Prepend first-response notice when this is a new session
59+
if self._is_first_agent_response(invocation_context.session.events):
60+
first_text = event.content.parts[first_text_idx].text or ""
61+
event.content.parts[first_text_idx].text = (
62+
FIRST_RESPONSE_NOTICE + first_text
63+
)
64+
logger.debug("Prepended first-response notice to agent response")
65+
66+
# Append disclaimer footer to every final response
67+
last_text = event.content.parts[last_text_idx].text or ""
68+
event.content.parts[last_text_idx].text = last_text + RESPONSE_FOOTER
69+
70+
return event
71+
72+
@staticmethod
73+
def _is_first_agent_response(session_events: list[Event]) -> bool:
74+
"""Return True when no prior agent event in the session contains text."""
75+
for ev in session_events:
76+
if ev.author == "user":
77+
continue
78+
if ev.content and ev.content.parts:
79+
for part in ev.content.parts:
80+
if part.text:
81+
return False
82+
return True

src/lightspeed_agent/api/app.py

Lines changed: 28 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -9,12 +9,15 @@
99
"""
1010

1111
import logging
12+
import pathlib
1213
from collections.abc import AsyncIterator
1314
from contextlib import asynccontextmanager
1415
from typing import Any
1516

1617
from a2a.server.apps.jsonrpc.fastapi_app import A2AFastAPI
18+
from fastapi import Request
1719
from fastapi.middleware.cors import CORSMiddleware
20+
from starlette.responses import FileResponse
1821

1922
from lightspeed_agent.api.a2a.a2a_setup import setup_a2a_routes
2023
from lightspeed_agent.api.a2a.agent_card import get_agent_card_dict
@@ -24,9 +27,18 @@
2427
from lightspeed_agent.ratelimit import RateLimitMiddleware, get_redis_rate_limiter
2528
from lightspeed_agent.security import RequestBodyLimitMiddleware, SecurityHeadersMiddleware
2629

30+
_LOGO_PATH = pathlib.Path(__file__).parent.parent / "static" / "logo.png"
31+
2732
logger = logging.getLogger(__name__)
2833

2934

35+
def _agent_card_response(request: Request) -> dict[str, Any]:
36+
"""Build agent card dict with a dynamic iconUrl derived from the request base URL."""
37+
card = get_agent_card_dict()
38+
icon_url = f"{str(request.base_url).rstrip('/')}/static/logo.png"
39+
return {**card, "iconUrl": icon_url}
40+
41+
3042
@asynccontextmanager
3143
async def lifespan(app: A2AFastAPI) -> AsyncIterator[None]:
3244
"""Application lifespan manager for startup/shutdown events."""
@@ -134,19 +146,32 @@ def create_app() -> A2AFastAPI:
134146
lifespan=lifespan,
135147
)
136148

149+
# Serve the Red Hat logo for the agent card iconUrl
150+
@app.get("/static/logo.png")
151+
async def serve_logo() -> FileResponse:
152+
"""Serve the agent logo image."""
153+
return FileResponse(_LOGO_PATH, media_type="image/png")
154+
155+
# Custom agent card endpoint registered BEFORE setup_a2a_routes so
156+
# FastAPI's first-match routing picks it up instead of the SDK default.
157+
@app.get("/.well-known/agent.json")
158+
async def agent_card_with_icon(request: Request) -> dict[str, Any]:
159+
"""AgentCard endpoint with dynamic iconUrl."""
160+
return _agent_card_response(request)
161+
137162
# Set up A2A protocol routes using ADK's built-in integration
138163
# This provides:
139-
# - GET /.well-known/agent.json - AgentCard
164+
# - GET /.well-known/agent.json - AgentCard (overridden above)
140165
# - POST / - JSON-RPC 2.0 endpoint for message/send, message/stream, etc.
141166
# The ADK integration handles SSE streaming, task management, and
142167
# event conversion automatically.
143168
setup_a2a_routes(app)
144169

145170
# Alias for agent card (some clients use agent-card.json)
146171
@app.get("/.well-known/agent-card.json")
147-
async def agent_card_alias() -> dict[str, Any]:
172+
async def agent_card_alias(request: Request) -> dict[str, Any]:
148173
"""AgentCard endpoint (alias for agent.json)."""
149-
return get_agent_card_dict()
174+
return _agent_card_response(request)
150175

151176
# Add authentication middleware for A2A endpoint (innermost layer)
152177
# Validates Red Hat SSO JWT tokens on POST / requests

0 commit comments

Comments
 (0)