在 Cloud Run 服務上執行個人代理程式 (咖啡廳經理助理)

1. 簡介

總覽

在本程式碼研究室中,您將建構個人 AI 助理,透過聊天 UI 分析業務資料及執行其他工作。您將使用 Cloud Run 服務代管個人代理程式。

代理程式會使用 Cloud Run 沙箱。Cloud Run 沙箱是原生、安全且超快速的執行階段環境,專為執行不信任的程式碼和代理程式工作負載而建構,啟動時間僅需毫秒。AI 代理可在沙箱中動態編寫、執行及測試程式碼,即時解決複雜的分析問題。

注意:為確保在本機和正式環境中執行時,都能享有流暢的開發體驗,請採取下列措施:

  • 在正式環境 (Cloud Run 沙箱):代理程式會透過專屬沙箱二進位檔 (/usr/local/gcp/bin/sandbox),在隔離的容器化遊樂區中安全地執行程式碼。
  • 在本機 (您的電腦):在本機執行時,應用程式會偵測到沒有實際工作環境沙箱 (IS_LOCAL_MODE = True)。代理程式會直接在本機主機的系統終端機上執行 Python 指令碼和殼層指令。

建構項目:

在本情境中,您在大學城經營咖啡廳,並為畢業季週末做準備。您需要代理程式交叉比對原始銷售點 (POS) 資料和大學的典禮時間表,找出隱藏的營運瓶頸。

代理程式會使用安全沙箱編寫及執行 Python 指令碼,分析飲品複雜度與收銀員人數,建議調整員工配置和庫存。

代理會透過模擬即時通訊 UI,向業主傳送目標庫存和人力配置建議。在取得明確授權前,不會更新試算表,加入咖啡店經理的待辦事項。

課程內容

  • 如何建立 Cloud Run 服務
  • 如何在 Cloud Run 服務上部署 ADK 代理
  • 如何讓代理程式在 Cloud Run 服務的沙箱中執行程式碼
  • 如何使用 WebSocket 建立即時通訊 UI,與背景代理互動

2. 設定和需求條件

設定專案 ID 和區域。

GOOGLE_CLOUD_PROJECT=<YOUR_PROJECT_ID>

REGION=us-west2
gcloud config set project $GOOGLE_CLOUD_PROJECT
gcloud config set run/region $REGION

以下是本程式碼研究室會用到的環境變數。您可以將這些變數儲存在環境檔案中,然後「來源」該檔案。請務必正確設定專案 ID 值,並視需要設定區域。

SA_NAME=coffee-shop-agent-sa
SERVICE_ACCOUNT_ADDRESS=$SA_NAME@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com

啟用本程式碼研究室所需的 API

gcloud services enable --project $GOOGLE_CLOUD_PROJECT \
    run.googleapis.com \
    cloudbuild.googleapis.com \
    artifactregistry.googleapis.com \
    sheets.googleapis.com \
    aiplatform.googleapis.com

3. 建立服務帳戶和試算表

建議為 Cloud Run 服務建立專屬服務帳戶,僅授予必要的角色。

建立 Cloud Run 服務的服務帳戶

gcloud iam service-accounts create $SA_NAME \
  --description="Service account for the Coffee Shop Agent Codelab" \
  --display-name="Coffee Shop Agent SA"

由於代理程式使用 Gemini API (GOOGLE_GENAI_USE_VERTEXAI=1),請授予這個服務帳戶專案的 Agent Platform 使用者角色。

gcloud projects add-iam-policy-binding $GOOGLE_CLOUD_PROJECT \
--member="serviceAccount:$SERVICE_ACCOUNT_ADDRESS" \
--role="roles/aiplatform.user"

如要使用這個服務帳戶在本機執行及測試代理程式,請授予個人 Google Cloud 身分模擬這個服務帳戶的權限:

gcloud iam service-accounts add-iam-policy-binding \
coffee-shop-agent-sa@$GOOGLE_CLOUD_PROJECT.iam.gserviceaccount.com \
--member="user:$(gcloud config get-value account)" \
--role="roles/iam.serviceAccountTokenCreator"

建立試算表

