From 936984528409ba1dab69a042e2cc76e7de2a729d Mon Sep 17 00:00:00 2001 From: Yuri Khrakovski Date: Tue, 2 Jun 2026 10:58:59 +0300 Subject: [PATCH 1/3] feat(streaming): configurable nginx, controller, and UI inactivity timeouts Helm chart (`helm/kagent/`) - values.yaml: - Add `ui.streamTimeoutSeconds` (default 1800) - client-side chat stream inactivity timeout, propagated to the browser via the UI ConfigMap. - Add `ui.nginx.proxyReadTimeout` / `proxySendTimeout` (default 1800s) - templated into the UI sidecar nginx config in place of the previously hard-coded per-location values. - Add `ui.openshiftRoute.annotations` with `haproxy.router.openshift.io/timeout: 120m` required for A2A/SSE streaming through the OpenShift router (default ~60s caused ERR_INCOMPLETE_CHUNKED_ENCODING in the browser). - Keep `controller.streaming.timeout` at 1800s (pre-PR default). - files/nginx.conf: replace hard-coded `proxy_read_timeout` / `proxy_send_timeout` values (600s / 1800s / 3600s) with a single templated value driven by `ui.nginx.*`. - templates/openshift-route.yaml: render `ui.openshiftRoute.annotations`. - templates/ui-deployment.yaml: expose `streamTimeoutSeconds` to the UI container. UI (`ui/`) - Add `ui/src/app/actions/config.ts` to load runtime config (stream timeout). - `ui/src/app/a2a/[namespace]/[agentName]/route.ts`: keep upstream connection alive while the stream is open. - `ui/src/components/chat/ChatInterface.tsx`: drive the EventSource inactivity timeout from the configured `streamTimeoutSeconds` and add keep-alive timers to prevent idle browser disconnects. Authored-by: Yuri Khrakovski Co-authored-by: Cursor Signed-off-by: Yaniv Marom Nachumi Co-authored-by: Cursor --- helm/kagent/files/nginx.conf | 12 +++++--- helm/kagent/templates/openshift-route.yaml | 4 +++ helm/kagent/templates/ui-deployment.yaml | 4 +++ helm/kagent/values.yaml | 18 +++++++++++ .../app/a2a/[namespace]/[agentName]/route.ts | 7 +++++ ui/src/app/actions/config.ts | 21 +++++++++++++ ui/src/components/chat/ChatInterface.tsx | 30 ++++++++++++++++--- 7 files changed, 88 insertions(+), 8 deletions(-) create mode 100644 ui/src/app/actions/config.ts 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..01c1920c70 100644 --- a/ui/src/app/a2a/[namespace]/[agentName]/route.ts +++ b/ui/src/app/a2a/[namespace]/[agentName]/route.ts @@ -89,6 +89,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 }); @@ -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..77b15a1270 --- /dev/null +++ b/ui/src/app/actions/config.ts @@ -0,0 +1,21 @@ +"use server"; + +// Default client-side stream inactivity timeout (30 minutes) used when Helm does +// not provide an override. Kept in sync with ui.streamTimeoutSeconds default. +const DEFAULT_STREAM_TIMEOUT_MS = 1800000; + +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..65c5e0461b 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -20,6 +20,7 @@ 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 { toast } from "sonner"; import { useRouter } from "next/navigation"; import { createMessageHandlers, extractMessagesFromTasks, extractApprovalMessagesFromTasks, extractTokenStatsFromTasks, createMessage, ADKMetadata, ProcessedToolCallData } from "@/lib/messageHandlers"; @@ -32,6 +33,10 @@ import { Message, DataPart, Task, TaskState } from "@a2a-js/sdk"; // Task states where the agent is actively processing — resubscribe to live stream. const RESUBSCRIBE_TASK_STATES: TaskState[] = ["submitted", "working"]; +// Fallback stream inactivity timeout (30 minutes) until the Helm-provided runtime +// config is loaded. Overridden by ui.streamTimeoutSeconds via getUiRuntimeConfig. +const DEFAULT_STREAM_TIMEOUT_MS = 1800000; + interface ChatInterfaceProps { selectedAgentName: string; selectedNamespace: string; @@ -65,6 +70,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 +335,19 @@ 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 streamTimeoutMs = streamTimeoutMsRef.current; + const timeoutLabel = `${Math.round(streamTimeoutMs / 60000)} minutes`; const startTimeout = () => { if (timeoutTimer) clearTimeout(timeoutTimer); 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"); + console.error(`⏰ Stream timeout - no events received for ${timeoutLabel}`); + toast.error(`⏰ Stream timed out - no events received for ${timeoutLabel}`); streamActive = false; abortControllerRef.current?.abort(); } - }, STREAM_TIMEOUT_MS); + }, streamTimeoutMs); }; startTimeout(); From 712b6cfd11d452619f573d20368644a7bc6c0a14 Mon Sep 17 00:00:00 2001 From: Yaniv Marom Nachumi Date: Tue, 9 Jun 2026 15:14:22 +0300 Subject: [PATCH 2/3] fix: address PR review comments on streaming timeout config - Read streamTimeoutMs from ref inside startTimeout() so runtime config updates are picked up mid-stream - Export DEFAULT_STREAM_TIMEOUT_MS from config.ts as single source of truth instead of duplicating in ChatInterface.tsx - Use Math.ceil for timeout label and fall back to seconds for sub-minute values to avoid displaying "0 minutes" - Remove trailing whitespace in route.ts Signed-off-by: Yaniv Marom Nachumi Co-authored-by: Cursor --- .../app/a2a/[namespace]/[agentName]/route.ts | 6 +++--- ui/src/app/actions/config.ts | 4 ++-- ui/src/components/chat/ChatInterface.tsx | 19 ++++++++++--------- 3 files changed, 15 insertions(+), 14 deletions(-) diff --git a/ui/src/app/a2a/[namespace]/[agentName]/route.ts b/ui/src/app/a2a/[namespace]/[agentName]/route.ts index 01c1920c70..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', @@ -89,7 +89,7 @@ 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(); @@ -115,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; diff --git a/ui/src/app/actions/config.ts b/ui/src/app/actions/config.ts index 77b15a1270..d5fa1d4c95 100644 --- a/ui/src/app/actions/config.ts +++ b/ui/src/app/actions/config.ts @@ -1,8 +1,8 @@ "use server"; // Default client-side stream inactivity timeout (30 minutes) used when Helm does -// not provide an override. Kept in sync with ui.streamTimeoutSeconds default. -const DEFAULT_STREAM_TIMEOUT_MS = 1800000; +// not provide an override via ui.streamTimeoutSeconds. +export const DEFAULT_STREAM_TIMEOUT_MS = 1800000; export interface UiRuntimeConfig { streamTimeoutMs: number; diff --git a/ui/src/components/chat/ChatInterface.tsx b/ui/src/components/chat/ChatInterface.tsx index 65c5e0461b..9f0bf9d440 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -20,7 +20,7 @@ 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 { getUiRuntimeConfig, DEFAULT_STREAM_TIMEOUT_MS } from "@/app/actions/config"; import { toast } from "sonner"; import { useRouter } from "next/navigation"; import { createMessageHandlers, extractMessagesFromTasks, extractApprovalMessagesFromTasks, extractTokenStatsFromTasks, createMessage, ADKMetadata, ProcessedToolCallData } from "@/lib/messageHandlers"; @@ -33,10 +33,6 @@ import { Message, DataPart, Task, TaskState } from "@a2a-js/sdk"; // Task states where the agent is actively processing — resubscribe to live stream. const RESUBSCRIBE_TASK_STATES: TaskState[] = ["submitted", "working"]; -// Fallback stream inactivity timeout (30 minutes) until the Helm-provided runtime -// config is loaded. Overridden by ui.streamTimeoutSeconds via getUiRuntimeConfig. -const DEFAULT_STREAM_TIMEOUT_MS = 1800000; - interface ChatInterfaceProps { selectedAgentName: string; selectedNamespace: string; @@ -335,15 +331,20 @@ export default function ChatInterface({ selectedAgentName, selectedNamespace, se const consumeStream = async (stream: AsyncIterable) => { let timeoutTimer: NodeJS.Timeout | null = null; let streamActive = true; - const streamTimeoutMs = streamTimeoutMsRef.current; - const timeoutLabel = `${Math.round(streamTimeoutMs / 60000)} 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 ${timeoutLabel}`); - toast.error(`⏰ Stream timed out - no events received for ${timeoutLabel}`); + 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(); } From 8c66c401c2fdc1e144d3f04ee5471ad7d36793d9 Mon Sep 17 00:00:00 2001 From: Yaniv Marom Nachumi Date: Tue, 9 Jun 2026 21:56:41 +0300 Subject: [PATCH 3/3] fix: move DEFAULT_STREAM_TIMEOUT_MS to shared constants module "use server" files can only export async functions. Move the constant to lib/constants.ts and import it in both config.ts and ChatInterface.tsx. Signed-off-by: Yaniv Marom Nachumi Co-authored-by: Cursor --- ui/src/app/actions/config.ts | 4 +--- ui/src/components/chat/ChatInterface.tsx | 3 ++- ui/src/lib/constants.ts | 4 ++++ 3 files changed, 7 insertions(+), 4 deletions(-) diff --git a/ui/src/app/actions/config.ts b/ui/src/app/actions/config.ts index d5fa1d4c95..57f7f72619 100644 --- a/ui/src/app/actions/config.ts +++ b/ui/src/app/actions/config.ts @@ -1,8 +1,6 @@ "use server"; -// 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; +import { DEFAULT_STREAM_TIMEOUT_MS } from "@/lib/constants"; export interface UiRuntimeConfig { streamTimeoutMs: number; diff --git a/ui/src/components/chat/ChatInterface.tsx b/ui/src/components/chat/ChatInterface.tsx index 9f0bf9d440..dcc89dd91c 100644 --- a/ui/src/components/chat/ChatInterface.tsx +++ b/ui/src/components/chat/ChatInterface.tsx @@ -20,7 +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, DEFAULT_STREAM_TIMEOUT_MS } from "@/app/actions/config"; +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"; 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;