#!/bin/bash set -e # Initialize PIDs up front so the cleanup trap below does not emit bare # ``kill`` usage errors when the script aborts before either child starts # (e.g. FATAL in ``_check_key``). AGENT_PID="" NEXT_PID="" WATCHDOG_PID="" # Disable Python stdout buffering so the FastAPI/uvicorn agent flushes # tracebacks and log lines immediately. Without this a silent crash during # module import can sit in Python's userspace buffer until the process # exits, by which point the container is already gone. Paired with `python # -u` on the uvicorn invocation below and `awk ... fflush()` on the log # prefixer — all three are belt-and-suspenders measures against pipe- # buffered log loss observed across Railway deploys. export PYTHONUNBUFFERED=1 cleanup() { # Trap may fire from a FATAL ``exit 1`` path where ``set -e`` is still # active. Any non-zero return from ``kill`` (e.g. process already gone) # in a ``&&`` chain whose final command is ``kill`` is subject to # errexit and would abort cleanup before the grace loop runs. Disable # errexit for the duration of the trap — every kill/wait below # explicitly expects and tolerates non-zero returns. set +e # Guard each pid: empty var -> skip (no operand), set var -> best-effort # SIGTERM. ``2>/dev/null`` swallows normal "no such process" races after # wait has already reaped the child. # # After SIGTERM, give each child up to 5s to exit cleanly before # escalating to SIGKILL. Matches the survivor-termination grace window # further down and the starter entrypoint's cleanup pattern — a # runaway uvicorn / next.js process should not get wedged on trap-exit # waiting for the container runtime to SIGKILL it. [ -n "$AGENT_PID" ] && kill "$AGENT_PID" 2>/dev/null [ -n "$NEXT_PID" ] && kill "$NEXT_PID" 2>/dev/null [ -n "$WATCHDOG_PID" ] && kill "$WATCHDOG_PID" 2>/dev/null for _ in 1 2 3 4 5; do local any_alive=0 [ -n "$AGENT_PID" ] && kill -0 "$AGENT_PID" 2>/dev/null && any_alive=1 [ -n "$NEXT_PID" ] && kill -0 "$NEXT_PID" 2>/dev/null && any_alive=1 [ "$any_alive" = "0" ] && break sleep 1 done [ -n "$AGENT_PID" ] && kill -0 "$AGENT_PID" 2>/dev/null && kill -9 "$AGENT_PID" 2>/dev/null [ -n "$NEXT_PID" ] && kill -0 "$NEXT_PID" 2>/dev/null && kill -9 "$NEXT_PID" 2>/dev/null [ -n "$WATCHDOG_PID" ] && kill -0 "$WATCHDOG_PID" 2>/dev/null && kill -9 "$WATCHDOG_PID" 2>/dev/null return 0 } trap cleanup EXIT # Provider-agnostic startup diagnostic. langroid is multi-provider — the chat # model is selected via ``LANGROID_MODEL`` (e.g. ``gpt-4.1``, # ``litellm/anthropic/claude-opus-4``, ``gemini/gemini-2.5-flash``). Whichever # provider is picked, only THAT provider's API key is required. # # This block inspects ``LANGROID_MODEL`` (and the planner-only override # ``A2UI_MODEL`` if distinct) and warns when the expected credential env # var is missing. Default behavior is warn-and-continue so operators can # bring the container up for local dev; set ``REQUIRE_LANGROID_API_KEY=1`` # in production to fail-fast. # Map a langroid model string like ``gpt-4.1`` (bare OpenAI name) or # ``gemini/gemini-2.5-flash`` to the env var that langroid's ``OpenAIGPT`` # client actually reads at request time. Mappings verified against # langroid's installed ``language_models/openai_gpt.py`` — in particular: # * Bare OpenAI names (``gpt-*``, ``o1*``, ``o3*``, ``o4*``, anything with # NO ``/`` separator) # -> ``OPENAI_API_KEY``. langroid strips no prefix from # ``openai/`` — it passes the model string # LITERALLY to the OpenAI SDK, which then rejects # ``openai/gpt-4.1`` as "model not found". Use bare # OpenAI names. # * ``openai/*`` -> WARN (fatal under REQUIRE_LANGROID_API_KEY=1): # ``openai/`` is NOT a langroid-native prefix; # langroid passes it literally to the OpenAI SDK # which will reject the model id. # * ``gemini/*`` -> ``GEMINI_API_KEY`` (NOT ``GOOGLE_API_KEY``; that is # google-genai / google-adk's convention, not langroid's). # * ``openrouter/*`` -> ``OPENROUTER_API_KEY``. # * ``groq/*`` -> ``GROQ_API_KEY`` (native langroid prefix). # * ``cerebras/*`` -> ``CEREBRAS_API_KEY`` (native langroid prefix). # * ``glhf/*`` -> ``GLHF_API_KEY`` (native langroid prefix). # * ``minimax/*`` -> ``MINIMAX_API_KEY`` (native langroid prefix). # * ``portkey/*`` -> ``PORTKEY_API_KEY`` (native langroid prefix; note # langroid ALSO reads portkey provider-specific keys # at request time — a plain ``PORTKEY_API_KEY`` probe # is the best we can do at boot). # * ``deepseek/*`` -> ``DEEPSEEK_API_KEY`` (native langroid prefix). # * ``litellm/anthropic/*`` -> ``ANTHROPIC_API_KEY`` (langroid strips the # ``litellm/`` prefix and delegates to litellm, which # reads ``ANTHROPIC_API_KEY`` for the Anthropic provider). # * Bare ``anthropic/*`` is NOT a langroid-native prefix — langroid has no # handling for it and falls through to the default # OpenAI client, which rejects the request. We still # map it to ``ANTHROPIC_API_KEY`` so the env-guard # doesn't falsely succeed in warn-mode, but _check_key # FATALs under ``REQUIRE_LANGROID_API_KEY=1`` so fail- # fast operators see this misconfig at boot rather than # at first request. # * ``ollama/*``, ``local/*``, ``vllm/*``, ``llamacpp/*`` -> no API key # required (local-inference); ``_check_key`` returns # the ``NO_KEY_REQUIRED`` sentinel and logs INFO. _expected_key_for_model() { local model="${1:-gpt-4.1}" # ORDER MATTERS: ``litellm/anthropic/*`` must precede the bare # ``anthropic/*`` arm below. Otherwise ``litellm/anthropic/...`` would # never match — bash ``case`` uses first-match-wins, and an earlier bare # ``anthropic/*`` arm would never fire for a ``litellm/`` prefix anyway, # but keeping litellm first makes the routing intent explicit and is # robust to future reorderings. case "$model" in # Local-inference prefixes: no API key required. Sentinel distinct # from the empty string so _check_key can log an INFO and return 0 # even under REQUIRE_LANGROID_API_KEY=1 (fail-fast), rather than # FATALing with "Cannot infer required credential". ollama/*|local/*|vllm/*|llamacpp/*) echo "NO_KEY_REQUIRED" ;; litellm/anthropic/*) echo "ANTHROPIC_API_KEY" ;; anthropic/*) echo "ANTHROPIC_API_KEY" ;; openai/*) echo "OPENAI_API_KEY" ;; openrouter/*) echo "OPENROUTER_API_KEY" ;; gemini/*) echo "GEMINI_API_KEY" ;; # ``google/`` is intentionally NOT mapped here. langroid has no # native ``google/`` prefix handling — treating it as a gemini # alias would let fail-fast mode "succeed" at boot (because # GEMINI_API_KEY is set) only to blow up at request time when # langroid falls through to the default OpenAI client. The # dedicated ``google/*`` arm inside ``_check_key`` FATALs under # REQUIRE_LANGROID_API_KEY=1 and WARNs otherwise, which is the # correct signal. groq/*) echo "GROQ_API_KEY" ;; cerebras/*) echo "CEREBRAS_API_KEY" ;; glhf/*) echo "GLHF_API_KEY" ;; minimax/*) echo "MINIMAX_API_KEY" ;; portkey/*) echo "PORTKEY_API_KEY" ;; deepseek/*) echo "DEEPSEEK_API_KEY" ;; # langdb/*: langroid's ``OpenAIGPT`` natively handles this prefix # (sets ``is_langdb``) and resolves credentials via ``langdb_params`` # (a config object) rather than a single env var. There is no env var # for us to probe at startup — emit a distinct NO_KEY_REQUIRED_* # sentinel so ``_check_key`` logs INFO and returns 0 even under # REQUIRE_LANGROID_API_KEY=1. langdb/*) echo "NO_KEY_REQUIRED_LANGDB" ;; # litellm-proxy/*: langroid's ``OpenAIGPT`` natively handles this # prefix (sets ``is_litellm_proxy``) and resolves credentials via # ``LiteLLMProxyConfig`` (a config object) rather than a single env # var. Same NO_KEY_REQUIRED_* treatment as langdb/. litellm-proxy/*) echo "NO_KEY_REQUIRED_LITELLM_PROXY" ;; # Non-anthropic litellm variants (``litellm/openai/*``, # ``litellm/azure/*``, ``litellm/bedrock/*``, etc.) — litellm resolves # per-provider env vars internally (AZURE_API_KEY, AZURE_API_BASE, # AWS_ACCESS_KEY_ID, ...) and we don't know which to probe at boot. # Note: ``litellm/anthropic/*`` is handled by the SPECIFIC earlier # arm (returns ANTHROPIC_API_KEY) and matches first by bash # first-match-wins ordering — this catch-all only sees the non- # anthropic variants. litellm/*) echo "NO_KEY_REQUIRED_LITELLM" ;; # Bare model names with no ``/`` separator are treated as OpenAI # (gpt-*, o1*, o3*, o4*, chatgpt-*, etc.). This matches langroid's # canonical convention (``OpenAIChatModel.GPT4_1.value == "gpt-4.1"``) # — and the OpenAI SDK accepts them directly. */*) echo "" ;; *) echo "OPENAI_API_KEY" ;; esac } # Log when we're falling back to the default so operators understand why # the OpenAI-shaped env guard fires even though they "didn't pick OpenAI". if [ -z "${LANGROID_MODEL:-}" ]; then echo "[entrypoint] INFO: LANGROID_MODEL not set — defaulting to 'gpt-4.1' (OPENAI_API_KEY will be required)" >&2 fi LANGROID_MODEL_EFFECTIVE="${LANGROID_MODEL:-gpt-4.1}" A2UI_MODEL_EFFECTIVE="${A2UI_MODEL:-$LANGROID_MODEL_EFFECTIVE}" _check_key() { local model="$1"; local role="$2" # ``google/`` is a common typo for ``gemini/`` — handle it BEFORE we # call ``_expected_key_for_model`` so a GEMINI_API_KEY that happens to # be set can't silently pass the fail-fast guard for a prefix that has # no langroid-native routing. case "$model" in google/*) if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then echo "[entrypoint] FATAL: $role model '$model' uses 'google/' prefix which is not a langroid-native prefix. Use 'gemini/' instead (with GEMINI_API_KEY set); refusing to start under REQUIRE_LANGROID_API_KEY=1" >&2 exit 1 fi echo "[entrypoint] WARN: $role model '$model' uses 'google/' prefix — langroid has no native google/ routing; use 'gemini/' instead. Request-time calls will fail." >&2 return 0 ;; esac # ``openai/*`` is NOT langroid-native either: langroid passes the full # string LITERALLY to the OpenAI SDK (verified empirically — the # ``openai/`` prefix is not stripped inside ``lm.OpenAIGPT``), and the # SDK rejects ``openai/gpt-4.1`` as "model not found". Emit a warning so # operators see the boot-time remediation rather than a cryptic # request-time failure. case "$model" in openai/*) if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then echo "[entrypoint] FATAL: $role model '$model' uses 'openai/' prefix which is not a langroid-native prefix — langroid passes it literally to the OpenAI SDK which will reject it. Use the bare model name (e.g. 'gpt-4.1') instead; refusing to start under REQUIRE_LANGROID_API_KEY=1" >&2 exit 1 fi echo "[entrypoint] WARN: $role model '$model' uses 'openai/' prefix — langroid passes it LITERALLY to the OpenAI SDK (the prefix is NOT stripped) and the SDK will reject it as 'model not found'. Use the bare model name (e.g. 'gpt-4.1') instead. Falling through to OPENAI_API_KEY check so the operator sees both issues at boot." >&2 ;; esac local var var=$(_expected_key_for_model "$model") # NO_KEY_REQUIRED sentinels — two families: # * Plain ``NO_KEY_REQUIRED``: local-inference models (ollama/, local/, # vllm/, llamacpp/) — no credential at all. # * ``NO_KEY_REQUIRED_*`` variants: langroid-native prefixes where # credentials ARE required but resolved via a config object # (langdb_params, LiteLLMProxyConfig) or via per-provider env vars # internal to litellm (AZURE_*, AWS_*, etc.). We cannot name a # single env var to probe at startup — skip the env-key check and # let request-time surface any missing config. # Both skip the env check and return 0 even under REQUIRE_LANGROID_API_KEY=1 # so the fail-fast contract doesn't reject a legitimately-configured # langroid-native prefix. case "$var" in NO_KEY_REQUIRED) echo "[entrypoint] INFO: local-inference model '$model' — no API key required for $role" >&2 return 0 ;; NO_KEY_REQUIRED_*) echo "[entrypoint] INFO: $role model '$model' uses a langroid-native prefix that resolves credentials via config (no single env var to probe) — skipping env-key check" >&2 return 0 ;; esac if [ -z "$var" ]; then if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then echo "[entrypoint] FATAL: Cannot infer required credential for $role model '$model'. Set a langroid-native prefix (bare OpenAI name e.g. 'gpt-4.1', litellm/anthropic/, gemini/, openrouter/, groq/, cerebras/, glhf/, minimax/, portkey/, deepseek/, ollama/, local/, vllm/, llamacpp/) or set REQUIRE_LANGROID_API_KEY=0 to downgrade to warn-mode." >&2 exit 1 fi echo "[entrypoint] INFO: $role model '$model' does not match a known provider prefix — skipping env-key check (request-time calls will surface credentials)" >&2 return 0 fi # Bash indirect expansion with default: ``${!var:-}`` resolves to the # value of the env var NAMED by ``$var``, or "" if unset. The ``:-`` # default guarantees we evaluate to the empty string when the caller has # not exported the credential, which is what the empty-check below # expects. Note: this script runs under ``set -e`` but NOT ``set -u`` — # every ``${FOO:-default}`` site in the file is load-bearing as-written # because several env vars (REQUIRE_LANGROID_API_KEY, LANGROID_MODEL, # A2UI_MODEL, PORT) are commonly unset in dev. local val="${!var:-}" if [ -z "$val" ]; then if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then echo "[entrypoint] FATAL: $var not set (required by $role model '$model') and REQUIRE_LANGROID_API_KEY=1 — refusing to start" >&2 exit 1 fi echo "[entrypoint] WARN: $var not set — $role ('$model') calls will fail at request time (structured error returned to client)" >&2 fi # Bare ``anthropic/`` is not a langroid-native prefix; langroid # only routes Anthropic via ``litellm/anthropic/...`` or # ``openrouter/anthropic/...``. If an operator sets # ``LANGROID_MODEL=anthropic/claude-opus-4`` the env-key check passes # but the request will fail downstream because langroid falls back to # the default OpenAI client and the OpenAI SDK rejects the model id. # # Under ``REQUIRE_LANGROID_API_KEY=1`` we FATAL (fail-fast contract) — # silently booting and failing at first request contradicts the whole # point of the guard. Under warn-mode we surface a WARN so local-dev # operators can still bring the container up. The outer ``case`` # pattern already matched ``anthropic/*`` — no inner guard is needed # (a string cannot simultaneously start with ``anthropic/`` and # ``litellm/anthropic/``; the latter is handled by the earlier # ``litellm/anthropic/*`` arm in ``_expected_key_for_model``). # NOTE: this case intentionally tests only the bare ``anthropic/*`` # pattern. ``litellm/anthropic/...`` strings already matched the earlier # ``litellm/anthropic/*`` arm in ``_expected_key_for_model`` (which runs # first by design — see the ORDER MATTERS comment there) and are routed # correctly via litellm; we must NOT warn on them here. case "$model" in anthropic/*) if [ "${REQUIRE_LANGROID_API_KEY:-0}" = "1" ]; then echo "[entrypoint] FATAL: $role model '$model' uses bare 'anthropic/' prefix which is not routable through langroid (native langroid Anthropic support goes via 'litellm/anthropic/' with ANTHROPIC_API_KEY set); refusing to start under REQUIRE_LANGROID_API_KEY=1" >&2 exit 1 fi echo "[entrypoint] WARN: $role model '$model' uses bare 'anthropic/' prefix — langroid has no native Anthropic routing; requests will fail. Use 'litellm/anthropic/' instead (drop-in replacement that reads ANTHROPIC_API_KEY)." >&2 ;; esac } _check_key "$LANGROID_MODEL_EFFECTIVE" "primary agent" if [ "$A2UI_MODEL_EFFECTIVE" != "$LANGROID_MODEL_EFFECTIVE" ]; then _check_key "$A2UI_MODEL_EFFECTIVE" "A2UI planner" fi # Start agent backend. # NOTE: `set -e` does not fire on backgrounded processes — if uvicorn crashes # immediately, the shell still proceeds to start Next.js. We capture PIDs and # probe them explicitly after `wait -n` so operators can tell which process # died with which exit code. # # `python -u` + `awk ... fflush()` below: unbuffered stdout at the interpreter # level + line-flushed awk prefixer so uvicorn request lines and tracebacks # reach Railway's log stream immediately rather than block-buffered in pipe # buffers. python -u -m uvicorn agent_server:app --host 0.0.0.0 --port 8000 &> >(awk '{print "[agent] " $0; fflush()}') & AGENT_PID=$! # Start Next.js frontend (PORT defaults to 10000 — Railway / local compose # override as needed). npx next start --port ${PORT:-10000} &> >(awk '{print "[nextjs] " $0; fflush()}') & NEXT_PID=$! # Watchdog: Railway deploys of showcase packages have been observed to hit a # silent agent hang — the Python process stays alive (so `wait -n` never # fires and the container never restarts) but stops responding on :8000. # Poll the agent's /health endpoint every 30s; after 3 consecutive failures # (~90s of unreachable agent), kill the agent process so `wait -n` returns # and Railway restarts the container. Generalized from # showcase/integrations/crewai-crews/entrypoint.sh (PRs #4114 + #4115). ( FAILS=0 while sleep 30; do if ! kill -0 "$AGENT_PID" 2>/dev/null; then break fi if curl -fsS --max-time 5 http://127.0.0.1:8000/health > /dev/null 2>&1; then FAILS=0 else FAILS=$((FAILS + 1)) echo "[watchdog] Agent health probe failed (count=$FAILS)" >&2 if [ $FAILS -ge 3 ]; then echo "[watchdog] Agent unresponsive for ~90s — killing PID $AGENT_PID to trigger container restart" >&2 kill -9 "$AGENT_PID" 2>/dev/null || true break fi fi done ) & WATCHDOG_PID=$! echo "[entrypoint] Watchdog started (PID: $WATCHDOG_PID)" >&2 # Wait for either process to exit; then figure out which one. # set +e for wait -n; exit code captured explicitly into EXIT_CODE. The # subsequent `kill -0` / `echo` calls run without errexit — that is fine # because the final `exit "$EXIT_CODE"` uses the captured value, so the # container exits with the dying child's status regardless. # # errexit (set -e) is INTENTIONALLY left off for the remainder of the # script: the diagnostic and cleanup blocks below use `kill`, `kill -0`, # and `wait` calls whose non-zero returns are expected (dead process, # already-reaped child, EPERM). Re-enabling errexit would cause the shell # to abort before the survivor-termination grace window runs. set +e # ``wait -n "$AGENT_PID" "$NEXT_PID"`` (positional pid list) narrows the wait # to just the two children we explicitly spawned, so an unrelated reaped # subshell (e.g. process-substitution helper) cannot spuriously satisfy # ``wait -n`` with its exit code. Requires bash 5.1+ — the base image ships # bash 5.2. For symmetry with the starter entrypoint. wait -n "$AGENT_PID" "$NEXT_PID" EXIT_CODE=$? # Interpret common POSIX / shell exit codes for operators reading the log # stream. These are the codes likely to show up from uvicorn/next.js/Node # under typical container-orchestration conditions (OOM kill, SIGTERM, # missing binary, uncaught-fatal, Ctrl-C during `docker run -it`, etc.). case "$EXIT_CODE" in 0) EXIT_MEANING="clean exit (unexpected for a long-running server)" ;; 1) EXIT_MEANING="generic error (uncaught exception / non-zero program exit)" ;; 2) EXIT_MEANING="misuse of shell builtin / bad CLI args" ;; 126) EXIT_MEANING="command invoked but not executable (permission denied)" ;; 127) EXIT_MEANING="command not found (missing binary / bad PATH)" ;; 130) EXIT_MEANING="SIGINT (Ctrl-C / interactive interrupt)" ;; 137) EXIT_MEANING="SIGKILL (likely OOM-killed or force-stopped)" ;; 139) EXIT_MEANING="SIGSEGV (segmentation fault — native crash)" ;; 143) EXIT_MEANING="SIGTERM (orderly shutdown from platform)" ;; 255) EXIT_MEANING="exit -1 / catastrophic program failure" ;; *) EXIT_MEANING="(no common interpretation)" ;; esac SURVIVOR_PID="" if ! kill -0 "$AGENT_PID" 2>/dev/null; then echo "[entrypoint] agent backend (uvicorn, pid=$AGENT_PID) exited with code $EXIT_CODE — $EXIT_MEANING" >&2 if kill -0 "$NEXT_PID" 2>/dev/null; then SURVIVOR_PID="$NEXT_PID" fi elif ! kill -0 "$NEXT_PID" 2>/dev/null; then echo "[entrypoint] next.js frontend (pid=$NEXT_PID) exited with code $EXIT_CODE — $EXIT_MEANING" >&2 if kill -0 "$AGENT_PID" 2>/dev/null; then SURVIVOR_PID="$AGENT_PID" fi else # `wait -n` returned but both pids still resolve. This most commonly # happens when a child was reaped before we ran `kill -0` (race), which # means one IS actually dead — we just can't tell which. Escalate to # ERROR + exit 1 so this path does not silently mask the real death. # Under no-children-dead the shell would never reach this block. echo "[entrypoint] ERROR: wait -n returned exit=$EXIT_CODE ($EXIT_MEANING) but both agent ($AGENT_PID) and next.js ($NEXT_PID) appear alive — treating as fatal race; the actual dying child's status has already been reaped" >&2 exit 1 fi # Terminate the surviving sibling with a bounded grace window so it shuts # down cleanly rather than getting SIGKILL'd by the container runtime at # teardown. if [ -n "$SURVIVOR_PID" ]; then echo "[entrypoint] Terminating surviving sibling (pid=${SURVIVOR_PID}) to avoid orphan-reparent" >&2 # Capture kill failure: if `kill` returns non-zero AND the process is # still alive, that's a real signal-delivery failure (e.g. EPERM) — # surface it rather than letting `2>/dev/null` swallow the diagnosis. if ! kill "$SURVIVOR_PID" 2>/dev/null; then if kill -0 "$SURVIVOR_PID" 2>/dev/null; then echo "[entrypoint] WARN: kill(SIGTERM) failed for survivor pid=${SURVIVOR_PID} but process is still alive — signal delivery refused (EPERM?)" >&2 fi fi for _ in 1 2 3 4 5; do kill -0 "$SURVIVOR_PID" 2>/dev/null || break sleep 1 done if kill -0 "$SURVIVOR_PID" 2>/dev/null; then echo "[entrypoint] Survivor (pid=${SURVIVOR_PID}) did not exit within 5s — sending SIGKILL" >&2 if ! kill -9 "$SURVIVOR_PID" 2>/dev/null; then if kill -0 "$SURVIVOR_PID" 2>/dev/null; then echo "[entrypoint] WARN: kill(SIGKILL) failed for survivor pid=${SURVIVOR_PID} but process is still alive — cannot force-terminate (EPERM?)" >&2 fi fi fi wait "$SURVIVOR_PID" 2>/dev/null || true fi exit "$EXIT_CODE"