diff --git a/helm/kagent/files/nginx.conf b/helm/kagent/files/nginx.conf index 8810933207..1af906b3eb 100644 --- a/helm/kagent/files/nginx.conf +++ b/helm/kagent/files/nginx.conf @@ -47,8 +47,8 @@ http { proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Origin $scheme://$host; - proxy_read_timeout 600s; - proxy_send_timeout 600s; + proxy_read_timeout {{ .Values.ui.nginx.proxyReadTimeout }}; + proxy_send_timeout {{ .Values.ui.nginx.proxySendTimeout }}; proxy_buffering off; } @@ -63,6 +63,10 @@ http { proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header Origin $scheme://$host; proxy_cache_bypass $http_upgrade; + # Increased timeouts for streaming endpoints + proxy_read_timeout {{ .Values.ui.nginx.proxyReadTimeout }}; + proxy_send_timeout {{ .Values.ui.nginx.proxySendTimeout }}; + proxy_buffering off; } location /health { @@ -80,8 +84,8 @@ http { proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Host $server_name; proxy_cache_bypass $http_upgrade; - proxy_read_timeout 3600s; - proxy_send_timeout 3600s; + proxy_read_timeout {{ .Values.ui.nginx.proxyReadTimeout }}; + proxy_send_timeout {{ .Values.ui.nginx.proxySendTimeout }}; proxy_buffering off; } } diff --git a/helm/kagent/templates/openshift-route.yaml b/helm/kagent/templates/openshift-route.yaml index 4bb08e95f8..725d5c4c48 100644 --- a/helm/kagent/templates/openshift-route.yaml +++ b/helm/kagent/templates/openshift-route.yaml @@ -6,6 +6,10 @@ kind: Route metadata: name: {{ include "kagent.fullname" . }}-ui namespace: {{ include "kagent.namespace" . }} + {{- with .Values.ui.openshiftRoute.annotations }} + annotations: + {{- toYaml . | nindent 6 }} + {{- end }} spec: to: kind: Service diff --git a/helm/kagent/templates/ui-deployment.yaml b/helm/kagent/templates/ui-deployment.yaml index 05d1b86b88..a3023f42be 100644 --- a/helm/kagent/templates/ui-deployment.yaml +++ b/helm/kagent/templates/ui-deployment.yaml @@ -64,6 +64,10 @@ spec: - name: SSO_REDIRECT_PATH value: {{ .Values.ui.auth.ssoRedirectPath | default "/oauth2/start" | quote }} {{- end }} + {{- if .Values.ui.streamTimeoutSeconds }} + - name: KAGENT_STREAM_TIMEOUT_MS + value: {{ mul (int .Values.ui.streamTimeoutSeconds) 1000 | quote }} + {{- end }} {{- with .Values.ui.additionalForwardedHeaders }} - name: KAGENT_ADDITIONAL_FORWARDED_HEADERS value: {{ join "," . | quote }} diff --git a/helm/kagent/values.yaml b/helm/kagent/values.yaml index 4b5d62b5bd..8fc035110e 100644 --- a/helm/kagent/values.yaml +++ b/helm/kagent/values.yaml @@ -328,6 +328,17 @@ ui: # Path to redirect users to when they click "Sign in with SSO" on the login page # Default: /oauth2/start (oauth2-proxy's authentication start endpoint) ssoRedirectPath: "/oauth2/start" + # -- Client-side chat stream inactivity timeout (seconds). The browser aborts a + # streaming response if no event is received within this window. Should be >= + # ui.nginx.proxyReadTimeout so nginx isn't the silent limit. Default 1800 (30m). + streamTimeoutSeconds: 1800 + # -- Nginx proxy timeout configuration for the UI sidecar (values are passed + # directly to the corresponding nginx directives, e.g. "1800s"). + nginx: + # -- proxy_read_timeout: max time between two successive reads from the upstream. + proxyReadTimeout: 1800s + # -- proxy_send_timeout: max time between two successive writes to the upstream. + proxySendTimeout: 1800s env: {} # Additional configuration key-value pairs for the ui ConfigMap # -- Additional request headers (beyond Authorization) the UI proxy will forward # to the backend. Names are case-insensitive. Hop-by-hop headers (Connection, @@ -390,6 +401,13 @@ ui: # path: /health # port: http # periodSeconds: 15 + + # OpenShift Route only (when route.openshift.io/v1 exists). Long timeouts are required + # for A2A/SSE streaming through the cluster router; defaults are often ~60s and cause + # net::ERR_INCOMPLETE_CHUNKED_ENCODING in the browser. + openshiftRoute: + annotations: + haproxy.router.openshift.io/timeout: 120m # ============================================================================== # LLM PROVIDERS CONFIGURATION # ============================================================================== diff --git a/ui/src/app/a2a/[namespace]/[agentName]/route.ts b/ui/src/app/a2a/[namespace]/[agentName]/route.ts index 8f5eb9cc9b..c2c2b13b8e 100644 --- a/ui/src/app/a2a/[namespace]/[agentName]/route.ts +++ b/ui/src/app/a2a/[namespace]/[agentName]/route.ts @@ -32,7 +32,7 @@ export async function POST( if (!backendResponse.ok) { const errorText = await backendResponse.text(); - return new Response(errorText || 'Backend request failed', { + return new Response(errorText || 'Backend request failed', { status: backendResponse.status, headers: { 'Content-Type': 'text/plain', @@ -90,6 +90,10 @@ export async function POST( return Promise.resolve(); } + // Any upstream bytes count as activity for proxies; also start keep-alives + // before the first complete SSE frame (otherwise HAProxy may idle-timeout). + resetKeepAliveTimer(); + buffer += decoder.decode(value, { stream: true }); // Process complete SSE events (delimited by \n\n) @@ -111,7 +115,7 @@ export async function POST( }).catch(error => { console.error('A2A Proxy: Error in stream pump:', error); if (keepAliveTimer) clearTimeout(keepAliveTimer); - + if (!isClosed) { controller.error(error); isClosed = true; @@ -121,6 +125,9 @@ export async function POST( }); }; + // Begin keep-alives immediately so the browser↔UI connection survives long gaps + // until the first (or any) chunk from the controller. + resetKeepAliveTimer(); pump(); } }); diff --git a/ui/src/app/actions/config.ts b/ui/src/app/actions/config.ts new file mode 100644 index 0000000000..57f7f72619 --- /dev/null +++ b/ui/src/app/actions/config.ts @@ -0,0 +1,19 @@ +"use server"; + +import { DEFAULT_STREAM_TIMEOUT_MS } from "@/lib/constants"; + +export interface UiRuntimeConfig { + streamTimeoutMs: number; +} + +/** + * Returns runtime UI configuration sourced from server-side environment + * variables (set by the Helm chart). Read on the server so values reflect the + * deployment at runtime, unlike NEXT_PUBLIC_* vars which are inlined at build. + */ +export async function getUiRuntimeConfig(): Promise { + const raw = process.env.KAGENT_STREAM_TIMEOUT_MS; + const parsed = raw ? Number(raw) : NaN; + const streamTimeoutMs = Number.isFinite(parsed) && parsed > 0 ? parsed : DEFAULT_STREAM_TIMEOUT_MS; + return { streamTimeoutMs }; +} diff --git a/ui/src/components/chat/ChatInterface.tsx b/ui/src/components/chat/ChatInterface.tsx index 778c501632..dcc89dd91c 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -20,6 +20,8 @@ import type { TokenStats, Session, ChatStatus, ToolDecision } from "@/types"; import StatusDisplay from "./StatusDisplay"; import { createSession, getSessionTasks, checkSessionExists } from "@/app/actions/sessions"; import { waitForSandboxAgentReady } from "@/app/actions/agents"; +import { getUiRuntimeConfig } from "@/app/actions/config"; +import { DEFAULT_STREAM_TIMEOUT_MS } from "@/lib/constants"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { createMessageHandlers, extractMessagesFromTasks, extractApprovalMessagesFromTasks, extractTokenStatsFromTasks, createMessage, ADKMetadata, ProcessedToolCallData } from "@/lib/messageHandlers"; @@ -65,6 +67,22 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se const pendingDecisionsRef = useRef>({}); /** Per-tool rejection reasons collected as the user rejects individual tools. */ const pendingRejectionReasonsRef = useRef>({}); + // Stream inactivity timeout (ms), configurable via Helm (ui.streamTimeoutSeconds). + const streamTimeoutMsRef = useRef(DEFAULT_STREAM_TIMEOUT_MS); + + useEffect(() => { + let cancelled = false; + getUiRuntimeConfig() + .then((config) => { + if (!cancelled) streamTimeoutMsRef.current = config.streamTimeoutMs; + }) + .catch(() => { + /* keep default on failure */ + }); + return () => { + cancelled = true; + }; + }, []); const { isListening, @@ -314,18 +332,24 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se const consumeStream = async (stream: AsyncIterable) => { let timeoutTimer: NodeJS.Timeout | null = null; let streamActive = true; - const STREAM_TIMEOUT_MS = 600000; // 10 minutes + + const formatTimeout = (ms: number): string => { + const mins = ms / 60000; + return mins >= 1 ? `${Math.ceil(mins)} minutes` : `${Math.round(ms / 1000)} seconds`; + }; const startTimeout = () => { if (timeoutTimer) clearTimeout(timeoutTimer); + const streamTimeoutMs = streamTimeoutMsRef.current; timeoutTimer = setTimeout(() => { if (streamActive) { - console.error("⏰ Stream timeout - no events received for 10 minutes"); - toast.error("⏰ Stream timed out - no events received for 10 minutes"); + const label = formatTimeout(streamTimeoutMs); + console.error(`⏰ Stream timeout - no events received for ${label}`); + toast.error(`⏰ Stream timed out - no events received for ${label}`); streamActive = false; abortControllerRef.current?.abort(); } - }, STREAM_TIMEOUT_MS); + }, streamTimeoutMs); }; startTimeout(); diff --git a/ui/src/lib/constants.ts b/ui/src/lib/constants.ts index 2023bcf476..ac5fcd22fc 100644 --- a/ui/src/lib/constants.ts +++ b/ui/src/lib/constants.ts @@ -1,3 +1,7 @@ // Model-related constants export const OLLAMA_DEFAULT_TAG = "latest"; export const OLLAMA_DEFAULT_HOST = "localhost:11434"; + +// Default client-side stream inactivity timeout (30 minutes) used when Helm +// does not provide an override via ui.streamTimeoutSeconds. +export const DEFAULT_STREAM_TIMEOUT_MS = 1800000;