This is an automated email from the ASF dual-hosted git repository.

wenjin272 pushed a commit to branch main
in repository https://gitbox.apache.org/repos/asf/flink-agents.git

commit 432e90d875b7174d84d06ba8eebbd2bf0144ae51
Author: WenjinXie <[email protected]>
AuthorDate: Thu Aug 6 22:46:40 2026 +0800

    [e2e] Stabilize standalone example tests
    
    Generated-by: Codex CLI 0.144.5
---
 .github/workflows/nightly-e2e.yml                  |  15 +-
 .../test-scripts/test_submit_examples_to_flink.sh  | 562 +++++++++++++++++----
 .../agents/examples/agents/ParallelChatAgent.java  |   3 +-
 .../quickstart/agents/parallel_chat_agent.py       |   9 +-
 .../flink_agents/examples/rag/agents/__init__.py   |  17 +
 .../{rag_agent_example.py => agents/rag_agent.py}  |  64 +--
 .../examples/rag/knowledge_base_setup.py           |  13 +-
 .../flink_agents/examples/rag/rag_agent_example.py | 142 +-----
 .../examples/rag/tests/test_rag_agent.py           |  46 ++
 tools/test/unit/verify_example_job.bats            | 466 +++++++++++++++++
 10 files changed, 1037 insertions(+), 300 deletions(-)

diff --git a/.github/workflows/nightly-e2e.yml 
b/.github/workflows/nightly-e2e.yml
index 1adf197e..9863dfd3 100644
--- a/.github/workflows/nightly-e2e.yml
+++ b/.github/workflows/nightly-e2e.yml
@@ -23,9 +23,9 @@ on:
   workflow_dispatch:
     inputs:
       flink-version:
-        description: 'Flink version to test (e.g., 2.2.0, 2.1.1)'
+        description: 'Flink version to test (e.g., 2.3.0, 2.2.1)'
         required: false
-        default: '2.2.0'
+        default: '2.3.0'
         type: string
   pull_request:
     branches: [main]
@@ -47,14 +47,14 @@ jobs:
     strategy:
       fail-fast: false
       matrix:
-        flink-version: ['2.2.0']
+        flink-version: ['2.3.0']
     steps:
       - uses: actions/checkout@v4
       - name: Install java
         uses: actions/setup-java@v4
         with:
           java-version: '11'
-          distribution: 'adopt'
+          distribution: 'temurin'
       - name: Install python
         uses: actions/setup-python@v4
         with:
@@ -66,9 +66,10 @@ jobs:
       - name: Run submit-examples E2E test
         env:
           FLINK_VERSION: ${{ github.event.inputs.flink-version || 
matrix.flink-version }}
-          # Use a lightweight chat model on GitHub-hosted runners (CPU only,
-          # ~7GB RAM). The script aliases this to the names examples hardcode.
-          OLLAMA_CHAT_MODEL: 'qwen3:0.6b'
+          # Use a compact model that reliably follows the examples' structured
+          # output prompts. The script aliases it to the names examples 
hardcode.
+          OLLAMA_CHAT_MODEL: 'qwen3.5:4b'
+          OLLAMA_CONTEXT_LENGTH: '2048'
           OLLAMA_EMBED_MODEL: 'nomic-embed-text'
         run: bash e2e-test/test-scripts/test_submit_examples_to_flink.sh
       - name: Upload Flink logs on failure
diff --git a/e2e-test/test-scripts/test_submit_examples_to_flink.sh 
b/e2e-test/test-scripts/test_submit_examples_to_flink.sh
index 68cadb04..7b479c42 100755
--- a/e2e-test/test-scripts/test_submit_examples_to_flink.sh
+++ b/e2e-test/test-scripts/test_submit_examples_to_flink.sh
@@ -17,12 +17,14 @@
 # limitations under the License.
 #
 #
-# Submits all Java/Python examples to a local Flink standalone cluster, runs
-# them against a local Ollama server and waits for each job to FINISH.
-# Examples are auto-discovered from the examples directories.
+# Submits all Java/Python examples to a local Flink standalone cluster and runs
+# them against a local Ollama server. Detached jobs pass after they either
+# finish or remain RUNNING for a bounded stability period. Examples are
+# auto-discovered from the examples directories.
 #
-# Env: FLINK_VERSION (default 2.2.0), FLINK_HOME (reuse existing install),
-#      VERBOSE=1 (set -x).
+# Env: FLINK_VERSION (default 2.3.0), FLINK_HOME (reuse existing install),
+#      JOB_STARTUP_TIMEOUT (default 60), JOB_STABLE_RUNNING_SECONDS (default 
20),
+#      JOB_STATUS_POLL_INTERVAL (default 2), VERBOSE=1 (set -x).
 
 set -euo pipefail
 
@@ -50,10 +52,14 @@ SCRIPT_DIR="$(cd "$(dirname "${BASH_SOURCE[0]}")" && pwd)"
 ROOT_DIR="$(cd "$SCRIPT_DIR/../.."; pwd)"
 log_info "Project root: $ROOT_DIR"
 
-FLINK_VERSION="${FLINK_VERSION:-2.2.0}"
+FLINK_VERSION="${FLINK_VERSION:-2.3.0}"
 FLINK_MAJOR_MINOR="${FLINK_VERSION%.*}"
 SUBMIT_TIMEOUT="${SUBMIT_TIMEOUT:-180}"
 JOB_FINISH_TIMEOUT="${JOB_FINISH_TIMEOUT:-300}"
+JOB_STARTUP_TIMEOUT="${JOB_STARTUP_TIMEOUT:-60}"
+JOB_STABLE_RUNNING_SECONDS="${JOB_STABLE_RUNNING_SECONDS:-20}"
+JOB_STATUS_POLL_INTERVAL="${JOB_STATUS_POLL_INTERVAL:-2}"
+SLOT_RELEASE_TIMEOUT="${SLOT_RELEASE_TIMEOUT:-30}"
 
 # Models to pull for Ollama. Override these in CI to use lighter models.
 # The script aliases the pulled chat model to the names hardcoded in
@@ -63,12 +69,13 @@ OLLAMA_EMBED_MODEL="${OLLAMA_EMBED_MODEL:-nomic-embed-text}"
 
 # Model names referenced (hardcoded) by example code. The pulled chat model
 # is aliased to each of these via `ollama cp` when it differs.
-OLLAMA_CHAT_MODEL_ALIASES=("qwen3:8b" "qwen3.5:9b")
+OLLAMA_CHAT_MODEL_ALIASES=("qwen3:1.7b" "qwen3:8b" "qwen3.5:9b")
 
 # Bash 3 (default on macOS) lacks associative arrays.
 RESULT_NAMES=()
 RESULT_STATES=()
 SUBMITTED_JOB_IDS=()