這份試算表代表去年畢業週末的銷售額。

  1. 在 Google 雲端硬碟中建立新的 Google 試算表。
  2. 將下列逗號分隔值 (CSV) 複製到新的 Google 試算表 (例如選取儲存格 A1 並貼上)。
Day,Time,Drip_Coffee,Cold_Brew,Extra_Espresso,Alt_Milk_Oz,Pastries,Cashiers_Working,Wait_Time_Minutes
Saturday,07:00:00,95,15,5,30,80,2,9
Saturday,08:00:00,80,25,10,35,60,2,7
Saturday,09:00:00,30,30,20,40,20,1,4
Saturday,10:00:00,40,130,95,50,25,2,4
Saturday,11:00:00,25,45,25,40,15,1,5
Saturday,12:00:00,30,35,15,45,20,1,3
Saturday,13:00:00,15,20,10,30,10,1,2
Saturday,14:00:00,45,60,30,65,30,2,8
Saturday,15:00:00,20,30,15,35,15,1,3
Saturday,16:00:00,25,35,10,50,20,1,4
Saturday,17:00:00,10,20,5,35,10,1,2
Saturday,18:00:00,20,40,10,190,65,2,12
Saturday,19:00:00,10,15,5,40,15,1,2
Sunday,07:00:00,90,10,5,35,75,2,8
Sunday,08:00:00,75,20,10,40,65,2,6
Sunday,09:00:00,25,25,15,35,15,1,3
Sunday,10:00:00,60,35,15,60,50,2,7
Sunday,11:00:00,20,30,10,35,10,1,3
Sunday,12:00:00,25,40,10,55,20,1,3
Sunday,13:00:00,15,25,5,40,10,1,2
Sunday,14:00:00,30,50,15,180,60,2,11
Sunday,15:00:00,15,25,10,45,15,1,3
Sunday,16:00:00,20,45,20,40,15,1,4
Sunday,17:00:00,10,25,10,30,10,1,2
Sunday,18:00:00,25,145,110,45,20,2,16
Sunday,19:00:00,15,30,15,35,10,1,4
  1. 選取資料後,依序點選 Google 試算表頂端選單中的「資料」>「將文字分隔成不同欄」

確認服務帳戶是否具備試算表的「編輯者權限」。這與授予隊友存取權的方式類似。

  1. 複製新服務帳戶的完整電子郵件地址
echo $SERVICE_ACCOUNT_ADDRESS
  1. 在 Google 試算表中,按一下右上角的「共用」
  2. 貼上服務帳戶電子郵件地址,將權限設為「編輯者」,然後按一下「共用」。(你可以取消勾選「通知」)。
  3. 記錄要傳送給代理商的試算表 ID,例如:https://docs.google.com/spreadsheets/d/<YOUR_SPREADSHEET_ID>/edit?gid=0#gid=0
SPREADSHEET_ID=<THE SPREADHSEET ID FROM ITS URL>

4. 建立 ADK 代理程式

首先,請為程式碼建立目錄。

mkdir coffee-mgr-agent && cd coffee-mgr-agent

建立 requirements.txt 檔案。

fastapi>=0.100.0
uvicorn>=0.22.0
google-adk>=1.27.1
google-auth
google-api-python-client

建立 Dockerfile

FROM python:3.11-slim

ENV PYTHONUNBUFFERED=1

WORKDIR /app

COPY requirements.txt .
RUN pip install --no-cache-dir -r requirements.txt

COPY main.py .

EXPOSE 8080

CMD ["uvicorn", "main:app", "--host", "0.0.0.0", "--port", "8080"]

建立 main.py 檔案

import os
import asyncio
import subprocess
from pathlib import Path
from typing import List
from fastapi import FastAPI, HTTPException, WebSocket, WebSocketDisconnect
from fastapi.responses import HTMLResponse
from pydantic import BaseModel

from google.adk.agents import LlmAgent as Agent
from google.adk.tools import FunctionTool
from google.adk.apps import App
from google.adk import Runner
from google.adk.sessions import InMemorySessionService
from google.genai import types

SANDBOX_CLI = '/usr/local/gcp/bin/sandbox'
IS_LOCAL_MODE = not Path(SANDBOX_CLI).exists()

active_connections: list[WebSocket] = []

