{"slug": "agent-platform-in-practice-4-towards-cloud-two-layer-decoupling-and-full-the-ai", "title": "[Agent Platform in Practice (4)] Towards Cloud Two-layer Decoupling and Full Persistence: Completing the LINE AI Business Card M", "summary": "A developer extended the open-source LINE AI Business Card Assistant (line-card-assistant) with a Cloud SQL PostgreSQL-backed PostgresStore, completing a three-part cloud-native persistence scheme alongside GCS artifact storage and Vertex AI session storage. The update, shipped in PR #12 for Issues #5 and #6, also adds a HybridSearch node with token-scoring fault-tolerant BroadSearch and splits the system into a Cloud Run LINE webhook gateway and a Google Cloud Agent Runtime execution layer. The two-tier decoupling lets the webhook verify X-Line-Signature and return 200 OK within 1-2 seconds while the agent runtime handles the 12-node ADK workflow.", "body_md": "In the first three articles of the \"Agent Platform in Action\" series, we step-by-step pushed the **LINE AI Business Card Assistant (`line-card-assistant`)** from a conceptual prototype to a cloud-native architecture:\n\n`google.golang.org/adk/v2/workflow` to build a 12-node business card processing graph (Graph Workflow), covering Vision OCR, `AddFanOut` / `AddFanIn` parallel background investigations, and the HITL (Human-in-the-Loop) pause and modification loop of `NewRequestInputEvent`.` agents-cli` (`adk_go`) standard dual-channel architecture, allowing the project to support LINE Webhook, ADK WebUI Playground, and the A2A protocol simultaneously.`artifact/gcsartifact` to move multi-MB business card photos out of `Session.State`, and combining `session/vertexai` to implement `resolvePendingHITL`, allowing stateless containers to seamlessly continue HITL modification and saving even if scaled to 0 or instances are switched.\nIn today's fourth episode of the series, we will complete the **final piece of Phase 2 ([Issue #5](https://github.com/kkdai/line-card-assistant/issues/5))** and the **Phase 3 core architecture upgrade ([Issue #6](https://github.com/kkdai/line-card-assistant/issues/6))** (corresponding to **[PR #12](https://github.com/kkdai/line-card-assistant/pull/12)**):\n\n`PostgresStore` / Cloud SQL)`HybridSearch` and token-scoring fault-tolerant `BroadSearch`.\nWhen we deploy `line-card-assistant` to a Google Cloud production environment, a careful examination of the responsibilities of every piece of data and component in the system reveals two key problems that must be solved:\n\n`CardStore` Business Card Database\nIn the previous post (PR #11), we already placed the **\"original business card photos\"** in **Google Cloud Storage (`GCSArtifactService`)** and the **\"workflow execution state and HITL breakpoints\"** in **Vertex AI / Cloud SQL (`SessionService`)**. However, when a user clicks \"✅ Confirm Save\" in LINE and reaches `SaveCardNode`, if the final confirmed business card data (` models.BusinessCard`) is still written to local memory `MemoryStore` or a local SQLite file inside the container:\n\n`HybridSearchNode` won't find the card just saved in Instance B's memory!\nTherefore, we must complete the final corner of the **\"Cloud-Native Persistence Triad\"** — **Cloud SQL PostgreSQL (`PostgresStore`)**:\n\n| Persistence Dimension | Content Responsible for Storing | Cloud Backend Service | ADK / Go Implementation Package | \n|---|---|---|---|\n| **1. Binary Large Files (Artifacts)** | Original business card photos ( `business_card.jpg` v1, v2…) | **Google Cloud Storage (GCS)** | `sessions.NewArtifactService` (`artifact/gcsartifact` ) | \n| **2. Conversation and Graph Breakpoints (Sessions)** | Graph Checkpoints, `active_interrupt_id` ,`draft_card` | **Vertex AI Agent Engine / Cloud SQL** | `sessions.NewService` (`session/vertexai` ,`session/database` ) | \n| **3. Structured Business Data (Domain DB)** | Verified `BusinessCard` official records, JSON industry tags, hybrid retrieval | **Cloud SQL for PostgreSQL** | `store.NewStore` (`internal/store/postgres.go` ) | \n\nIn monolithic (All-in-One) mode, `cmd/server` receives LINE Webhooks, runs Vision OCR, and executes three parallel Grounding search nodes in the same process. However, in an enterprise-grade Google Cloud Agent Platform architecture, splitting the system into **\"Cloud Run LINE Webhook Gateway (Edge Layer)\"** and **\"Google Cloud Agent Runtime (Agent Execution Layer)\"** offers three major advantages:\n\n`POST /webhook`) to push events, uses `LINE_CHANNEL_SECRET` to verify the `X-Line-Signature`, and must `200 OK` within 1-2 seconds`app.NewRootAgent` hosted on the Agent Runtime`AGENT_RUNTIME_URL` is not set, `cmd/server` automatically runs in `AGENT_RUNTIME_URL=https://...` in Cloud Run automatically switches Let's look at the cross-layer interaction under the two-tier decoupled architecture with a complete system sequence diagram:\n\n```\nsequenceDiagram\n    autonumber\n    actor User as LINE User\n    participant GW as Cloud Run<br/>LINE Webhook Gateway<br/>(cmd/server + gateway.Client)\n    participant AR as Google Cloud Agent Runtime<br/>(adkrest.Server / app.NewRootAgent)\n    participant GCS as Cloud Storage<br/>(GCSArtifactService)\n    participant Sess as Vertex AI / Cloud SQL<br/>(SessionService)\n    participant PG as Cloud SQL PostgreSQL<br/>(PostgresStore)\n\n    User->>GW: 1. Send business card photo (POST /webhook)\n    GW-->>User: 2. Verify X-Line-Signature and immediately return HTTP 200 OK\n    GW->>GW: 3. Download image from LINE Blob API []byte\n    GW->>AR: 4. POST /api/apps/app/users/{user}/sessions/{id}<br/>+ POST /api/run (With Google Cloud ADC ID Token and image InlineData)\n    AR->>GCS: 5. RouterNode automatically saves image to GCS Artifact (\"business_card.jpg\" v1)\n    AR->>AR: 6. VisionOCRNode -> AddFanOut (Deduplication / Google Search company background / Conversation context) -> BuildPreviewNode\n    AR->>Sess: 7. HITLConfirmNode triggers NewRequestInputEvent<br/>Persist active_interrupt_id=\"hitl_confirm_1\"\n    AR-->>GW: 8. Return Events and Session State (including flex_preview_json)\n    GW-->>User: 9. Push LINE Flex Message business card preview card (⏸️ Waiting for user confirmation)\n\n    Note over GW,AR: ☁️ Gateway is completely stateless; the next request can land on any Gateway instance\n\n    User->>GW: 10. Input modification: \"Change title to CTO\" or click \"✅ Confirm Save\"\n    GW->>AR: 11. HasPendingHITL -> GET /api/apps/app/users/{user}/sessions<br/>Automatically retrieve remote paused {SessionID, active_interrupt_id}\n    GW->>AR: 12. POST /api/run (Send adk_request_input FunctionResponse)\n    AR->>PG: 13. If confirming save: SaveCardNode calls PostgresStore.Save() Upsert to write to Cloud SQL\n    AR->>Sess: 14. Clear active_interrupt_id=\"\" and record saved_card_id\n    AR-->>GW: 15. Return save success and AI icebreaker follow-up draft\n    GW-->>User: 16. Push save confirmation and icebreaker to user!\n```\n\n`internal/store/postgres.go`) — Issue #5\nIn our project, since `sessions/sessions.go` already introduced `gorm.io/gorm` and `gorm.io/driver/postgres` to support `SESSION_TYPE=cloud_sql`, we can directly use GORM to build a production-grade **` PostgresStore`** for `CardStore` without extra dependency bloat!\n\n`store.CardStore` Interface: Native While checking the original [`internal/workflow/nodes_search.go`](https://github.com/kkdai/line-card-assistant/blob/main/internal/workflow/nodes_search.go), we found a type assertion for `*store.MemoryStore` in `BroadSearchNode`:\n\n```\n// ❌ Old way: Only MemoryStore supports BroadSearch; other Stores fall back to ListByUser\nif memStore, ok := cardStore.(*store.MemoryStore); ok {\n    cards, err = memStore.BroadSearch(ctx, userID, keywords)\n} else {\n    cards, err = cardStore.ListByUser(ctx, userID)\n}\n```\n\nTo ensure `PostgresStore` and `MemoryStore` share a consistent hybrid retrieval and fuzzy fallback experience, we officially included `BroadSearch` in the `CardStore` standard interface in [`internal/store/store.go`](https://github.com/kkdai/line-card-assistant/blob/main/internal/store/store.go):\n\n```\n// CardStore defines the persistence and search interface for business cards.\ntype CardStore interface {\n    Save(ctx context.Context, card *models.BusinessCard) error\n    GetByID(ctx context.Context, id string) (*models.BusinessCard, error)\n    CheckDuplicate(ctx context.Context, userID, name, company, email string) (*models.BusinessCard, bool, error)\n    HybridSearch(ctx context.Context, filter models.SearchFilter) ([]*models.BusinessCard, error)\n    BroadSearch(ctx context.Context, userID string, keywords []string) ([]*models.BusinessCard, error)\n    ListByUser(ctx context.Context, userID string) ([]*models.BusinessCard, error)\n}\n```\n\nThis way, `BroadSearchNode` only needs to cleanly call one line: `cardStore.BroadSearch(ctx, userID, keywords)`!\n\n`BusinessCardRecord`)\nIn [`internal/store/postgres.go`](https://github.com/kkdai/line-card-assistant/blob/main/internal/store/postgres.go), we defined `BusinessCardRecord` corresponding to the PostgreSQL `business_cards` table, added indexes to common query fields (`user_id`, `name`, `company`, `email`, `met_date`, `updated_at`), and stored `Tags []string` as a JSON string in `tags_json`:\n\n```\n// BusinessCardRecord is the GORM database model for persisting a models.BusinessCard\n// in PostgreSQL / Cloud SQL.\ntype BusinessCardRecord struct {\n    ID string `gorm:\"primaryKey;size:128\"`\n    UserID string `gorm:\"index;size:128;not null\"`\n    Name string `gorm:\"index;size:255\"`\n    Title string `gorm:\"size:255\"`\n    Company string `gorm:\"index;size:255\"`\n    Email string `gorm:\"index;size:255\"`\n    Phone string `gorm:\"size:64\"`\n    Address string `gorm:\"type:text\"`\n    TaxID string `gorm:\"size:32\"`\n    Website string `gorm:\"size:512\"`\n    RawOCR string `gorm:\"type:text\"`\n    CompanySummary string `gorm:\"type:text\"`\n    CompanyInfo string `gorm:\"type:text\"`\n    TagsJSON string `gorm:\"column:tags_json;type:text\"`\n    MetContext string `gorm:\"type:text\"`\n    RawImageURL string `gorm:\"type:text\"`\n    MetDate time.Time `gorm:\"index\"`\n    CreatedAt time.Time `gorm:\"index\"`\n    UpdatedAt time.Time `gorm:\"index\"`\n}\n```\n\nWhen a user takes a photo of an existing contact's business card (e.g., the person was promoted from \"Senior Engineer\" to \"VP of Engineering\", and `CleanAndDedupNode` identifies the duplicate and reuses the original `card.ID`), `PostgresStore.Save` performs an atomic Upsert using PostgreSQL's native `ON CONFLICT (id) DO UPDATE` (`clause.OnConflict{UpdateAll: true}`):\n\n```\nif err := s.db.WithContext(ctx).Clauses(clause.OnConflict{\n    Columns: []clause.Column,\n    UpdateAll: true,\n}).Create(rec).Error; err != nil {\n    return fmt.Errorf(\"upsert business card %q: %w\", cp.ID, err)\n}\n```\n\n`store.NewStore(ctx)` Factory: Auto-sensing Cloud SQL Unix Sockets and Environment Variables\nWhen connecting to Cloud SQL from Cloud Run or Agent Runtime, the official recommendation is to use the Unix Domain Socket (`/cloudsql/<PROJECT>:<REGION>:<INSTANCE>`) established by the Cloud SQL Auth Proxy; locally or on GKE, `DATABASE_URL` or `DB_HOST` are common. We support all three modes in `BuildPostgresDSNFromEnv()` and `store.NewStore(ctx)`, and **automatically fall back seamlessly to `MemoryStore` preloaded with 4 Taiwan sample business cards** when no database environment variables are set:\n\n```\n// BuildPostgresDSNFromEnv resolves a PostgreSQL / Cloud SQL connection string from environment variables.\n// Priority:\n// 1. CARD_STORE_DSN / DATABASE_URL / POSTGRES_DSN\n// 2. Cloud SQL Unix socket when INSTANCE_CONNECTION_NAME + DB_PASS are set\n// 3. Standard TCP host when DB_HOST + DB_PASS are set\nfunc BuildPostgresDSNFromEnv() string {\n    for _, key := range []string{\"CARD_STORE_DSN\", \"DATABASE_URL\", \"POSTGRES_DSN\"} {\n        if v := strings.TrimSpace(os.Getenv(key)); v != \"\" {\n            return v\n        }\n    }\n\n    dbUser := envOrDefault(\"DB_USER\", \"postgres\")\n    dbName := envOrDefault(\"DB_NAME\", \"postgres\")\n    dbPass := strings.TrimSpace(os.Getenv(\"DB_PASS\"))\n    instanceConn := strings.TrimSpace(os.Getenv(\"INSTANCE_CONNECTION_NAME\"))\n    if instanceConn != \"\" && dbPass != \"\" {\n        return fmt.Sprintf(\n            \"host=/cloudsql/%s user=%s password=%s dbname=%s sslmode=disable\",\n            instanceConn, dbUser, dbPass, dbName,\n        )\n    }\n    // ...\n}\n```\n\nThis way, as long as your environment has `INSTANCE_CONNECTION_NAME` and `DB_PASS` set for `SESSION_TYPE=cloud_sql`, **` SessionService` and `CardStore` can share the same Cloud SQL PostgreSQL instance**, without spending an extra penny on database costs!\n\n`AgentRuntimeClient` (`internal/gateway/client.go`) — Issue #6\nNext is the core engineering of Phase 3: **How to make the `cmd/server` LINE Bot Webhook Handler forward all business card recognition, HITL recovery, search, and icebreaker requests to a remote Agent Runtime via HTTP instead of running `workflow.Engine` locally?**\n\nRemember how we had the foresight to separate the Webhook Handler and Workflow Engine with a **`linebot.WorkflowRunner` interface** in the first version of `internal/linebot/handler.go`?\n\n```\n// internal/linebot/handler.go\ntype WorkflowRunner interface {\n    HasPendingHITL(userID string) bool\n    RecordUserMessage(userID, text string)\n    StartIngest(ctx context.Context, userID string, imageBytes []byte, recentMessages []string) (*WorkflowResponse, error)\n    ResumeHITL(ctx context.Context, userID string, userReply string) (*WorkflowResponse, error)\n    RunSearch(ctx context.Context, userID string, query string) (*WorkflowResponse, error)\n    RunEmailDraft(ctx context.Context, userID string, cardID string) (*WorkflowResponse, error)\n}\n```\n\nBecause of this clean interface, we **don't need to change a single line of code** in `linebot.Handler`! We just need to implement a **` gateway.Client`** that satisfies the `linebot.WorkflowRunner` interface in [`internal/gateway/client.go`](https://github.com/kkdai/line-card-assistant/blob/main/internal/gateway/client.go)!\n\n`gateway.NewClient` Supporting Automatic Google Cloud ADC Token Issuance\nWhen the LINE Webhook Gateway on Cloud Run needs to call an IAM-protected remote Agent Runtime (or another ADK REST Server on an internal Cloud Run), `gateway.NewClient` automatically obtains the current Service Account's Google Cloud ADC credentials via `google.golang.org/api/idtoken` (or `golang.org/x/oauth2/google`) and attaches them to the `Authorization: Bearer <token>` header of every HTTP request:\n\n```\nfunc NewClient(ctx context.Context, baseURL string, useADC bool, opts ...ClientOption) (*Client, error) {\n    trimmed := strings.TrimRight(strings.TrimSpace(baseURL), \"/\")\n    if trimmed == \"\" {\n        return nil, fmt.Errorf(\"agent runtime baseURL cannot be empty\")\n    }\n    if !strings.HasSuffix(trimmed, \"/api\") {\n        trimmed += \"/api\"\n    }\n\n    hc := &http.Client{Timeout: 60 * time.Second}\n    if useADC {\n        if idClient, err := idtoken.NewClient(ctx, strings.TrimSuffix(trimmed, \"/api\")); err == nil {\n            hc = idClient\n        } else if oauthClient, err := google.DefaultClient(ctx, \"https://www.googleapis.com/auth/cloud-platform\"); err == nil {\n            hc = oauthClient\n        }\n    }\n    // ...\n}\n```\n\nWhen a user uploads a business card photo on LINE:\n\n`imageBytes` from LINE Blob API.`gateway.Client.StartIngest` calls the remote ADK REST API `POST /api/apps/{appName}/users/{userID}/sessions/{sessionID}` to create a session.`POST /api/run`, wrapping `InlineData` of `newMessage.Parts` to send to the remote Agent Runtime:\n\n```\nfunc (c *Client) StartIngest(ctx context.Context, userID string, imageBytes []byte, recentMessages []string) (*linebot.WorkflowResponse, error) {\n    sessionID := \"ingest-\" + uuid.NewString()\n    initialState := map[string]any{\n        workflow.StateKeyInputType: workflow.RouteIngest,\n        workflow.StateKeyUserID: userID,\n        workflow.StateKeyRecentMessages: recentMessages,\n    }\n    if err := c.createSession(ctx, userID, sessionID, initialState); err != nil {\n        return nil, err\n    }\n\n    parts := []*genai.Part{\n        {Text: workflow.RouteIngest},\n    }\n    if len(imageBytes) > 0 {\n        parts = append(parts, &genai.Part{\n            InlineData: &genai.Blob{\n                MIMEType: \"image/jpeg\",\n                Data: imageBytes,\n            },\n        })\n    }\n    msg := &genai.Content{\n        Role: genai.RoleUser,\n        Parts: parts,\n    }\n\n    res, err := c.runTurn(ctx, userID, sessionID, msg, initialState)\n    // ...\n}\n```\n\nAt this point, the logic we wrote for `RouterNode` in Phase 2 works like magic: when the remote Agent Runtime's `RouterNode` receives this request with `InlineData`, it **automatically calls `ctx.Artifacts().Save(ctx, \"business_card.jpg\", ...)` to store the image in the GCS Artifact Bucket**, leaving only lightweight `card_image_artifact` and `card_image_version` in `Session.State`! This means the **LINE Webhook Gateway doesn't even need direct write permissions to the GCS Bucket** for the remote Agent Runtime to complete versioned GCS Artifact archiving!\n\n`ResumeHITL`)\nThe most exciting part: when a user sees the LINE Flex preview card and inputs \"*Change title to CTO*\" 5 minutes later, and this Webhook request lands on **another completely new Cloud Run Gateway instance**, how does `gateway.Client` know this user has a pending HITL Session on the remote Agent Runtime?\n\nWe use the `GET /api/apps/{appName}/users/{userID}/sessions` endpoint via the ADK REST API in `gateway.Client.resolvePendingHITL` to query the remote Agent Runtime for the user's session status and check `state[\"active_interrupt_id\"]`:\n\n```\nfunc (c *Client) resolvePendingHITL(ctx context.Context, userID string) (pendingHITL, bool) {\n    // 1. If local cache has SessionID, call remote GET /sessions/{id} to get the latest active_interrupt_id\n    // (Important! Because after each Edit loop, hitl_seq increments to hitl_confirm_2, hitl_confirm_3...)\n    c.mu.RLock()\n    cached, hasCached := c.pendingSession[userID]\n    c.mu.RUnlock()\n    if hasCached && cached.SessionID != \"\" {\n        if fullSess, err := c.getSession(ctx, userID, cached.SessionID); err == nil && fullSess != nil {\n            if activeID := readStringFromMap(fullSess.State, workflow.StateKeyActiveInterruptID); activeID != \"\" {\n                found := pendingHITL{SessionID: cached.SessionID, InterruptID: activeID}\n                c.mu.Lock()\n                c.pendingSession[userID] = found\n                c.mu.Unlock()\n                return found, true\n            }\n        }\n    }\n\n    // 2. If this is a brand-new stateless Gateway instance, call remote GET /sessions to find sessions with active_interrupt_id\n    sessions, err := c.listSessions(ctx, userID)\n    if err != nil {\n        return pendingHITL{}, false\n    }\n    for _, sess := range sessions {\n        activeID := readStringFromMap(sess.State, workflow.StateKeyActiveInterruptID)\n        if activeID == \"\" && sess.ID != \"\" {\n            if fullSess, err := c.getSession(ctx, userID, sess.ID); err == nil && fullSess != nil {\n                activeID = readStringFromMap(fullSess.State, workflow.StateKeyActiveInterruptID)\n            }\n        }\n        if activeID != \"\" {\n            found := pendingHITL{SessionID: sess.ID, InterruptID: activeID}\n            c.mu.Lock()\n            c.pendingSession[userID] = found\n            c.mu.Unlock()\n            return found, true\n        }\n    }\n    return pendingHITL{}, false\n}\n```\n\nHere is a real bug and key detail we caught while writing integration tests:\n\n`HITLConfirmNode` is `hitl_confirm_1`` ApplyEditNode` modifies the title and loops back to `hitl_confirm_2`` FunctionResponse` won't match the latest breakpoint `SessionID`, it synchronizes the latest `active_interrupt_id` from the remote via `getSession`` InterruptID`!` cmd/server/main.go`: Seamless Switching Between Monolithic and Two-Tier Gateway Modes\nFinally, we added mode detection in [`cmd/server/main.go`](https://github.com/kkdai/line-card-assistant/blob/main/cmd/server/main.go):\n\n``` js\n    var runner linebot.WorkflowRunner\n    if cfg.AgentRuntimeURL != \"\" {\n        // 🌐 Mode A: Decoupled Two-Tier LINE Webhook Gateway Mode (Forwarding to remote Agent Runtime)\n        gwClient, err := gateway.NewClient(\n            ctx,\n            cfg.AgentRuntimeURL,\n            cfg.AgentRuntimeUseADC,\n            gateway.WithAppName(cfg.AgentRuntimeAppName),\n        )\n        if err != nil {\n            slog.Error(\"failed to initialize remote Agent Runtime gateway client\", \"url\", cfg.AgentRuntimeURL, \"err\", err)\n            os.Exit(1)\n        }\n        slog.Info(\"running in decoupled LINE Webhook Gateway mode\",\n            \"agent_runtime_url\", cfg.AgentRuntimeURL,\n            \"agent_runtime_app\", cfg.AgentRuntimeAppName,\n            \"use_adc\", cfg.AgentRuntimeUseADC,\n        )\n        runner = gwClient\n    } else {\n        // 🚀 Mode B: All-in-One Local Engine Mode (Default for local development and single container deployment)\n        cardStore, err := store.NewStore(ctx)\n        // ... Initialize local workflow.NewEngine ...\n        runner = engine.LinebotRunner()\n    }\n\n    botHandler, err := linebot.NewHandler(cfg, runner)\n```\n\n`internal/gateway/client_test.go`)\nTo ensure `gateway.Client` is 100% compatible with the official ADK Go v2.6.0 `server/adkrest` REST server, we started a real **`adkrest.NewServer`** (mounting our `app.NewRootAgentWithDeps`) using `httptest.NewServer` in [`internal/gateway/client_test.go`](https://github.com/kkdai/line-card-assistant/blob/main/internal/gateway/client_test.go) and established two completely independent Gateway Clients (`client1` and `client2`) for a five-stage end-to-end test:\n\n`client1.StartIngest`)` ArtifactService` successfully writes `business_card.jpg` $\\rightarrow$ Verify return of `WaitingHITL=true` (`client2.HasPendingHITL`)` client2` probes the paused session created by `client1` via `GET /api/apps/app/users/{user}/sessions`.` client2.ResumeHITL` — Natural Language Modification)`DraftCard.Title == \"CTO\"`, and pausing again at `client1.ResumeHITL` — Confirm Save)`\"action=confirm_save\"` $\\rightarrow$ `SaveCardNode` $\\rightarrow$ Verify business card successfully written to remote `client2.RunSearch` & `client2.RunEmailDraft`)\nRunning the full test suite:\n\n``` bash\n$ go test -race -v ./...\n=== RUN TestGatewayClient_EndToEndDecoupledIngestHITLAndSearch\n--- PASS: TestGatewayClient_EndToEndDecoupledIngestHITLAndSearch (0.01s)\n=== RUN TestBusinessCardRecordRoundTrip\n--- PASS: TestBusinessCardRecordRoundTrip (0.00s)\n=== RUN TestBuildPostgresDSNFromEnv\n--- PASS: TestBuildPostgresDSNFromEnv (0.00s)\n=== RUN TestNewStoreFactoryFallback\n--- PASS: TestNewStoreFactoryFallback (0.00s)\n=== RUN TestRankCardsByKeywords\n--- PASS: TestRankCardsByKeywords (0.00s)\nPASS\n```\n\nAll unit tests and cross-layer integration tests passed with green lights under `-race` detector!\n\nAfter completing **[PR #12](https://github.com/kkdai/line-card-assistant/pull/12)**, looking back at the five stages of **[Roadmap Issue #9](https://github.com/kkdai/line-card-assistant/issues/9)**, **Phase 1, Phase 2, and Phase 3 are now 100% complete**!\n\n`agents-cli eval` Automated Evaluation and Benchmarking)\nIn the upcoming articles of this series, we will enter **Phase 4 and Phase 5**, using `agents-cli eval` to establish a Quality Flywheel for this 12-node Graph Workflow, and officially deploy the entire system using `agents-cli deploy` and OpenTelemetry. Stay tuned!", "url": "https://wpnews.pro/news/agent-platform-in-practice-4-towards-cloud-two-layer-decoupling-and-full-the-ai", "canonical_source": "https://dev.to/gde/agent-platform-in-practice-4-towards-cloud-two-layer-decoupling-and-full-persistence-60g", "published_at": "2026-10-23 08:32:27+00:00", "updated_at": "2026-10-23 08:41:26.581109+00:00", "lang": "en", "topics": ["ai-agents", "ai-infrastructure", "developer-tools", "mlops", "agent-protocols"], "entities": ["LINE", "Google Cloud", "Cloud Run", "Cloud SQL", "Vertex AI", "Google Cloud Storage", "ADK", "line-card-assistant"], "also_reported_by": [], "alternates": {"html": "https://wpnews.pro/news/agent-platform-in-practice-4-towards-cloud-two-layer-decoupling-and-full-the-ai", "markdown": "https://wpnews.pro/news/agent-platform-in-practice-4-towards-cloud-two-layer-decoupling-and-full-the-ai.md", "text": "https://wpnews.pro/news/agent-platform-in-practice-4-towards-cloud-two-layer-decoupling-and-full-the-ai.txt", "jsonld": "https://wpnews.pro/news/agent-platform-in-practice-4-towards-cloud-two-layer-decoupling-and-full-the-ai.jsonld"}}