+SUBMISSION_PIDS=()
 OLLAMA_PID=""
 
 cleanup() {
@@ -82,6 +89,18 @@ cleanup() {
             "$FLINK_HOME/bin/flink" cancel "$jid" >/dev/null 2>&1 || true
         done
 
+        # Attached Java submissions are kept alive while their jobs are
+        # checked so example shutdown hooks do not remove input files before
+        # TaskManagers open them. Stop any client still left after cancelling
+        # its job.
+        for pid in "${SUBMISSION_PIDS[@]:-}"; do
+            [[ -n "$pid" ]] || continue
+            if kill -0 "$pid" 2>/dev/null; then
+                kill "$pid" 2>/dev/null || true
+            fi
+            wait "$pid" 2>/dev/null || true
+        done
+
         if [[ -x "$FLINK_HOME/bin/stop-cluster.sh" ]]; then
             log_info "Stopping Flink cluster"
             "$FLINK_HOME/bin/stop-cluster.sh" >/dev/null 2>&1 || true
@@ -100,7 +119,6 @@ cleanup() {
     print_summary
     exit "$exit_code"
 }
-trap cleanup EXIT
 
 print_summary() {
     log_section "Test summary"
@@ -126,7 +144,7 @@ print_summary() {
     printf "\nTotal: %d   Passed: %d   Failed: %d\n" "$total" "$passed" 
"$failed"
 
     if (( failed > 0 )); then
-        log_error "$failed example(s) failed to submit"
+        log_error "$failed example(s) failed validation"
         exit 1
     fi
 }
@@ -136,42 +154,95 @@ record_result() {
     RESULT_STATES+=("$2")
 }
 
+install_flink_distribution() {
+    local install_dir="$1"
+
+    # Reuse install.sh's download, archive validation, and extraction logic,
+    # but stop before it installs a released Flink Agents JAR. This E2E test
+    # must run the artifacts built from the current checkout, which may support
+    # a newer Flink version than the latest published Flink Agents release.
+    FLINK_AGENTS_INSTALL_SH_NO_RUN=1 \
+        FLINK_VERSION="$FLINK_VERSION" \
+        INSTALL_FLINK=Yes \
+        INSTALL_DIR="$install_dir" \
+        NO_PROMPT=1 \
+        bash -c '
+            source "$1"
+            plan_flink
+            install_flink_if_needed
+        ' _ "$ROOT_DIR/tools/install.sh"
+}
+
+prepare_python_venv() {
+    if [[ -f "$VENV_DIR/pyvenv.cfg" && -x "$VENV_DIR/bin/python" ]]; then
+        log_info "Reusing Python venv: $VENV_DIR"
+        return
+    fi
+    if [[ -e "$VENV_DIR" ]] \
+        && [[ ! -d "$VENV_DIR" \
+            || -n "$(find "$VENV_DIR" -mindepth 1 -print -quit 2>/dev/null)" 
]]; then
+        log_error "VENV_DIR is not an empty directory or valid venv: $VENV_DIR"
+        return 1
+    fi
+
+    local python_bin="${PYTHON_BIN:-python3}"
+    log_info "Creating Python venv: $VENV_DIR"
+    "$python_bin" -m venv "$VENV_DIR"
+}
+
 install_flink() {
     log_section "Step 1: install Flink standalone (version $FLINK_VERSION)"
 
+    # Anchor VENV_DIR to the repo so both fresh and reused Flink installations
+    # use the same Python environment.
+    export VENV_DIR="${VENV_DIR:-$ROOT_DIR/.flink-agents-env}"
+
     if [[ -n "${FLINK_HOME:-}" && -x "$FLINK_HOME/bin/flink" ]]; then
         log_info "Reusing existing FLINK_HOME: $FLINK_HOME"
         export FLINK_HOME
-        return 0
+    else
+        local install_dir="${INSTALL_DIR:-$HOME/.local/flink}"
+        log_info "Installing the Flink distribution with tools/install.sh 
helpers"
+        install_flink_distribution "$install_dir"
+        export FLINK_HOME="${install_dir}/flink-${FLINK_VERSION}"
+
+        if [[ ! -x "$FLINK_HOME/bin/flink" ]]; then
+            log_error "Flink installation not found at expected path: 
$FLINK_HOME"
+            return 1
+        fi
+        log_ok "Flink installed at: $FLINK_HOME"
     fi
 
-    # Anchor VENV_DIR to the repo so we can find it after install.sh exits.
-    export VENV_DIR="${VENV_DIR:-$ROOT_DIR/.flink-agents-env}"
+    local pyflink_jar="$FLINK_HOME/opt/flink-python-${FLINK_VERSION}.jar"
+    if [[ ! -f "$pyflink_jar" ]]; then
+        log_error "PyFlink JAR not found in Flink distribution: $pyflink_jar"
+        return 1
+    fi
+    cp "$pyflink_jar" "$FLINK_HOME/lib/"
+    prepare_python_venv
+}
 
-    log_info "Running tools/install.sh --non-interactive --install-flink 
--enable-pyflink"
-    FLINK_VERSION="$FLINK_VERSION" bash "$ROOT_DIR/tools/install.sh" \
-        --non-interactive --install-flink --enable-pyflink
+install_built_python_package() {
+    local wheel
+    wheel=$(find "$ROOT_DIR/python/dist" -maxdepth 1 -name '*.whl' | head -n 1)
+    if [[ -z "$wheel" ]]; then
+        log_error "Python wheel not found after build"
+        return 1
+    fi
 
-    local install_dir="${INSTALL_DIR:-$HOME/.local/flink}"
-    export FLINK_HOME="${install_dir}/flink-${FLINK_VERSION}"
+    log_info "Installing current wheel and apache-flink==$FLINK_VERSION into 
$VENV_DIR"
+    "$VENV_DIR/bin/python" -m pip install --quiet \
+        "$wheel" "apache-flink==$FLINK_VERSION"
 
-    if [[ ! -x "$FLINK_HOME/bin/flink" ]]; then
-        log_error "Flink installation not found at expected path: $FLINK_HOME"
-        exit 1
+    if ! "$VENV_DIR/bin/python" -c 'import flink_agents, pyflink' >/dev/null 
2>&1; then
+        log_error "Current Flink Agents wheel or PyFlink is not importable 
from: $VENV_DIR/bin/python"
+        return 1
     fi
-    log_ok "Flink installed at: $FLINK_HOME"
 
-    # `flink run -py` shells out to a Python interpreter that must have
-    # pyflink importable. Activate the venv install.sh provisioned and
-    # point PYFLINK_CLIENT_EXECUTABLE at it.
-    if [[ ! -x "$VENV_DIR/bin/python" ]]; then
-        log_error "Expected Python venv not found at: $VENV_DIR"
-        exit 1
-    fi
     # shellcheck disable=SC1091
     source "$VENV_DIR/bin/activate"
     export PYFLINK_CLIENT_EXECUTABLE="$VENV_DIR/bin/python"
-    log_ok "Activated PyFlink venv: $VENV_DIR"
+    log_ok "Activated current Flink Agents and PyFlink venv: $VENV_DIR"
 }
 
 build_project() {
@@ -180,6 +251,7 @@ build_project() {
         cd "$ROOT_DIR"
         SKIP_SPOTLESS_CHECK=true bash tools/build.sh
     )
+    install_built_python_package
     log_ok "Build completed"
 }
 
@@ -272,6 +344,77 @@ extract_job_id() {
     grep -Eo 'JobID [0-9a-f]{32}' "$1" | tail -n 1 | awk '{print $2}'
 }
 
+# Starts a Java example in attached mode but leaves the Flink client in the
+# background. Keeping the client alive preserves temporary resources created
+# by the example until startup validation and cancellation have completed.
+ATTACHED_SUBMISSION_PID=""
+ATTACHED_SUBMISSION_JOB_ID=""
+remove_submission_pid() {
+    local target_pid="$1"
+    local remaining_pids=()
+    local pid
+
+    for pid in "${SUBMISSION_PIDS[@]:-}"; do
+        if [[ -n "$pid" && "$pid" != "$target_pid" ]]; then
+            remaining_pids+=("$pid")
+        fi
+    done
+    SUBMISSION_PIDS=("${remaining_pids[@]}")
+}
+
+start_attached_java_submission() {
+    local class_name="$1" out="$2"
+    local elapsed=0
+
+    ATTACHED_SUBMISSION_PID=""
+    ATTACHED_SUBMISSION_JOB_ID=""
+
+    timeout "$SUBMIT_TIMEOUT" "$FLINK_HOME/bin/flink" run \
+            -c "$class_name" \
+            "$EXAMPLES_JAR" >"$out" 2>&1 &
+    ATTACHED_SUBMISSION_PID=$!
+    SUBMISSION_PIDS+=("$ATTACHED_SUBMISSION_PID")
+
+    while (( elapsed < SUBMIT_TIMEOUT )); do
+        ATTACHED_SUBMISSION_JOB_ID=$(extract_job_id "$out") || true
+        if [[ -n "$ATTACHED_SUBMISSION_JOB_ID" ]]; then
+            return 0
+        fi
+
+        if ! kill -0 "$ATTACHED_SUBMISSION_PID" 2>/dev/null; then
+            wait "$ATTACHED_SUBMISSION_PID" 2>/dev/null || true
+            remove_submission_pid "$ATTACHED_SUBMISSION_PID"
+            return 1
+        fi
+
+        sleep 1
+        elapsed=$((elapsed + 1))
+    done
+
+    log_error "Timed out waiting for Java example job id"
+    kill "$ATTACHED_SUBMISSION_PID" 2>/dev/null || true
+    wait "$ATTACHED_SUBMISSION_PID" 2>/dev/null || true
+    remove_submission_pid "$ATTACHED_SUBMISSION_PID"
+    return 1
+}
+
+wait_for_attached_submission_client() {
+    local pid="$1" name="$2"
+    local elapsed=0
+
+    while kill -0 "$pid" 2>/dev/null && (( elapsed < 30 )); do
+        sleep 1
+        elapsed=$((elapsed + 1))
+    done
+
+    if kill -0 "$pid" 2>/dev/null; then
+        log_warn "$name: attached Flink client did not exit after cleanup; 
stopping it"
+        kill "$pid" 2>/dev/null || true
+    fi
+    wait "$pid" 2>/dev/null || true
+    remove_submission_pid "$pid"
+}
+
 # ---------------------------------------------------------------------------
 # Check Flink logs for unexpected errors/exceptions after a job completes.
 # Inspired by Apache Flink's e2e test-scripts/common.sh approach.
@@ -327,33 +470,208 @@ check_logs_for_errors() {
     return 0
 }
 
-wait_for_job_finish() {
-    local job_id="$1" name="$2" timeout_sec="${3:-$JOB_FINISH_TIMEOUT}"
+extract_job_state() {
+    printf '%s\n' "$1" \
+        | grep -Eo '"state"[[:space:]]*:[[:space:]]*"[^"]+"' \
+        | sed -n '1{s/.*"\([^"]*\)"$/\1/p;}'
+}
+
+# Sets JOB_STATE to the current state returned by Flink's REST API.
+JOB_STATE=""
+get_job_state() {
+    local job_id="$1"
+    local response
+    JOB_STATE=""
+
+    response=$(curl -fsS "http://localhost:8081/jobs/$job_id"; 2>/dev/null) || 
return 1
+    JOB_STATE=$(extract_job_state "$response") || return 1
+    [[ -n "$JOB_STATE" ]]
+}
+
+extract_available_slots() {
+    printf '%s\n' "$1" \
+        | grep -Eo '"slots-available"[[:space:]]*:[[:space:]]*[0-9]+' \
+        | sed -n '1{s/.*:[[:space:]]*//p;}'
+}
+
+# Sets AVAILABLE_SLOTS to the number reported by Flink's overview endpoint.
+AVAILABLE_SLOTS=""
+get_available_slots() {
+    local response
+    AVAILABLE_SLOTS=""
+
+    response=$(curl -fsS "http://localhost:8081/overview"; 2>/dev/null) || 
return 1
+    AVAILABLE_SLOTS=$(extract_available_slots "$response") || return 1
+    [[ -n "$AVAILABLE_SLOTS" ]]
+}
+
+wait_for_cluster_slot_available() {
+    local name="$1" timeout_sec="${2:-$SLOT_RELEASE_TIMEOUT}"
     local elapsed=0
+
     while (( elapsed < timeout_sec )); do
-        local status
-        status=$("$FLINK_HOME/bin/flink" list -a 2>/dev/null | grep "$job_id" 
|| true)
-        if echo "$status" | grep -q "FINISHED"; then
-            log_ok "$name reached FINISHED status"
-            sleep 2  # allow log flush
-            if check_logs_for_errors "$name"; then
-                log_ok "$name completed successfully (no unexpected errors in 
logs)"
-            else
-                log_warn "$name finished but with warnings in logs (non-fatal)"
-            fi
+        if get_available_slots && (( AVAILABLE_SLOTS > 0 )); then
             return 0
-        elif echo "$status" | grep -q "FAILED\|CANCELED"; then
-            log_error "$name ended with unexpected status"
-            check_logs_for_errors "$name" || true
-            return 1
         fi
-        sleep 5
-        elapsed=$((elapsed + 5))
+        sleep "$JOB_STATUS_POLL_INTERVAL"
+        elapsed=$((elapsed + JOB_STATUS_POLL_INTERVAL))
     done
-    log_error "$name timed out after ${timeout_sec}s"
+
+    log_warn "$name: cluster slot was not released within ${timeout_sec}s"
     return 1
 }
 
+# Python operators may take substantially longer than the bounded health check
+# to tear down after cancellation. Restart the single-node standalone cluster
+# instead of letting one slow cleanup starve every subsequently submitted job.
+restart_cluster_after_cleanup() {
+    local name="$1"
+
+    log_warn "$name: restarting the standalone cluster to recover its slot"
+    if ! "$FLINK_HOME/bin/stop-cluster.sh" >/dev/null 2>&1; then
+        log_warn "$name: stop-cluster.sh reported an error; attempting startup 
anyway"
+    fi
+    if ! "$FLINK_HOME/bin/start-cluster.sh" >/dev/null 2>&1; then
+        log_error "$name: failed to restart the standalone cluster"
+        return 1
+    fi
+    if ! wait_for_cluster_slot_available "$name after cluster restart" 60; then
+        log_error "$name: cluster restart did not recover an available slot"
+        return 1
+    fi
+
+    log_ok "$name: standalone cluster restarted and slot recovered"
+}
+
+ensure_cluster_slot_available() {
+    local name="$1"
+
+    if wait_for_cluster_slot_available "$name"; then
+        return 0
+    fi
+    restart_cluster_after_cleanup "$name"
+}
+
+# A detached example is considered healthy when it finishes successfully or
+# remains continuously RUNNING for a short period. This catches startup
+# failures without waiting for slow inference or unbounded sources to finish.
+wait_for_job_healthy() {
+    local job_id="$1" name="$2" timeout_sec="${3:-$JOB_STARTUP_TIMEOUT}"
+    local stable_sec="${4:-$JOB_STABLE_RUNNING_SECONDS}"
+    local elapsed=0
+    local running_for=0
+    local previous_state=""
+
+    while (( elapsed < timeout_sec )); do
+        local status
+        if get_job_state "$job_id"; then
+            status="$JOB_STATE"
+        else
+            status="UNAVAILABLE"
+        fi
+
+        if [[ "$status" != "$previous_state" ]]; then
+            log_info "$name state: $status"
+        fi
+
+        case "$status" in
+            FINISHED)
+                log_ok "$name reached FINISHED status"
+                return 0
+                ;;
+            RUNNING)
+                if [[ "$previous_state" == "RUNNING" ]]; then
+                    running_for=$((running_for + JOB_STATUS_POLL_INTERVAL))
+                else
+                    running_for=0
+                fi
+                if (( running_for >= stable_sec )); then
+                    log_ok "$name remained RUNNING for ${stable_sec}s"
+                    return 0
+                fi
+                ;;
+            FAILING|FAILED|CANCELLING|CANCELED|SUSPENDED)
+                log_error "$name entered unexpected state: $status"
+                check_logs_for_errors "$name" || true
+                return 1
+                ;;
+            *)
+                running_for=0
+                ;;
+        esac
+
+        previous_state="$status"
+        sleep "$JOB_STATUS_POLL_INTERVAL"
+        elapsed=$((elapsed + JOB_STATUS_POLL_INTERVAL))
+    done
+
+    log_error "$name did not become stably RUNNING or FINISHED within 
${timeout_sec}s"
+    return 1
+}
+
+cancel_job_after_check() {
+    local job_id="$1" name="$2"
+
+    if get_job_state "$job_id"; then
+        case "$JOB_STATE" in
+            FINISHED|FAILED|CANCELED|SUSPENDED)
+                ensure_cluster_slot_available "$name"
+                return $?
+                ;;
+        esac
+    fi
+
+    log_info "$name: cancelling job after startup verification"
+    if timeout 30 "$FLINK_HOME/bin/flink" cancel "$job_id" >/dev/null 2>&1; 
then
+        if ensure_cluster_slot_available "$name"; then
+            log_ok "$name cancelled and cluster slot released"
+            return 0
+        fi
+        return 1
+    fi
+
+    # The job may have reached a terminal state while the cancel request raced
+    # with completion. Treat that as successful cleanup.
+    if get_job_state "$job_id"; then
+        case "$JOB_STATE" in
+            FINISHED|FAILED|CANCELED|SUSPENDED)
+                ensure_cluster_slot_available "$name"
+                return $?
+                ;;
+        esac
+    fi
+
+    # A fast FINISHED job can disappear from the active-job endpoint before
+    # the failed cancel request returns. In this sequential single-slot test,
+    # an available slot is sufficient evidence that cleanup has completed.
+    if ensure_cluster_slot_available "$name"; then
+        log_ok "$name no longer occupies the cluster slot"
+        return 0
+    fi
+
+    log_error "$name: failed to cancel job $job_id"
+    return 1
+}
+
+verify_submitted_job() {
+    local job_id="$1" name="$2"
+    local healthy=0
+    local cleaned_up=0
+
+    if wait_for_job_healthy "$job_id" "$name"; then
+        healthy=1
+    fi
+    if cancel_job_after_check "$job_id" "$name"; then
+        cleaned_up=1
+    fi
+
+    if (( healthy == 1 && cleaned_up == 1 )); then
+        record_result "$name" "PASS"
+    else
+        record_result "$name" "FAIL"
+    fi
+}
+
 submit_java_example() {
     local class_name="$1"
     local label="java:${class_name##*.}"
@@ -362,10 +680,7 @@ submit_java_example() {
     local out
     out=$(mktemp)
     local rc=0
-    timeout "$SUBMIT_TIMEOUT" "$FLINK_HOME/bin/flink" run \
-            --detached \
-            -c "$class_name" \
-            "$EXAMPLES_JAR" >"$out" 2>&1 || rc=$?
+    start_attached_java_submission "$class_name" "$out" || rc=$?
     cat "$out"
 
     if (( rc != 0 )); then
@@ -375,26 +690,19 @@ submit_java_example() {
         return 0
     fi
 
-    local job_id
-    job_id=$(extract_job_id "$out") || true
-    rm -f "$out"
+    local job_id="$ATTACHED_SUBMISSION_JOB_ID"
     if [[ -z "$job_id" ]]; then
         log_error "$label: could not extract job id"
         record_result "$label" "FAIL"
+        rm -f "$out"
         return 0
     fi
     SUBMITTED_JOB_IDS+=("$job_id")
 
-    # Submission succeeded — record PASS immediately
     log_ok "$label submitted successfully (JobID: $job_id)"
-    record_result "$label" "PASS"
-
-    # Optionally wait and report final status (informational only, does not 
affect PASS/FAIL)
-    if wait_for_job_finish "$job_id" "$label" "$JOB_FINISH_TIMEOUT"; then
-        log_ok "$label job reached FINISHED status"
-    else
-        log_warn "$label job did not reach FINISHED (expected with lightweight 
CI model)"
-    fi
+    verify_submitted_job "$job_id" "$label"
+    wait_for_attached_submission_client "$ATTACHED_SUBMISSION_PID" "$label"
+    rm -f "$out"
 }
 
 submit_python_example() {
@@ -433,16 +741,8 @@ submit_python_example() {
     fi
     SUBMITTED_JOB_IDS+=("$job_id")
 
-    # Submission succeeded — record PASS
     log_ok "$label submitted successfully (JobID: $job_id)"
-    record_result "$label" "PASS"
-
-    # Informational: wait and report final status
-    if wait_for_job_finish "$job_id" "$label" "$JOB_FINISH_TIMEOUT"; then
-        log_ok "$label job reached FINISHED status"
-    else
-        log_warn "$label job did not reach FINISHED (expected with lightweight 
CI model)"
-    fi
+    verify_submitted_job "$job_id" "$label"
 }
 
 # RAG examples run end-to-end (not as detached jobs) via flink run -py.