SPREADSHEET_ID = os.environ.get("SPREADSHEET_ID")

# And
def run_sandbox_process(args: list[str]):
    cmd = args[2:] if IS_LOCAL_MODE and args[:2] == ['do', '--'] else ([SANDBOX_CLI] + args if not IS_LOCAL_MODE else args)
    return subprocess.run(cmd, capture_output=True, text=True, timeout=10)

def execute_sandbox_command(command: str) -> str:
    """Executes arbitrary POSIX shell/bash commands inside sandbox."""
    mode = "LOCAL" if IS_LOCAL_MODE else "CLOUD RUN SANDBOX"
    print(f"[ADK Sandbox Tool] Starting {mode} shell run...")
    try:
        res = run_sandbox_process(['do', '--', '/bin/sh', '-c', command])
        if res.returncode != 0:
            return f"Execution Failed!\nExit Code: {res.returncode}\nStdout:\n{res.stdout}\nStderr:\n{res.stderr}"
        return res.stdout
    except Exception as err:
        return f"Internal Sandbox Tool Error: {str(err)}"

def get_sheets_service():
    """Initializes and returns the Google Sheets client service."""
    from google.auth import default
    from googleapiclient.discovery import build
    credentials, _ = default(scopes=[
        'https://www.googleapis.com/auth/spreadsheets',
        'https://www.googleapis.com/auth/cloud-platform'
    ])
    return build('sheets', 'v4', credentials=credentials)

def read_spreadsheet_values(spreadsheet_id: str, range_name: str) -> str:
    """Reads a range of cells from a Google Spreadsheet."""
    try:
        service = get_sheets_service()
        result = service.spreadsheets().values().get(spreadsheetId=spreadsheet_id, range=range_name).execute()
        rows = result.get('values', [])
        return str(rows) if rows else "No data found in the specified range."
    except Exception as e:
        return f"Read Error: {str(e)}"

def update_spreadsheet_values(spreadsheet_id: str, range_name: str, values: List[List[str]]) -> str:
    """Updates a range of cells in a Google Spreadsheet with the provided values."""
    try:
        service = get_sheets_service()
        result = service.spreadsheets().values().update(
            spreadsheetId=spreadsheet_id, range=range_name,
            valueInputOption="USER_ENTERED", body={'values': values}).execute()
        return f"Successfully updated {result.get('updatedCells')} cells in {range_name}."
    except Exception as e:
        return f"Write Error: {str(e)}"

def create_spreadsheet_tab(spreadsheet_id: str, tab_name: str) -> str:
    """Creates a new sheet tab in a Google Spreadsheet if it doesn't already exist."""
    try:
        service = get_sheets_service()
        # Check if tab exists
        spreadsheet = service.spreadsheets().get(spreadsheetId=spreadsheet_id).execute()
        for sheet in spreadsheet.get('sheets', []):
            if sheet.get('properties', {}).get('title') == tab_name:
                return f"Sheet tab '{tab_name}' already exists."
        # Create tab
        body = {'requests': [{'addSheet': {'properties': {'title': tab_name}}}]}
        service.spreadsheets().batchUpdate(spreadsheetId=spreadsheet_id, body=body).execute()
        return f"Successfully created new sheet tab '{tab_name}'."
    except Exception as e:
        return f"Error creating sheet tab: {str(e)}"

# ==========================================
# 3. ADK AGENT & RUNNER SETUP
# ==========================================

root_agent = Agent(
    name='secure_coding_assistant',
    description='ADK agent capable of executing shell commands and managing Google Spreadsheets.',
    model=os.environ.get('GEMINI_MODEL', 'gemini-3.1-flash-lite'),
    instruction=(
        f'You are an expert AI Business Analyst for a coffee shop during university graduation weekend.\n'
        f'The Google Spreadsheet ID you are managing is: "{SPREADSHEET_ID}". Use this ID for all sheet operations.\n'
        '1. Comparative Analysis Policy:\n'
        f'   - Ingest historical POS data from the "POS-2025" sheet tab using read_spreadsheet_values with spreadsheet_id="{SPREADSHEET_ID}".\n'
        '   - Receive the current graduation schedule directly from the manager\'s prompt (the manager will paste it and indicate it is the same schedule sequence as last year).\n'
        '   - Write a python3 script via the sandbox tool to:\n'
        '     a. Correlate the 2025 product spikes (Cold Brew, Alt Milk, Extra Espresso) with the specific ceremonies ending at those times.\n'
        '     b. Map those beverage profiles to the pasted schedule (which is the same sequence) to predict exactly when and where the 2026 spikes will occur.\n'
        '     c. Identify expected wait-time bottlenecks in 2026 based on the 2025 wait times for those same profiles.\n'
        '2. Bottleneck Diagnostics (Playbook):\n'
        '   - If a predicted high-volume slot in 2026 is expected to have Wait_Time_Minutes > 10:\n'
        '     - If Cashiers_Working < 2: Recommend scheduling another cashier.\n'
        '     - If Cashiers_Working == 2 and complex items (Cold Brew, Extra Espresso, Alt Milk) spike: Deduce that the bottleneck is barista output, not cashiers. Recommend adding a "Support Barista" role to handle fulfillment.\n'
        '3. Human-in-the-Loop Policy:\n'
        '   - Present your detailed data discoveries, wait-time bottlenecks, and actionable recommendations (stocking and staffing changes) to the manager.\n'
        '   - Highlight only two or three findings for specific ceremonies.\n'
        '   - Frame your recommendations as a clean list of suggested tasks for the manager\'s TODO list.\n'
        '   - Explicitly ask: "Would you like me to add these tasks to your \'TODO-2026\' TODO list?"\n'
        '   - Do NOT modify any spreadsheet data until explicit approval is given.\n'
        '4. Post-Approval Policy:\n'
        f'   - Upon receiving explicit user approval, first verify if the "TODO-2026" sheet tab exists in spreadsheet "{SPREADSHEET_ID}".\n'
        f'   - If the "TODO-2026" sheet tab does not exist, use the tool create_spreadsheet_tab to create it in spreadsheet "{SPREADSHEET_ID}".\n'
        f'   - Once the tab exists, use update_spreadsheet_values to append the approved adjustments as tasks to the "TODO-2026" sheet tab.\n'
        '   - Write the rows under the headers: Task (the actionable job, e.g., "Schedule a Support Barista role for Saturday morning"), Category ("Staffing" or "Inventory"), Ceremony, and Date_Added (today\'s date).\n'
        '   - Always confirm to the user exactly what tasks you have written to their "TODO-2026" TODO list.'
    ),
    tools=[
        FunctionTool(func=execute_sandbox_command),
        FunctionTool(func=read_spreadsheet_values),
        FunctionTool(func=update_spreadsheet_values),
        FunctionTool(func=create_spreadsheet_tab)
    ]
)

adk_app = App(name="secure_sandbox_app", root_agent=root_agent)
runner = Runner(app=adk_app, session_service=InMemorySessionService(), auto_create_session=True)

app = FastAPI(title="Secure ADK Sandbox Assistant")

# ==========================================
# 4. ENDPOINTS & WEBSOCKET ROUTING
# ==========================================

@app.websocket("/ws")
async def websocket_endpoint(websocket: WebSocket):
    await websocket.accept()
    active_connections.append(websocket)
    await websocket.send_text("🔌 System: Connected. Agent is ready...")

    try:
        while True:
            owner_reply = await websocket.receive_text()
            print(f"Owner replied via WS: {owner_reply}")
            await websocket.send_text("_Agent is running tools and thinking..._")
            
            new_message = types.Content(parts=[types.Part(text=owner_reply)])
            events = await asyncio.to_thread(
                runner.run,
                user_id="local_user",
                session_id="local_session",
                new_message=new_message
            )
            
            final_response = "".join(
                part.text
                for event in events
                if event.content and event.content.parts
                for part in event.content.parts
                if part.text
            ) or "Agent completed execution updates without text output."
            
            await websocket.send_text(final_response.strip())
            
    except WebSocketDisconnect:
        active_connections.remove(websocket)

class UserPrompt(BaseModel):
    prompt: str