@@ -479,16 +779,27 @@ submit_python_rag_example() {
 discover_java_examples() {
     # Find all example classes by scanning the jar's manifest or known package
     # Convention: all classes directly under org.apache.flink.agents.examples 
that end with "Example"
-    local classes=()
-    while IFS= read -r class; do
-        classes+=("$class")
-    done < <(jar -tf "$EXAMPLES_JAR" \
+    local jar_entries
+    if ! jar_entries=$(jar -tf "$EXAMPLES_JAR"); then
+        log_error "Failed to inspect examples JAR: $EXAMPLES_JAR"
+        return 1
+    fi
+
+    local discovered_classes
+    discovered_classes=$(printf '%s\n' "$jar_entries" \
         | grep '^org/apache/flink/agents/examples/[^/]*Example\.class$' \
-        | sed 's|/|.|g; s|\.class$||')
+        | sed 's|/|.|g; s|\.class$||' || true)
+
+    local classes=()
+    if [[ -n "$discovered_classes" ]]; then
+        while IFS= read -r class; do
+            classes+=("$class")
+        done <<< "$discovered_classes"
+    fi
 
     if [[ ${#classes[@]} -eq 0 ]]; then
         log_error "No Java example classes found in $EXAMPLES_JAR"
-        exit 1
+        return 1
     fi
     log_info "Discovered ${#classes[@]} Java example(s): ${classes[*]}"
     printf '%s\n' "${classes[@]}"
@@ -496,14 +807,29 @@ discover_java_examples() {
 
 discover_python_quickstart_examples() {
     local dir="$ROOT_DIR/python/flink_agents/examples/quickstart"
+    if [[ ! -d "$dir" ]]; then
+        log_error "Python quickstart examples directory not found: $dir"
+        return 1
+    fi
+
+    local discovered_scripts
+    if ! discovered_scripts=$(
+        find "$dir" -maxdepth 1 -name '*_example.py' -type f | sort
+    ); then
+        log_error "Failed to discover Python quickstart examples in $dir"
+        return 1
+    fi
+
     local scripts=()
-    while IFS= read -r f; do
-        scripts+=("$f")
-    done < <(find "$dir" -maxdepth 1 -name '*_example.py' -type f | sort)
+    if [[ -n "$discovered_scripts" ]]; then
+        while IFS= read -r f; do
+            scripts+=("$f")
+        done <<< "$discovered_scripts"
+    fi
 
     if [[ ${#scripts[@]} -eq 0 ]]; then
         log_error "No Python quickstart examples found in $dir"
-        exit 1
+        return 1
     fi
     log_info "Discovered ${#scripts[@]} Python quickstart example(s)"
     printf '%s\n' "${scripts[@]}"
@@ -513,16 +839,27 @@ discover_python_rag_examples() {
     local dir="$ROOT_DIR/python/flink_agents/examples/rag"
     if [[ ! -d "$dir" ]]; then
         log_info "No RAG examples directory found, skipping"
-        return
+        return 0
     fi
+
+    local discovered_scripts
+    if ! discovered_scripts=$(
+        find "$dir" -maxdepth 1 -name '*_example.py' -type f | sort
+    ); then
+        log_error "Failed to discover Python RAG examples in $dir"
+        return 1
+    fi
+
     local scripts=()
-    while IFS= read -r f; do
-        scripts+=("$f")
-    done < <(find "$dir" -maxdepth 1 -name '*_example.py' -type f | sort)
+    if [[ -n "$discovered_scripts" ]]; then
+        while IFS= read -r f; do
+            scripts+=("$f")
+        done <<< "$discovered_scripts"
+    fi
 
     if [[ ${#scripts[@]} -eq 0 ]]; then
         log_info "No RAG examples found in $dir"
-        return
+        return 0
     fi
     log_info "Discovered ${#scripts[@]} Python RAG example(s)"
     printf '%s\n' "${scripts[@]}"
@@ -531,11 +868,14 @@ discover_python_rag_examples() {
 setup_rag_knowledge_base() {
     local 
setup_script="$ROOT_DIR/python/flink_agents/examples/rag/knowledge_base_setup.py"
     if [[ ! -f "$setup_script" ]]; then
-        log_warn "RAG knowledge_base_setup.py not found, skipping RAG setup"
+        log_error "RAG knowledge_base_setup.py not found: $setup_script"
         return 1
     fi
     log_info "Setting up RAG knowledge base"
-    python "$setup_script" || { log_error "RAG knowledge base setup failed"; 
return 1; }
+    if ! python "$setup_script"; then
+        log_error "RAG knowledge base setup failed"
+        return 1
+    fi
     log_ok "RAG knowledge base ready"
 }
 
@@ -549,25 +889,53 @@ main() {
 
     # Auto-discover and submit Java examples
     log_section "Step 7: submit Java examples"
+    local java_examples
+    if ! java_examples=$(discover_java_examples); then
+        log_error "Java example discovery failed"
+        return 1
+    fi
+    if [[ -z "$java_examples" ]]; then
+        log_error "Java example discovery returned no examples"
+        return 1
+    fi
     while IFS= read -r class; do
         submit_java_example "$class"
-    done < <(discover_java_examples)
+    done <<< "$java_examples"
 
     # Auto-discover and submit Python quickstart examples
     log_section "Step 8: submit Python quickstart examples"
+    local python_quickstart_examples
+    if ! python_quickstart_examples=$(discover_python_quickstart_examples); 
then
+        log_error "Python quickstart example discovery failed"
+        return 1
+    fi
+    if [[ -z "$python_quickstart_examples" ]]; then
+        log_error "Python quickstart example discovery returned no examples"
+        return 1
+    fi
     while IFS= read -r script; do
         submit_python_example "$script"
-    done < <(discover_python_quickstart_examples)
+    done <<< "$python_quickstart_examples"
 
     # Auto-discover and run Python RAG examples (these run end-to-end, not as 
detached jobs)
     log_section "Step 9: run Python RAG examples"
-    if setup_rag_knowledge_base; then
+    local python_rag_examples
+    if ! python_rag_examples=$(discover_python_rag_examples); then
+        log_error "Python RAG example discovery failed"
+        return 1
+    fi
+    if [[ -n "$python_rag_examples" ]]; then
+        if ! setup_rag_knowledge_base; then
+            log_error "Cannot run Python RAG examples because setup failed"
+            return 1
+        fi
         while IFS= read -r script; do
             submit_python_rag_example "$script"
-        done < <(discover_python_rag_examples)
-    else
-        log_warn "Skipping RAG examples due to setup failure"
+        done <<< "$python_rag_examples"
     fi
 }
 
-main "$@"
+if [[ "${BASH_SOURCE[0]}" == "$0" ]]; then
+    trap cleanup EXIT
+    main "$@"
+fi
diff --git 
a/examples/src/main/java/org/apache/flink/agents/examples/agents/ParallelChatAgent.java
 
b/examples/src/main/java/org/apache/flink/agents/examples/agents/ParallelChatAgent.java
index c2984e44..2286b6f9 100644
--- 
a/examples/src/main/java/org/apache/flink/agents/examples/agents/ParallelChatAgent.java
+++ 
b/examples/src/main/java/org/apache/flink/agents/examples/agents/ParallelChatAgent.java
@@ -94,7 +94,8 @@ public class ParallelChatAgent extends Agent {
         return 
ResourceDescriptor.Builder.newBuilder(ResourceName.ChatModel.OLLAMA_SETUP)
                 .addInitialArgument("connection", "ollamaChatModelConnection")
                 .addInitialArgument("model", OLLAMA_MODEL)
-                .addInitialArgument("extract_reasoning", true)
+                .addInitialArgument("think", false)
+                .addInitialArgument("extract_reasoning", false)
                 .build();
     }
 
diff --git 
a/python/flink_agents/examples/quickstart/agents/parallel_chat_agent.py 
b/python/flink_agents/examples/quickstart/agents/parallel_chat_agent.py
index 6d98cb2a..f2e20652 100644
--- a/python/flink_agents/examples/quickstart/agents/parallel_chat_agent.py
+++ b/python/flink_agents/examples/quickstart/agents/parallel_chat_agent.py
@@ -114,7 +114,8 @@ class ParallelChatAgent(Agent):
             clazz=ResourceName.ChatModel.OLLAMA_SETUP,
             connection="ollama_server",
             model=OLLAMA_MODEL,
-            extract_reasoning=True,
+            think=False,
+            extract_reasoning=False,
         )
 
     @action(InputEvent.EVENT_TYPE)
@@ -155,7 +156,11 @@ class ParallelChatAgent(Agent):
         response_event = ChatResponseEvent.from_event(event)
         parsed = response_event.response.extra_args[STRUCTURED_OUTPUT]
         if isinstance(parsed, dict):
-            parsed = SummaryResponse(**parsed) if "summary" in parsed else 
AspectResponse(**parsed)
+            parsed = (
+                SummaryResponse(**parsed)
+                if "summary" in parsed
+                else AspectResponse(**parsed)
+            )
         if isinstance(parsed, SummaryResponse):
             ctx.send_event(
                 _build_output_event(
diff --git a/python/flink_agents/examples/rag/agents/__init__.py 
b/python/flink_agents/examples/rag/agents/__init__.py
new file mode 100644
index 00000000..65b48d4d
--- /dev/null
+++ b/python/flink_agents/examples/rag/agents/__init__.py
@@ -0,0 +1,17 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
diff --git a/python/flink_agents/examples/rag/rag_agent_example.py 
b/python/flink_agents/examples/rag/agents/rag_agent.py
similarity index 74%
copy from python/flink_agents/examples/rag/rag_agent_example.py
copy to python/flink_agents/examples/rag/agents/rag_agent.py
index 00e688b6..461080d1 100644
--- a/python/flink_agents/examples/rag/rag_agent_example.py
+++ b/python/flink_agents/examples/rag/agents/rag_agent.py
@@ -16,8 +16,8 @@
 # limitations under the License.
 
################################################################################
 import os
-
-from pyflink.datastream import StreamExecutionEnvironment
+import tempfile
+from pathlib import Path
 
 from flink_agents.api.agents.agent import Agent
 from flink_agents.api.chat_message import ChatMessage, MessageRole
@@ -35,7 +35,6 @@ from flink_agents.api.events.context_retrieval_event import (
 )
 from flink_agents.api.events.event import Event, InputEvent, OutputEvent
 from flink_agents.api.events.event_type import EventType
-from flink_agents.api.execution_environment import AgentsExecutionEnvironment
 from flink_agents.api.prompts.prompt import Prompt
 from flink_agents.api.resource import (
     ResourceDescriptor,
@@ -43,10 +42,13 @@ from flink_agents.api.resource import (
     ResourceType,
 )
 from flink_agents.api.runner_context import RunnerContext
-from flink_agents.examples.rag.knowledge_base_setup import 
populate_knowledge_base
 
 OLLAMA_CHAT_MODEL = os.environ.get("OLLAMA_CHAT_MODEL", "qwen3:8b")
 OLLAMA_EMBEDDING_MODEL = os.environ.get("OLLAMA_EMBEDDING_MODEL", 
"nomic-embed-text")
+CHROMA_PERSIST_DIRECTORY = os.environ.get(
+    "CHROMA_PERSIST_DIRECTORY",
+    str(Path(tempfile.gettempdir()) / "flink-agents-rag-chroma"),
+)
 
 
 class MyRAGAgent(Agent):
@@ -94,6 +96,7 @@ Please provide a helpful answer based on the context 
provided."""
             clazz=ResourceName.VectorStore.CHROMA_VECTOR_STORE,
             embedding_model="text_embedder",
             collection="example_knowledge_base",
+            persist_directory=CHROMA_PERSIST_DIRECTORY,
         )
 
     @chat_model_setup
@@ -104,6 +107,7 @@ Please provide a helpful answer based on the context 
provided."""
             clazz=ResourceName.ChatModel.OLLAMA_SETUP,
             connection="ollama_chat_connection",
             model=OLLAMA_CHAT_MODEL,
+            think=False,
         )
 
     @action(EventType.InputEvent)
@@ -121,20 +125,16 @@ Please provide a helpful answer based on the context 
provided."""
 
     @action(EventType.ContextRetrievalResponseEvent)
     @staticmethod
-    def process_retrieved_context(
-        event: Event, ctx: RunnerContext
-    ) -> None:
+    def process_retrieved_context(event: Event, ctx: RunnerContext) -> None:
         """Process retrieved context and create enhanced chat request."""
         retrieval_event = ContextRetrievalResponseEvent.from_event(event)
         user_query = retrieval_event.query
         retrieved_docs = retrieval_event.documents
 
-        # Create context from retrieved documents
         context_text = "\n\n".join(
             [f"{i + 1}. {doc.content}" for i, doc in enumerate(retrieved_docs)]
         )
 
-        # Get prompt resource and format it
         prompt_resource = ctx.get_resource(
             "context_enhanced_prompt", ResourceType.PROMPT
         )
@@ -142,7 +142,6 @@ Please provide a helpful answer based on the context 
provided."""
             context=context_text, user_query=user_query
         )
 
-        # Send chat request with enhanced prompt
         ctx.send_event(
             ChatRequestEvent(
                 model="chat_model",
@@ -157,48 +156,3 @@ Please provide a helpful answer based on the context 
provided."""
         chat_response = ChatResponseEvent.from_event(event)
         if chat_response.response and chat_response.response.content:
             ctx.send_event(OutputEvent(output=chat_response.response.content))
-
-
-if __name__ == "__main__":
-    print("Starting RAG Example Agent...")
-
-    # Populate vector store with sample documents
-    populate_knowledge_base()
-
-    agent = MyRAGAgent()
-
-    # Set up the Flink streaming environment and the Agents execution 
environment.
-    env = StreamExecutionEnvironment.get_execution_environment()
-    agents_env = AgentsExecutionEnvironment.get_execution_environment(env)
-
-    # Setup Ollama embedding and chat model connections
-    agents_env.add_resource(
-        "ollama_embedding_connection",
-        ResourceType.EMBEDDING_MODEL_CONNECTION,
-        
ResourceDescriptor(clazz=ResourceName.EmbeddingModel.OLLAMA_CONNECTION),
-    )
-    agents_env.add_resource(
-        "ollama_chat_connection",
-        ResourceType.EMBEDDING_MODEL,
-        ResourceDescriptor(clazz=ResourceName.ChatModel.OLLAMA_CONNECTION),
-    )
-
-    # A small stream of example queries, keyed by the query text.
-    query_stream = env.from_collection(
-        [
-            "What is Apache Flink?",
-            "What is Apache Flink Agents?",
-            "What is Python?",
-        ],
-    )
-
-    # Use the RAG agent to answer each query and print the responses to stdout.
-    response_stream = (
-        agents_env.from_datastream(input=query_stream, key_selector=lambda x: 
x)
-        .apply(agent)
-        .to_datastream()
-    )
-    response_stream.print()
-
-    # Execute the Flink pipeline.
-    agents_env.execute("RAG Agent Example Job")
diff --git a/python/flink_agents/examples/rag/knowledge_base_setup.py 
b/python/flink_agents/examples/rag/knowledge_base_setup.py
index de3f6f05..ace827ec 100644
--- a/python/flink_agents/examples/rag/knowledge_base_setup.py
+++ b/python/flink_agents/examples/rag/knowledge_base_setup.py
@@ -17,6 +17,8 @@
 
#################################################################################
 
 import os
+import tempfile
+from pathlib import Path
 
 import chromadb
 
@@ -25,6 +27,10 @@ from 
flink_agents.integrations.embedding_models.local.ollama_embedding_model imp
 )
 
 OLLAMA_EMBEDDING_MODEL = os.environ.get("OLLAMA_EMBEDDING_MODEL", 
"nomic-embed-text")
+CHROMA_PERSIST_DIRECTORY = os.environ.get(
+    "CHROMA_PERSIST_DIRECTORY",
+    str(Path(tempfile.gettempdir()) / "flink-agents-rag-chroma"),
+)
 
 """Utility for populating ChromaDB with sample knowledge documents for RAG 
examples."""
 
@@ -35,7 +41,10 @@ def populate_knowledge_base() -> None:
 
     # Create connections directly
     embedding_connection = OllamaEmbeddingModelConnection()
-    chroma_client = chromadb.EphemeralClient()
+    # The Flink client and TaskManager run in separate processes, so an
+    # in-memory Chroma client cannot share the populated collection with the
+    # agent. Persist it on the local filesystem used by the standalone cluster.
+    chroma_client = chromadb.PersistentClient(path=CHROMA_PERSIST_DIRECTORY)
 
     # Get collection (create if doesn't exist)
     collection_name = "example_knowledge_base"
@@ -76,7 +85,7 @@ def populate_knowledge_base() -> None:
     }
 
     # Add documents to ChromaDB
-    collection.add(**test_data)
+    collection.upsert(**test_data)
 
     print(
         f"Knowledge base setup complete! Added {len(documents)} documents to 
ChromaDB."
diff --git a/python/flink_agents/examples/rag/rag_agent_example.py 
b/python/flink_agents/examples/rag/rag_agent_example.py
index 00e688b6..2a90d669 100644
--- a/python/flink_agents/examples/rag/rag_agent_example.py
+++ b/python/flink_agents/examples/rag/rag_agent_example.py
@@ -15,150 +15,17 @@
 #  See the License for the specific language governing permissions and
 # limitations under the License.
 
################################################################################
-import os
-
 from pyflink.datastream import StreamExecutionEnvironment
 
-from flink_agents.api.agents.agent import Agent
-from flink_agents.api.chat_message import ChatMessage, MessageRole
-from flink_agents.api.decorators import (
-    action,
-    chat_model_setup,
-    embedding_model_setup,
-    prompt,
-    vector_store,
-)
-from flink_agents.api.events.chat_event import ChatRequestEvent, 
ChatResponseEvent
-from flink_agents.api.events.context_retrieval_event import (
-    ContextRetrievalRequestEvent,
-    ContextRetrievalResponseEvent,
-)
-from flink_agents.api.events.event import Event, InputEvent, OutputEvent
-from flink_agents.api.events.event_type import EventType
 from flink_agents.api.execution_environment import AgentsExecutionEnvironment
-from flink_agents.api.prompts.prompt import Prompt
 from flink_agents.api.resource import (
     ResourceDescriptor,
     ResourceName,
     ResourceType,
 )
-from flink_agents.api.runner_context import RunnerContext
+from flink_agents.examples.rag.agents.rag_agent import MyRAGAgent
 from flink_agents.examples.rag.knowledge_base_setup import 
populate_knowledge_base
 
-OLLAMA_CHAT_MODEL = os.environ.get("OLLAMA_CHAT_MODEL", "qwen3:8b")
-OLLAMA_EMBEDDING_MODEL = os.environ.get("OLLAMA_EMBEDDING_MODEL", 
"nomic-embed-text")
-
-
-class MyRAGAgent(Agent):
-    """Example RAG agent demonstrating context retrieval.
-
-    This RAG agent shows how to:
-    1. Receive a user query
-    2. Retrieve relevant context from a vector store using semantic search
-    3. Augment the user query with retrieved context
-    4. Generate enhanced responses using the chat model
-
-    This is a basic example demonstrating RAG workflow with Ollama and 
ChromaDB.
-    """
-
-    @prompt
-    @staticmethod
-    def context_enhanced_prompt() -> Prompt:
-        """Prompt template for enhancing user queries with retrieved 
context."""
-        template = """Based on the following context, please answer the user's 
question.
-
-Context:
-{context}
-
-User Question:
-{user_query}
-
-Please provide a helpful answer based on the context provided."""
-        return Prompt.from_text(template)
-
-    @embedding_model_setup
-    @staticmethod
-    def text_embedder() -> ResourceDescriptor:
-        """Embedding model setup for generating text embeddings."""
-        return ResourceDescriptor(
-            clazz=ResourceName.EmbeddingModel.OLLAMA_SETUP,
-            connection="ollama_embedding_connection",
-            model=OLLAMA_EMBEDDING_MODEL,
-        )
-
-    @vector_store
-    @staticmethod
-    def knowledge_base() -> ResourceDescriptor:
-        """Vector store setup for knowledge base."""
-        return ResourceDescriptor(
-            clazz=ResourceName.VectorStore.CHROMA_VECTOR_STORE,
-            embedding_model="text_embedder",
-            collection="example_knowledge_base",
-        )
-
-    @chat_model_setup
-    @staticmethod
-    def chat_model() -> ResourceDescriptor:
-        """Chat model setup for generating responses."""
-        return ResourceDescriptor(
-            clazz=ResourceName.ChatModel.OLLAMA_SETUP,
-            connection="ollama_chat_connection",
-            model=OLLAMA_CHAT_MODEL,
-        )
-
-    @action(EventType.InputEvent)
-    @staticmethod
-    def process_input(event: Event, ctx: RunnerContext) -> None:
-        """Process user input and retrieve relevant context."""
-        user_query = str(InputEvent.from_event(event).input)
-        ctx.send_event(
-            ContextRetrievalRequestEvent(
-                query=user_query,
-                vector_store="knowledge_base",
-                max_results=3,
-            )
-        )
-
-    @action(EventType.ContextRetrievalResponseEvent)
-    @staticmethod
-    def process_retrieved_context(
-        event: Event, ctx: RunnerContext
-    ) -> None:
-        """Process retrieved context and create enhanced chat request."""
-        retrieval_event = ContextRetrievalResponseEvent.from_event(event)
-        user_query = retrieval_event.query
-        retrieved_docs = retrieval_event.documents
-
-        # Create context from retrieved documents
-        context_text = "\n\n".join(
-            [f"{i + 1}. {doc.content}" for i, doc in enumerate(retrieved_docs)]
-        )
-
-        # Get prompt resource and format it
-        prompt_resource = ctx.get_resource(
-            "context_enhanced_prompt", ResourceType.PROMPT
-        )
-        enhanced_prompt = prompt_resource.format_string(
-            context=context_text, user_query=user_query
-        )
-
-        # Send chat request with enhanced prompt
-        ctx.send_event(
-            ChatRequestEvent(
-                model="chat_model",
-                messages=[ChatMessage(role=MessageRole.USER, 
content=enhanced_prompt)],
-            )
-        )
-
-    @action(EventType.ChatResponseEvent)
-    @staticmethod
-    def process_chat_response(event: Event, ctx: RunnerContext) -> None:
-        """Process chat model response and generate output."""
-        chat_response = ChatResponseEvent.from_event(event)
-        if chat_response.response and chat_response.response.content:
-            ctx.send_event(OutputEvent(output=chat_response.response.content))
-
-
 if __name__ == "__main__":
     print("Starting RAG Example Agent...")
 
@@ -179,8 +46,11 @@ if __name__ == "__main__":
     )
     agents_env.add_resource(
         "ollama_chat_connection",
-        ResourceType.EMBEDDING_MODEL,
-        ResourceDescriptor(clazz=ResourceName.ChatModel.OLLAMA_CONNECTION),
+        ResourceType.CHAT_MODEL_CONNECTION,
+        ResourceDescriptor(
+            clazz=ResourceName.ChatModel.OLLAMA_CONNECTION,
+            request_timeout=240.0,
+        ),
     )
 
     # A small stream of example queries, keyed by the query text.
diff --git a/python/flink_agents/examples/rag/tests/test_rag_agent.py 
b/python/flink_agents/examples/rag/tests/test_rag_agent.py
new file mode 100644
index 00000000..db75f3af
--- /dev/null
+++ b/python/flink_agents/examples/rag/tests/test_rag_agent.py
@@ -0,0 +1,46 @@
+################################################################################
+#  Licensed to the Apache Software Foundation (ASF) under one
+#  or more contributor license agreements.  See the NOTICE file
+#  distributed with this work for additional information
+#  regarding copyright ownership.  The ASF licenses this file
+#  to you under the Apache License, Version 2.0 (the
+#  "License"); you may not use this file except in compliance
+#  with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+#  Unless required by applicable law or agreed to in writing, software
+#  distributed under the License is distributed on an "AS IS" BASIS,
+#  WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+#  See the License for the specific language governing permissions and
+# limitations under the License.
+################################################################################
+
+from flink_agents.api.function import PythonFunction
+from flink_agents.examples.rag.agents.rag_agent import (
+    CHROMA_PERSIST_DIRECTORY as AGENT_CHROMA_DIRECTORY,
+)
+from flink_agents.examples.rag.agents.rag_agent import MyRAGAgent
+from flink_agents.examples.rag.knowledge_base_setup import (
+    CHROMA_PERSIST_DIRECTORY as SETUP_CHROMA_DIRECTORY,
+)
+
+
+def test_rag_agent_actions_resolve_from_an_importable_module() -> None:
+    actions = [
+        MyRAGAgent.process_input,
+        MyRAGAgent.process_retrieved_context,
+        MyRAGAgent.process_chat_response,
+    ]
+
+    for action in actions:
+        descriptor = PythonFunction.from_callable(action)
+        assert descriptor.module == 
"flink_agents.examples.rag.agents.rag_agent"
+        assert descriptor.as_callable() is action
+
+
+def test_rag_vector_store_uses_cross_process_persistence() -> None:
+    vector_store = MyRAGAgent.knowledge_base()
+
+    assert AGENT_CHROMA_DIRECTORY == SETUP_CHROMA_DIRECTORY
+    assert vector_store.arguments["persist_directory"] == 
SETUP_CHROMA_DIRECTORY
diff --git a/tools/test/unit/verify_example_job.bats 
b/tools/test/unit/verify_example_job.bats
new file mode 100644
index 00000000..2ed65a77
--- /dev/null
+++ b/tools/test/unit/verify_example_job.bats
@@ -0,0 +1,466 @@
+#!/usr/bin/env bats
+#
+# Licensed to the Apache Software Foundation (ASF) under one
+# or more contributor license agreements.  See the NOTICE file
+# distributed with this work for additional information
+# regarding copyright ownership.  The ASF licenses this file
+# to you under the Apache License, Version 2.0 (the
+# "License"); you may not use this file except in compliance
+# with the License.  You may obtain a copy of the License at
+#
+#      http://www.apache.org/licenses/LICENSE-2.0
+#
+# Unless required by applicable law or agreed to in writing, software
+# distributed under the License is distributed on an "AS IS" BASIS,
+# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
+# See the License for the specific language governing permissions and
+# limitations under the License.
+
+setup() {
+    source 
"${BATS_TEST_DIRNAME}/../../../e2e-test/test-scripts/test_submit_examples_to_flink.sh"
+    JOB_STATUS_POLL_INTERVAL=1
+    TEST_STATE_INDEX=0
+    TEST_STATES=()
+
+    sleep() {
+        :
+    }
+
+    get_job_state() {
+        local last_index=$((${#TEST_STATES[@]} - 1))
+        local index="$TEST_STATE_INDEX"
+        if (( index > last_index )); then
+            index="$last_index"
+        fi
+        JOB_STATE="${TEST_STATES[$index]}"
+        TEST_STATE_INDEX=$((TEST_STATE_INDEX + 1))
+        return 0
+    }
+}
+
+set_job_states() {
+    TEST_STATES=("$@")
+    TEST_STATE_INDEX=0
+}
+
+stub_main_dependencies() {
+    install_flink() { :; }
+    build_project() { :; }
+    locate_examples_jar() { :; }
+    stage_dist_jars() { :; }
+    start_ollama() { :; }
+    start_cluster() { :; }
+    discover_java_examples() { printf 'org.example.JavaExample\n'; }
+    discover_python_quickstart_examples() { printf '/tmp/python_example.py\n'; 
}
+    discover_python_rag_examples() { printf '/tmp/rag_example.py\n'; }
+    setup_rag_knowledge_base() { :; }
+    submit_java_example() { :; }
+    submit_python_example() { :; }
+    submit_python_rag_example() { :; }
+}
+
+create_fake_executable() {
+    local path="$1"
+    mkdir -p "$(dirname "$path")"
+    printf '#!/usr/bin/env bash\nexit 0\n' > "$path"
+    chmod +x "$path"
+}
+
+@test "job state parser extracts the current state" {
+    run extract_job_state '{"jid":"job-id","name":"example","state":"RUNNING"}'
+
+    [ "$status" -eq 0 ]
+    [ "$output" = "RUNNING" ]
+}
+
+@test "job state parser rejects a response without state" {
+    run extract_job_state '{"errors":["job not found"]}'
+
+    [ "$status" -ne 0 ]
+    [ -z "$output" ]
+}
+
+@test "available slot parser extracts the current count" {
+    run extract_available_slots 
'{"taskmanagers":1,"slots-total":1,"slots-available":1}'
+
+    [ "$status" -eq 0 ]
+    [ "$output" = "1" ]
+}
+
+@test "slot release check waits until a slot becomes available" {
+    local slot_index=0
+    get_available_slots() {
+        local slots=(0 0 1)
+        AVAILABLE_SLOTS="${slots[$slot_index]}"
+        if (( slot_index < 2 )); then
+            slot_index=$((slot_index + 1))
+        fi
+        return 0
+    }
+
+    run wait_for_cluster_slot_available "example" 4
+
+    [ "$status" -eq 0 ]
+}
+
+@test "job health check accepts FINISHED immediately" {
+    set_job_states FINISHED
+
+    run wait_for_job_healthy "job-id" "example" 5 2
+
+    [ "$status" -eq 0 ]
+    [[ "$output" == *"reached FINISHED status"* ]]
+}
+
+@test "job health check accepts a continuously RUNNING job" {
+    set_job_states CREATED RUNNING RUNNING RUNNING
+
+    run wait_for_job_healthy "job-id" "example" 5 2
+
+    [ "$status" -eq 0 ]
+    [[ "$output" == *"remained RUNNING for 2s"* ]]
+}
+
+@test "job health check rejects a failing job" {
+    set_job_states CREATED RUNNING FAILING
+    check_logs_for_errors() {
+        return 0
+    }
+
+    run wait_for_job_healthy "job-id" "example" 5 2
+
+    [ "$status" -ne 0 ]
+    [[ "$output" == *"entered unexpected state: FAILING"* ]]
+}
+
+@test "job health check resets the stability period after a restart" {
+    set_job_states RUNNING RESTARTING RUNNING RUNNING
+
+    run wait_for_job_healthy "job-id" "example" 4 2
+
+    [ "$status" -ne 0 ]
+    [[ "$output" == *"did not become stably RUNNING or FINISHED"* ]]
+}
+
+@test "submitted job is marked failed when health verification fails" {
+    RESULT_NAMES=()
+    RESULT_STATES=()
+    wait_for_job_healthy() {
+        return 1
+    }
+    cancel_job_after_check() {
+        return 0
+    }
+
+    verify_submitted_job "job-id" "example"
+
+    [ "${RESULT_NAMES[0]}" = "example" ]
+    [ "${RESULT_STATES[0]}" = "FAIL" ]
+}
+
+@test "submitted job passes only after health verification and cleanup" {
+    RESULT_NAMES=()
+    RESULT_STATES=()
+    CANCEL_CALLED=0
+    wait_for_job_healthy() {
+        return 0
+    }
+    cancel_job_after_check() {
+        CANCEL_CALLED=1
+        return 0
+    }
+
+    verify_submitted_job "job-id" "example"
+
+    [ "$CANCEL_CALLED" -eq 1 ]
+    [ "${RESULT_NAMES[0]}" = "example" ]
+    [ "${RESULT_STATES[0]}" = "PASS" ]
+}
+
+@test "submitted job is marked failed when cleanup fails" {
+    RESULT_NAMES=()
+    RESULT_STATES=()
+    wait_for_job_healthy() {
+        return 0
+    }
+    cancel_job_after_check() {
+        return 1
+    }
+
+    verify_submitted_job "job-id" "example"
+
+    [ "${RESULT_STATES[0]}" = "FAIL" ]
+}
+
+@test "cancel race succeeds when a finished job has already released its slot" 
{
+    FLINK_HOME="$BATS_TEST_TMPDIR/flink"
+    local state_calls=0
+    get_job_state() {
+        state_calls=$((state_calls + 1))
+        if (( state_calls == 1 )); then
+            JOB_STATE="RUNNING"
+            return 0
+        fi
+        return 1
+    }
+    timeout() {
+        return 1
+    }
+    ensure_cluster_slot_available() {
+        return 0
+    }
+
+    run cancel_job_after_check "job-id" "example"
+
+    [ "$status" -eq 0 ]
+    [[ "$output" == *"no longer occupies the cluster slot"* ]]
+}
+
+@test "slow slot cleanup restarts the standalone cluster" {
+    local restarted=0
+    wait_for_cluster_slot_available() {
+        return 1
+    }
+    restart_cluster_after_cleanup() {
+        restarted=1
+        return 0
+    }
+
+    ensure_cluster_slot_available "example"
+
+    [ "$restarted" -eq 1 ]
+}
+
+@test "cleanup fails when neither cancellation nor cluster recovery succeeds" {
+    FLINK_HOME="$BATS_TEST_TMPDIR/flink"
+    get_job_state() {
+        JOB_STATE="RUNNING"
+        return 0
+    }
+    timeout() {
+        return 1
+    }
+    ensure_cluster_slot_available() {
+        return 1
+    }
+
+    run cancel_job_after_check "job-id" "example"
+
+    [ "$status" -ne 0 ]
+    [[ "$output" == *"failed to cancel job job-id"* ]]
+}
+
+@test "CI chat model aliases cover every hardcoded quickstart model" {
+    [[ " ${OLLAMA_CHAT_MODEL_ALIASES[*]} " == *" qwen3:1.7b "* ]]
+    [[ " ${OLLAMA_CHAT_MODEL_ALIASES[*]} " == *" qwen3:8b "* ]]
+    [[ " ${OLLAMA_CHAT_MODEL_ALIASES[*]} " == *" qwen3.5:9b "* ]]
+}
+
+@test "Java submission keeps the attached client alive during validation" {
+    unset -f sleep
+
+    local fake_flink_home="$BATS_TEST_TMPDIR/flink"
+    local client_marker="$BATS_TEST_TMPDIR/client-running"
+    local client_release="$BATS_TEST_TMPDIR/client-release"
+    mkdir -p "$fake_flink_home/bin"
+    printf '%s\n' \
+        '#!/usr/bin/env bash' \
+        'touch "$ATTACHED_CLIENT_MARKER"' \
+        'printf "Job has been submitted with JobID 
0123456789abcdef0123456789abcdef\n"' \
+        'while [[ ! -f "$ATTACHED_CLIENT_RELEASE" ]]; do sleep 0.1; done' \
+        > "$fake_flink_home/bin/flink"
+    chmod +x "$fake_flink_home/bin/flink"
+
+    FLINK_HOME="$fake_flink_home"
+    EXAMPLES_JAR="$BATS_TEST_TMPDIR/examples.jar"
+    ATTACHED_CLIENT_MARKER="$client_marker"
+    ATTACHED_CLIENT_RELEASE="$client_release"
+    export ATTACHED_CLIENT_MARKER ATTACHED_CLIENT_RELEASE
+    SUBMIT_TIMEOUT=5
+    RESULT_NAMES=()
+    RESULT_STATES=()
+    SUBMITTED_JOB_IDS=()
+    SUBMISSION_PIDS=()
+
+    timeout() {
+        shift
+        "$@"
+    }
+    wait_for_job_healthy() {
+        [[ -f "$client_marker" ]]
+    }
+    cancel_job_after_check() {
+        touch "$client_release"
+    }
+
+    submit_java_example "org.example.JavaExample"
+
+    [ "${RESULT_NAMES[0]}" = "java:JavaExample" ]
+    [ "${RESULT_STATES[0]}" = "PASS" ]
+    [ "${SUBMITTED_JOB_IDS[0]}" = "0123456789abcdef0123456789abcdef" ]
+    [ "${#SUBMISSION_PIDS[@]}" -eq 0 ]
+}
+
+@test "main propagates Java example discovery failure" {
+    stub_main_dependencies
+    discover_java_examples() {
+        return 1
+    }
+
+    run main
+
+    [ "$status" -ne 0 ]
+    [[ "$output" == *"Java example discovery failed"* ]]
+}
+
+@test "main propagates Python quickstart discovery failure" {
+    stub_main_dependencies
+    discover_python_quickstart_examples() {
+        return 1
+    }
+
+    run main
+
+    [ "$status" -ne 0 ]
+    [[ "$output" == *"Python quickstart example discovery failed"* ]]
+}
+
+@test "main propagates RAG example discovery failure" {
+    stub_main_dependencies
+    discover_python_rag_examples() {
+        return 1
+    }
+
+    run main
+
+    [ "$status" -ne 0 ]
+    [[ "$output" == *"Python RAG example discovery failed"* ]]
+}
+
+@test "main propagates RAG knowledge base setup failure" {
+    stub_main_dependencies
+    setup_rag_knowledge_base() {
+        return 1
+    }
+
+    run main
+
+    [ "$status" -ne 0 ]
+    [[ "$output" == *"Cannot run Python RAG examples because setup failed"* ]]
+}
+
+@test "main skips RAG setup when no RAG examples exist" {
+    stub_main_dependencies
+    discover_python_rag_examples() {
+        return 0
+    }
+    setup_rag_knowledge_base() {
+        return 1
+    }
+
+    run main
+
+    [ "$status" -eq 0 ]
+}
+
+@test "Flink bootstrap sources installer helpers without installing released 
Agents" {
+    ROOT_DIR="$BATS_TEST_TMPDIR/root"
+    BOOTSTRAP_CALLS="$BATS_TEST_TMPDIR/bootstrap-calls"
+    export BOOTSTRAP_CALLS
+    mkdir -p "$ROOT_DIR/tools"
+    printf '%s\n' \
+        'plan_flink() {' \
+        '  printf "plan:%s:%s:%s\\n" "$FLINK_VERSION" "$INSTALL_FLINK" 
"$INSTALL_DIR" >> "$BOOTSTRAP_CALLS"' \
+        '}' \
+        'install_flink_if_needed() {' \
+        '  printf "install-flink\\n" >> "$BOOTSTRAP_CALLS"' \
+        '}' \
+        'install_flink_agents_jar() {' \
+        '  printf "install-agents\\n" >> "$BOOTSTRAP_CALLS"' \
+        '  return 1' \
+        '}' \
+        > "$ROOT_DIR/tools/install.sh"
+
+    install_flink_distribution "$BATS_TEST_TMPDIR/install"
+
+    [ "$(sed -n '1p' "$BOOTSTRAP_CALLS")" = \
+        "plan:$FLINK_VERSION:Yes:$BATS_TEST_TMPDIR/install" ]
+    [ "$(sed -n '2p' "$BOOTSTRAP_CALLS")" = "install-flink" ]
+    [ "$(wc -l < "$BOOTSTRAP_CALLS" | tr -d ' ')" -eq 2 ]
+}
+
+@test "reused FLINK_HOME prepares PyFlink without installing released Agents 
artifacts" {
+    local fake_flink_home="$BATS_TEST_TMPDIR/flink"
+    local fake_venv="$BATS_TEST_TMPDIR/venv"
+    create_fake_executable "$fake_flink_home/bin/flink"
+    mkdir -p "$fake_flink_home/lib" "$fake_flink_home/opt"
+    : > "$fake_flink_home/opt/flink-python-${FLINK_VERSION}.jar"
+
+    FLINK_HOME="$fake_flink_home"
+    VENV_DIR="$fake_venv"
+    PREPARE_VENV_CALLED=0
+    INSTALL_DISTRIBUTION_CALLED=0
+    prepare_python_venv() {
+        PREPARE_VENV_CALLED=1
+    }
+    install_flink_distribution() {
+        INSTALL_DISTRIBUTION_CALLED=1
+        return 1
+    }
+
+    install_flink
+
+    [ "$INSTALL_DISTRIBUTION_CALLED" -eq 0 ]
+    [ "$PREPARE_VENV_CALLED" -eq 1 ]
+    [ -f "$fake_flink_home/lib/flink-python-${FLINK_VERSION}.jar" ]
+}
+
+@test "fresh setup invokes only the Flink distribution installer" {
+    local install_dir="$BATS_TEST_TMPDIR/install"
+    local fake_flink_home="$install_dir/flink-$FLINK_VERSION"
+    local fake_venv="$BATS_TEST_TMPDIR/venv"
+
+    unset FLINK_HOME
+    INSTALL_DIR="$install_dir"
+    VENV_DIR="$fake_venv"
+    INSTALL_DISTRIBUTION_CALLED=0
+    prepare_python_venv() {
+        :
+    }
+    install_flink_distribution() {
+        INSTALL_DISTRIBUTION_CALLED=1
+        [ "$1" = "$install_dir" ]
+        create_fake_executable "$fake_flink_home/bin/flink"
+        mkdir -p "$fake_flink_home/lib" "$fake_flink_home/opt"
+        : > "$fake_flink_home/opt/flink-python-${FLINK_VERSION}.jar"
+    }
+
+    install_flink
+
+    [ "$FLINK_HOME" = "$fake_flink_home" ]
+    [ "$INSTALL_DISTRIBUTION_CALLED" -eq 1 ]
+    [ -f "$fake_flink_home/lib/flink-python-${FLINK_VERSION}.jar" ]
+}
+
+@test "built wheel and matching PyFlink are installed into the E2E venv" {
+    ROOT_DIR="$BATS_TEST_TMPDIR/root"
+    VENV_DIR="$BATS_TEST_TMPDIR/venv"
+    PYTHON_CALLS="$BATS_TEST_TMPDIR/python-calls"
+    export PYTHON_CALLS
+    mkdir -p "$ROOT_DIR/python/dist" "$VENV_DIR/bin"
+    : > "$ROOT_DIR/python/dist/flink_agents-0.3.0-py3-none-any.whl"
+    printf '%s\n' \
+        '#!/usr/bin/env bash' \
+        'printf "%s\\n" "$*" >> "$PYTHON_CALLS"' \
+        'exit 0' \
+        > "$VENV_DIR/bin/python"
+    chmod +x "$VENV_DIR/bin/python"
+    printf 'TEST_VENV_ACTIVATED=1\n' > "$VENV_DIR/bin/activate"
+
+    install_built_python_package
+
+    [ "$TEST_VENV_ACTIVATED" -eq 1 ]
+    [ "$PYFLINK_CLIENT_EXECUTABLE" = "$VENV_DIR/bin/python" ]
+    [[ "$(cat "$PYTHON_CALLS")" == *"-m pip install --quiet"* ]]
+    [[ "$(cat "$PYTHON_CALLS")" == *"apache-flink==$FLINK_VERSION"* ]]
+}

Reply via email to