@app.post("/chat")
def chat_with_agent(payload: UserPrompt):
    """Fallback HTTP POST endpoint if UI is not used."""
    try:
        events = runner.run(
            user_id="local_user",
            session_id="local_session",
            new_message=types.Content(parts=[types.Part(text=payload.prompt)])
        )
        final_response = "".join(
            part.text
            for event in events
            if event.content and event.content.parts
            for part in event.content.parts
            if part.text
        )
        return {"status": "success", "response": final_response.strip()}
    except Exception as e:
        raise HTTPException(status_code=500, detail=f"Agent loop failed: {str(e)}")

@app.get("/", response_class=HTMLResponse)
async def get_chat_ui():
    """Serves the warm coffee-themed chat interface."""
    return """
    <!DOCTYPE html>
    <html>
    <head>
        <title>Coffee Shop Agent</title>
        <!-- Load marked.js for client-side markdown rendering -->
        <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
        <style>
            body { display: flex; height: 100vh; margin: 0; font-family: 'Helvetica Neue', Helvetica, Arial, sans-serif; }
            #sidebar { width: 250px; background: #3E2723; color: #EFEBE9; padding: 20px; }
            #sidebar h2 { font-size: 1.3em; margin-top: 10px; color: #FFF; border-bottom: 1px solid #5D4037; padding-bottom: 10px; }
            #main { flex-grow: 1; display: flex; flex-direction: column; background: #FAF8F6; }
            #chat-history { flex-grow: 1; padding: 20px; overflow-y: auto; background: #F5EFEB; }
            #input-area { padding: 20px; border-top: 1px solid #D7CCC8; background: #FAF8F6; display: flex;}
            input { 
                flex-grow: 1; 
                padding: 12px; 
                border-radius: 6px; 
                border: 1px solid #D7CCC8; 
                margin-right: 10px;
                font-size: 1em;
                background: #FFF;
                transition: border-color 0.2s, box-shadow 0.2s;
            }
            input:focus {
                outline: none;
                border-color: #8D6E63;
                box-shadow: 0 0 0 2px rgba(141, 110, 99, 0.25);
            }
            button { 
                padding: 10px 24px; 
                background: #6D4C41; 
                color: white; 
                border: none; 
                border-radius: 6px; 
                cursor: pointer; 
                font-weight: bold;
                font-size: 1em;
                transition: background-color 0.2s, transform 0.1s;
            }
            button:hover {
                background: #5D4037;
            }
            button:active {
                background: #4E342E;
                transform: scale(0.98);
            }
            .message { margin-bottom: 15px; padding: 12px 16px; border-radius: 8px; max-width: 85%; line-height: 1.5; }
            .user-msg { background: #EFEBE9; color: #3E2723; align-self: flex-end; margin-left: auto; border: 1px solid #D7CCC8;}
            .agent-msg { background: #fff; color: #3E2723; border: 1px solid #E0DCD8; box-shadow: 0 1px 3px rgba(62,39,35,0.06); }
            
            .msg-header { display: flex; justify-content: space-between; align-items: center; margin-bottom: 6px; }
            .agent-name { font-weight: bold; color: #5D4037; margin: 0; font-size: 0.95em;}
            .user-name { font-weight: bold; color: #8D6E63; margin: 0; font-size: 0.95em;}
            .msg-timestamp { font-size: 0.8em; color: #8D6E63; font-weight: normal; }
            
            .day-divider {
                display: flex;
                align-items: center;
                text-align: center;
                color: #8D6E63;
                margin: 20px 0;
                font-size: 0.85em;
                font-weight: bold;
            }
            .day-divider::before, .day-divider::after {
                content: '';
                flex: 1;
                border-bottom: 1px solid #D7CCC8;
            }
            .day-divider:not(:empty)::before {
                margin-right: .5em;
            }
            .day-divider:not(:empty)::after {
                margin-left: .5em;
            }

            /* Markdown Styling inside Messages */
            .message p { margin: 4px 0 8px 0; }
            .message p:last-child { margin-bottom: 0; }
            .message ul, .message ol { margin: 4px 0 8px 0; padding-left: 20px; }
            .message li { margin-bottom: 3px; }
            .message h1, .message h2, .message h3, .message h4 { margin: 12px 0 6px 0; font-size: 1.15em; color: #3E2723; font-weight: bold; }
            .message h1:first-child, .message h2:first-child, .message h3:first-child { margin-top: 0; }
            
            /* Table Styling */
            .message table { border-collapse: collapse; width: 100%; margin: 10px 0; font-size: 0.95em; }
            .message th, .message td { border: 1px solid #D7CCC8; padding: 8px 10px; text-align: left; }
            .message th { background-color: #F5EFEB; font-weight: bold; color: #3E2723; }
            .message tr:nth-child(even) { background-color: #FAF8F6; }
            
            /* Code / Blockquote styling */
            .message code { background: #EFEBE9; padding: 2px 4px; border-radius: 3px; font-family: monospace; font-size: 0.9em; color: #5D4037; }
            .message pre { background: #F5EFEB; padding: 10px; border-radius: 5px; overflow-x: auto; margin: 8px 0; border: 1px solid #D7CCC8; }
            .message pre code { background: none; padding: 0; }
            .message blockquote { margin: 8px 0; padding-left: 12px; border-left: 4px solid #6D4C41; color: #5D4037; }
        </style>
    </head>
    <body>
        <div id="sidebar">
            <h2>☕ Coffee Shop Monitor</h2>
            <p>Monitoring sheet...</p>
        </div>
        <div id="main">
            <div id="chat-history"></div>
            <div id="input-area">
                <input type="text" id="msg" placeholder="Message Coffee Shop Monitor..." onkeypress="if(event.key === 'Enter') sendMessage()">
                <button onclick="sendMessage()">Send</button>
            </div>
        </div>

        <script>
            const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
            const ws = new WebSocket(`${protocol}//${window.location.host}/ws`);
            const history = document.getElementById('chat-history');

            // Scroll to the bottom on load to show latest messages
            history.scrollTop = history.scrollHeight;

            ws.onmessage = function(event) {
                // Parse markdown content from the agent
                const parsedHtml = marked.parse(event.data);
                const timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
                history.innerHTML += `
                    <div class="message agent-msg">
                        <div class="msg-header">
                            <span class="agent-name">Inventory Agent APP</span>
                            <span class="msg-timestamp">${timeStr}</span>
                        </div>
                        <div>${parsedHtml}</div>
                    </div>`;
                history.scrollTop = history.scrollHeight;
            };

            function sendMessage() {
                const input = document.getElementById('msg');
                const text = input.value;
                if (!text) return;
                
                // Parse user markdown if any (optional, but keeps styling consistent)
                const parsedHtml = marked.parse(text);
                const timeStr = new Date().toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' });
                history.innerHTML += `
                    <div class="message user-msg">
                        <div class="msg-header">
                            <span class="user-name">You</span>
                            <span class="msg-timestamp">${timeStr}</span>
                        </div>
                        <div>${parsedHtml}</div>
                    </div>`;
                
                ws.send(text);
                input.value = '';
                history.scrollTop = history.scrollHeight;
            }
        </script>
    </body>
    </html>
    """

if __name__ == "__main__":
    import uvicorn
    port_val = int(os.environ.get('PORT', 8080))
    uvicorn.run(app, host='0.0.0.0', port=port_val)

5. 部署 Cloud Run 服務

您將使用 Google Cloud 的 Buildpacks,將應用程式原始碼轉換為 Cloud Run 適用的容器映像檔,不必自行建構映像檔。

gcloud beta run deploy coffee-mgr-agent \
    --source=. \
    --region=$REGION \
    --sandbox-launcher \
    --max-instances=1 \
    --session-affinity \
    --allow-unauthenticated \
    --no-cpu-throttling \
    --set-env-vars GOOGLE_GENAI_USE_VERTEXAI=1,GOOGLE_CLOUD_PROJECT=$GOOGLE_CLOUD_PROJECT,GOOGLE_CLOUD_LOCATION=global,SPREADSHEET_ID=$SPREADSHEET_ID \
    --service-account $SERVICE_ACCOUNT_ADDRESS

6. 與虛擬服務專員對話

部署完成後,您會看到 URL: https://coffee-mgr-agent-YOUR_PROJECT_ID.YOUR_REGION.run.app

在瀏覽器中開啟這個網址。

向代理傳送下列提示詞。

The 2026 graduation schedule was just posted. It's the same schedule as last year. Can you review last year's POS data to help me prepare for this year?
Saturday, June 13:
College of Business (8:30 a.m.)
College of Science and Mathematics (12:30 p.m.)
College of Liberal Arts (4:30 p.m.)
Sunday, June 14:
College of Agriculture (8:30 a.m.)
College of Architecture (12:30 p.m.)
College of Engineering (4:30 p.m.)

代理程式應會回覆類似下列建議的內容:

Based on the 2025 POS data, I have correlated graduation ceremony patterns with beverage demand spikes. Last year, the post-ceremony windows consistently experienced high demand for complex items (Cold Brew, Extra Espresso, and Alt Milk).

Key Data Discoveries for 2026

- College of Business (Saturday 8:30 a.m. ceremony):
  - Significant demand spike observed at 10:00 a.m. Saturday.
  - Data: 130 Cold Brews and 95 Extra Espresso shots. This is a primary bottleneck for barista output.
- College of Engineering (Sunday 4:30 p.m. ceremony):
  - Significant demand spike observed at 6:00 p.m. Sunday.
  - Data: 145 Cold Brews and 110 Extra Espresso shots. This is the highest intensity period of the weekend.
- Alt-Milk Surge (Saturday/Sunday 2:00 p.m. post-ceremonies):
  - Consistent, high-volume Alt Milk consumption (65-180 oz) after the 12:30 p.m. ceremonies.

Diagnostic & Staffing Recommendations

Given that these complex beverage items (Cold Brew, Extra Espresso) correlate directly with your high-traffic post-ceremony windows, the risk of hitting wait times > 10 minutes is high. Assuming you typically have 2 cashiers, my diagnostic is that these are barista output bottlenecks, not cashier bottlenecks.

Suggested Tasks for your TODO-2026 List:

1. Staffing: Schedule a dedicated "Support Barista" for Saturday 9:30 a.m. – 11:00 a.m. to handle the Cold Brew/Espresso rush.
2. Staffing: Schedule a dedicated "Support Barista" for Sunday 5:30 p.m. – 7:00 p.m. to manage the high volume after the Engineering ceremony.
3. Inventory: Pre-stage extra Alt Milk cartons at the mobile station for Saturday 1:30 p.m. and Sunday 1:30 p.m. shifts.

Would you like me to add these tasks to your 'TODO-2026' TODO list?

接著,代理程式會要求您 (人機迴圈) 授權建立/更新 TODO-2026 分頁。

回覆服務專員並輸入 Yes

現在查看 TODO-2026 分頁時,您會看到類似下列內容:

Task	Category	Ceremony	Date_Added
Schedule a Support Barista role for Saturday morning rush	Staffing	College of Business	2025-05-22
Schedule a Support Barista role for Sunday evening rush	Staffing	College of Engineering	2025-05-22
Pre-stage extra Alt Milk cartons at mobile station	Inventory	College of Science/Math & Architecture	2025-05-22

7. 恭喜!

恭喜您完成本程式碼研究室!

建議您參閱 Cloud Run 說明文件、Cloud Run 沙箱說明文件和 ADK 說明文件

涵蓋內容

  • 如何建立 Cloud Run 服務
  • 如何在 Cloud Run 服務上部署 ADK 代理
  • 如何讓代理程式在 Cloud Run 服務的沙箱中執行程式碼

8. 清理

如要避免系統向您的 Google Cloud 帳戶收取本教學課程所用資源的費用,請刪除專案或個別資源。

方法 1:刪除資源

以下簡要說明如何清理專案,避免產生不必要的費用。

刪除 Cloud Run 服務

gcloud run services delete coffee-mgr-agent --region $REGION

刪除服務帳戶:移除專屬試算表服務帳戶

gcloud iam service-accounts delete $SERVICE_ACCOUNT_ADDRESS

方法 2:刪除專案

如要刪除整個專案,請前往「管理資源」,選取您在步驟 2 中建立的專案,然後選擇「刪除」。刪除專案後,您必須在 Cloud SDK 中變更專案。如要查看所有可用專案的清單,請執行 gcloud projects list。如要使用指令列,也可以執行下列指令:

gcloud projects delete ${GOOGLE_CLOUD_PROJECT}