Recap
In the previous two articles of this series:
-
"[Agent Platform in Action (1)] Building a LINE AI Business Card King with Go and Google ADK 2.0 Graph Workflow": We used
google.golang.org/adk/v2/workflowto implement a 12-node business card assistant featuring a Deterministic Router,AddFanOut/AddFanInthree-way parallel background checks, andNewRequestInputEventHuman-in-the-Loop (HITL) pause and loop correction. -
"[Agent Platform in Action (2)] From Monolithic Cloud Run to Google Cloud Agent Runtime: Refactoring LINE AI Business Card King with agents-cli and ADK Go": We introduced the
agents-cliofficial development lifecycle, explained the Agent Runtime's Scale-to-Zero mechanism, and completed Phase 1 (PR #10), transforming the project into a standardadk_godual-channel architecture supporting LINE Webhook, ADK WebUI Playground, and A2A protocols.
Entering Phase 2 (Issue #3 & Issue #4), we face a classic and common pitfall in distributed systems when pushing to production (Cloud Run / Agent Runtime auto-scaling clusters): When containers can Scale to Zero or be load-balanced to different instances at any time, where should multimodal business card photos (Binary Images) be stored? And how can the HITL pause state, waiting for a user to click a button, be recovered across instances?
In the third installment of this series, we will deep dive into how we used ADK Go v2.6.0's artifact/gcsartifact (GCS Artifact Service) and session/vertexai (Agent Platform Sessions) in PR #11 to completely cure the "HITL amnesia" of stateless containers!
1. Why did the original HITL suffer from "Amnesia" and "State Bloat" in cloud multi-instance environments?
During local development or with a single container instance, stuffing everything into memory is fast. However, once deployed to Google Cloud Run or Vertex AI Agent Runtime, which support Horizontal Auto-scaling or Scale-to-Zero, the first version's design immediately hits two major walls:
Pain Point 1: The "State Bloat" anti-pattern of stuffing multi-MB business card []byte into Session.State
In the first version, when a LINE user sent a high-resolution business card photo, we directly put the downloaded imageBytes []byte into ADK's Session.State["image_bytes"]:
// ❌ Version 1 approach: Directly stuffing multi-MB raw image []byte into Session State
InitialState: map[string]any{
StateKeyInputType: RouteIngest,
StateKeyImageBytes: imageBytes, // A 3MB JPEG inflates to a ~4MB Base64 string after JSON serialization!
}
When using the local session.InMemoryService(), this is just a Go slice pointer. But when switching to cloud-persisted Agent Platform Sessions (session/vertexai) or Cloud SQL (session/database):
-
Serialization and network transfer for every node state change (StateDelta): As the ADK Graph Workflow moves from
RouterNodetoVisionOCRNode, fans out to three parallel nodes, and finally pauses atHITLConfirmNode, keeping multi-MB image binary data inSession.Statesignificantly slows down every Cloud Session API read/write and might even trigger Cloud Session payload size limits! - Difficulty in auditing and versioning history: If a user sends multiple business cards across several turns, mixing raw binary files with structured JSON state makes the conversation state extremely bloated.
Pain Point 2: Container instance switching leads to loss of HITL pendingSession memory mapping
Let's look at a real-world user scenario:
-
Turn 1 (hits Container Instance A): A user takes a photo of a business card at a conference and sends it to the LINE Bot. Instance A completes Vision OCR and parallel background checks, then pauses at
HITLConfirmNodeviaworkflow.NewRequestInputEvent, pushing a Flex Message preview card to the user. - 10-minute break: The user goes to exchange thoughts with the contact and grabs a coffee. During this time, Instance A scales to 0 due to inactivity, or Container Instance B is scaled out due to other traffic.
- Turn 2 (hits Container Instance B): The user clicks "✏️ Edit Fields" on LINE and enters "Change title to CTO", or clicks "✅ Confirm Save".
In the first version, our Engine used an in-memory Go map[string]pendingHITL to track which userID was waiting for which SessionID and InterruptID:
// ❌ Version 1 approach: Relying solely on a local in-memory map to determine if there's a pending HITL
type Engine struct {
mu sync.RWMutex
pendingSession map[string]pendingHITL // userID -> {SessionID, InterruptID}
}
As a result, when the Turn 2 Webhook request is routed by the load balancer to the brand new Instance B, Instance B's pendingSession map is empty! HasPendingHITL(userID) returns false, and the system treats "Change title to CTO" as a brand new "Business Card Search" request, instantly breaking the HITL loop!
2. The Standard ADK 2.0 Solution: Separation of Concerns between Session State and Artifact Storage
To solve this, Google ADK 2.0's architectural design explicitly splits Agent runtime data into two independent services:
| Comparison Dimension | Session Service (session.Service) |
Artifact Service (artifact.Service) |
|---|---|---|
| Core Purpose | Stores conversation event logs (Events), workflow breakpoints (Interrupts), and lightweight structured state (JSON State) | Stores large binary files (images, PDFs, audio, CSV reports) and provides automatic version control (Versioning) |
| Cloud Implementation Package |
google.golang.org/adk/v2/session/vertexai (Agent Platform Sessions) or session/database
|
google.golang.org/adk/v2/artifact/gcsartifact (underlying storage in Google Cloud Storage Bucket) |
| Local Development Implementation | session.InMemoryService() |
artifact.InMemoryService() |
| Role in Business Card King | Stores draft_card JSON, active_interrupt_id, and lightweight pointers (filename + version number) to the image |
Stores raw business card photo business_card.jpg (automatically increments Version 1, 2, 3... per upload) |
With this dual-track design, the entire cross-instance multimodal business card recognition and HITL recovery process becomes crystal clear:
sequenceDiagram
autonumber
actor User as LINE / WebUI User
participant InstA as Container Instance A (Turn 1: Upload Card)
participant GCS as GCS Artifact Service<br/>(artifact/gcsartifact)
participant Sess as Agent Platform Sessions<br/>(session/vertexai)
participant InstB as Container Instance B (Turn 2: Edit Fields)
participant InstC as Container Instance C (Turn 3: Confirm Save)
User->>InstA: 1. Upload business card photo (JPEG []byte)
InstA->>GCS: 2. Save("business_card.jpg", inlineData)<br/>Returns Version = 1
InstA->>Sess: 3. Create Session, State only records:<br/>card_image_artifact="business_card.jpg"<br/>card_image_version=1
InstA->>GCS: 4. VisionOCRNode calls LoadVersion("business_card.jpg", 1)
Note over InstA,Sess: Fan-Out Parallel duplicate check / Company search / Interactive retrieval -> Generate preview card
InstA->>Sess: 5. HITLConfirmNode triggers NewRequestInputEvent<br/>Persists active_interrupt_id and draft_card
InstA-->>User: 6. Return LINE Flex preview card (⏸️ Workflow Paused)
Note over InstA,InstB: ☁️ Container scales to 0 or load balancer switches to a brand new stateless Instance B
User->>InstB: 7. Input: "Change title to CTO"
InstB->>Sess: 8. resolvePendingHITL(userID):<br/>Query latest Session's active_interrupt_id
Sess-->>InstB: 9. Retrieve {SessionID, InterruptID} and draft_card
InstB->>Sess: 10. ResumeHITL -> ApplyEditNode updates title -> Re-pauses and updates active_interrupt_id
InstB-->>User: 11. Return updated Flex preview card (Title: CTO)
Note over InstB,InstC: ☁️ Switch again to brand new stateless Instance C
User->>InstC: 12. Click "✅ Confirm Save" (action=confirm_save)
InstC->>Sess: 13. resolvePendingHITL(userID) retrieves latest InterruptID
InstC->>Sess: 14. ResumeHITL -> SaveCardNode writes to card library + clears active_interrupt_id
InstC-->>User: 15. Return save success and AI icebreaker draft!
3. Code Implementation Breakdown (Phase 2 — PR #11)
Now let's look at the code to see how we completed this upgrade with less than a few hundred lines of clean Go code.
1. Creating a Multi-backend Artifact Factory: sessions/artifacts.go
We added sessions/artifacts.go under the sessions package, using ADK Go v2.6.0's built-in google.golang.org/adk/v2/artifact/gcsartifact package to automatically switch between Cloud GCS Bucket and local memory based on environment variables:
package sessions
import (
"context"
"log/slog"
"os"
"strings"
"google.golang.org/adk/v2/artifact"
"google.golang.org/adk/v2/artifact/gcsartifact"
)
// NewArtifactService returns an ADK artifact.Service backed by Google Cloud Storage
// when ARTIFACT_BUCKET_NAME or LOGS_BUCKET_NAME is configured, and falls back to
// artifact.InMemoryService() for local development and unit tests.
func NewArtifactService(ctx context.Context) (artifact.Service, error) {
bucketName := resolveArtifactBucketName()
if bucketName == "" {
slog.Info("Using in-memory artifact service (set ARTIFACT_BUCKET_NAME or LOGS_BUCKET_NAME for GCS)")
return artifact.InMemoryService(), nil
}
svc, err := gcsartifact.NewService(ctx, bucketName)
if err != nil {
return nil, err
}
slog.Info("Using GCS artifact service", "bucket", bucketName)
return svc, nil
}
A thoughtful detail: In the agents-cli Agent Runtime deployment environment, the system usually configures LOGS_BUCKET_NAME (or our custom ARTIFACT_BUCKET_NAME). Some might accidentally fill it in with a format like gs://my-bucket/prefix. Our resolveArtifactBucketName() automatically strips the gs:// prefix and path to ensure gcsartifact.NewService connects reliably, falling back to artifact.InMemoryService() if no bucket is set.
Simultaneously, we inject ArtifactService and SessionService into launcher.Config in main.go and cmd/server/main.go:
// main.go — Injecting both SessionService and ArtifactService into ADK Universal Launcher
config := &launcher.Config{
SessionService: sessionService,
ArtifactService: artifactService,
AgentLoader: NewSingleAgentLoader(rootAgent),
A2AOptions: a2aOpts,
}
2. Storing Business Card Images in Artifacts, Keeping only "Lightweight Pointers" in Session State
Next, we refactor internal/workflow/graph.go and internal/workflow/nodes_ingest.go.
First, we define two new lightweight State Keys and a default filename:
const (
StateKeyImageBytes = "image_bytes" // Kept only for backward compatibility fallback
StateKeyCardImageArtifact = "card_image_artifact" // Artifact filename
StateKeyCardImageVersion = "card_image_version" // Artifact version number (int)
DefaultCardImageArtifactName = "business_card.jpg"
)
(1) Entering from LINE Webhook (Engine.StartIngest)
When a LINE user sends an image, StartIngest first calls e.artifactService.Save to write the image binary to the Artifact service and only records the filename and version number in InitialState. StateKeyImageBytes is no longer written to Session.State:
initialState := map[string]any{
StateKeyInputType: RouteIngest,
StateKeyRecentEvents: recentEvents,
}
if e.artifactService != nil && len(imageBytes) > 0 {
part := genai.NewPartFromBytes(imageBytes, detectImageMIMEType(imageBytes))
saveResp, saveErr := e.artifactService.Save(ctx, &artifact.SaveRequest{
AppName: appName,
UserID: userID,
SessionID: sess.ID(),
FileName: DefaultCardImageArtifactName,
Part: part,
})
if saveErr == nil && saveResp != nil {
initialState[StateKeyCardImageArtifact] = DefaultCardImageArtifactName
initialState[StateKeyCardImageVersion] = saveResp.Version
}
}
(2) Uploading images directly from ADK WebUI / Agent Engine Playground / A2A (RouterNode)
What if the user isn't using LINE but uploads a business card photo directly via ADK WebUI (make playground) or Agent Engine Console? In RouterNode, we use ADK 2.0's ctx.Artifacts().Save(...) to transfer InlineData from ctx.UserContent() into the Artifact Service:
// internal/workflow/nodes_ingest.go — RouterNode
if getStringState(ctx, StateKeyCardImageArtifact) == "" && len(getBytesState(ctx, StateKeyImageBytes)) == 0 && ctx.UserContent() != nil {
for _, part := range ctx.UserContent().Parts {
if part != nil && part.InlineData != nil && len(part.InlineData.Data) > 0 {
if ctx.Artifacts() != nil {
mimeType := part.InlineData.MIMEType
if mimeType == "" {
mimeType = detectImageMIMEType(part.InlineData.Data)
}
artifactPart := genai.NewPartFromBytes(part.InlineData.Data, mimeType)
if saveResp, saveErr := ctx.Artifacts().Save(ctx, DefaultCardImageArtifactName, artifactPart); saveErr == nil {
setState(ctx, ev, StateKeyCardImageArtifact, DefaultCardImageArtifactName)
setState(ctx, ev, StateKeyCardImageVersion, saveResp.Version)
} else {
setState(ctx, ev, StateKeyImageBytes, part.InlineData.Data)
}
}
if getStringState(ctx, StateKeyInputType) == "" {
setState(ctx, ev, StateKeyInputType, RouteIngest)
}
break
}
}
}
(3) VisionOCRNode Reads specific versions of business card images on demand (loadCardImageBytes)
When the workflow enters VisionOCRNode to call Gemini Vision for multimodal recognition, it uses ctx.Artifacts().LoadVersion (or Load) to load the image into memory for the model. It's discarded after use, keeping Session.State clean:
func loadCardImageBytes(ctx agent.Context) []byte {
if artifactName := getStringState(ctx, StateKeyCardImageArtifact); artifactName != "" && ctx.Artifacts() != nil {
version := getIntState(ctx, StateKeyCardImageVersion)
var (
resp *artifact.LoadResponse
err error
)
if version > 0 {
resp, err = ctx.Artifacts().LoadVersion(ctx, artifactName, version)
} else {
resp, err = ctx.Artifacts().Load(ctx, artifactName)
}
if err == nil && resp != nil && resp.Part != nil && resp.Part.InlineData != nil {
return resp.Part.InlineData.Data
}
}
return getBytesState(ctx, StateKeyImageBytes) // Fallback
}
3. Implementing Stateless Cross-Instance HITL State Discovery: resolvePendingHITL
With image storage solved, here's the main event: When Container Instance B receives a user's "Change title to CTO" or "✅ Confirm Save", how does it automatically retrieve the HITL workflow paused by Instance A when its local memory is empty?
The secret is: when HITLConfirmNode calls workflow.NewRequestInputEvent to pause, we've already written the current InterruptID into Session.State["active_interrupt_id"] (and SaveCardNode clears it to "" once saving is complete).
Therefore, we implemented resolvePendingHITL(ctx, userID) in internal/workflow/graph.go:
// resolvePendingHITL checks the local cache first and falls back to querying
// the shared session.Service for any session belonging to userID with a non-empty
// StateKeyActiveInterruptID. This enables stateless cross-instance HITL resume
// on Cloud Run / Vertex AI Agent Runtime.
func (e *Engine) resolvePendingHITL(ctx context.Context, userID string) (pendingHITL, bool) {
// 1. Fast path: If a record exists in the same instance's memory, verify with sessionService if it's still paused
e.mu.RLock()
cached, ok := e.pendingSession[userID]
e.mu.RUnlock()
if ok && cached.SessionID != "" && cached.InterruptID != "" {
if getResp, err := e.sessionService.Get(ctx, &session.GetRequest{
AppName: appName,
UserID: userID,
SessionID: cached.SessionID,
}); err == nil && getResp != nil && getResp.Session != nil {
if activeID, err := getResp.Session.State().Get(StateKeyActiveInterruptID); err == nil {
if s, ok := activeID.(string); ok && strings.TrimSpace(s) != "" {
cached.InterruptID = strings.TrimSpace(s)
return cached, true
}
}
}
}
// 2. Stateless Slow Path: When hitting a brand new container instance, query the shared sessionService directly!
listResp, err := e.sessionService.List(ctx, &session.ListRequest{
AppName: appName,
UserID: userID,
})
if err != nil || listResp == nil {
return pendingHITL{}, false
}
var (
matched pendingHITL
latestTime time.Time
foundActive bool
)
for _, sess := range listResp.Sessions {
if sess == nil || sess.State() == nil {
continue
}
val, err := sess.State().Get(StateKeyActiveInterruptID)
if err != nil || val == nil {
continue
}
interruptID, ok := val.(string)
interruptID = strings.TrimSpace(interruptID)
if !ok || interruptID == "" {
continue
}
updatedAt := sess.LastUpdateTime()
if !foundActive || updatedAt.After(latestTime) {
foundActive = true
latestTime = updatedAt
matched = pendingHITL{
SessionID: sess.ID(),
InterruptID: interruptID,
}
}
}
if foundActive {
e.mu.Lock()
e.pendingSession[userID] = matched
e.mu.Unlock()
return matched, true
}
return pendingHITL{}, false
}
Behold the power of this design:
- Regardless of which stateless instance the user's request is dispatched to by the Cloud Run or Agent Runtime load balancer,
HasPendingHITL(userID)andResumeHITL(ctx, userID, userInput)can retrieve the user's latest paused{SessionID, InterruptID}directly via the sharedsession.Service. - Then, Instance B simply wraps the user's input into an ADK 2.0 standard
adk_request_input(workflow.WorkflowInputFunctionCallName)FunctionResponseand callse.runner.Runto seamlessly wake the workflow fromHITLConfirmNode!
4. Writing Unit Tests to Verify "Across Three Container Instances"!
To prove this architecture works in a multi-instance environment with no shared local memory, we wrote the TestCrossInstanceHITLResumeAndArtifactStorage test in internal/workflow/graph_test.go, creating 3 independent Engine instances (instanceA, instanceB, instanceC) to simulate three stateless containers:
func TestCrossInstanceHITLResumeAndArtifactStorage(t *testing.T) {
ctx := context.Background()
cardStore := store.NewMemoryStore()
mockLLM := NewMockLLM()
// Simulating cloud-shared Agent Platform Session Service and GCS Artifact Service
sharedSessionSvc := session.InMemoryService()
sharedArtifactSvc := artifact.InMemoryService()
// Creating 3 completely independent Engine instances (each with an independent and empty pendingSession map)
instanceA, _ := NewEngine(cardStore, mockLLM,
WithSessionService(sharedSessionSvc),
WithArtifactService(sharedArtifactSvc),
)
instanceB, _ := NewEngine(cardStore, mockLLM,
WithSessionService(sharedSessionSvc),
WithArtifactService(sharedArtifactSvc),
)
instanceC, _ := NewEngine(cardStore, mockLLM,
WithSessionService(sharedSessionSvc),
WithArtifactService(sharedArtifactSvc),
)
userID := "U_cross_instance_user"
fakeImage := []byte{0xFF, 0xD8, 0xFF, 0xE0, 0x00, 0x10, 'J', 'F', 'I', 'F'}
// Turn 1 (hits Instance A): Upload card photo -> Write to Artifact and pause at HITLConfirmNode
res1, _ := instanceA.StartIngest(ctx, userID, fakeImage, []string{"Met at Cloud Summit"})
// Verify Session State has no bloated raw image_bytes, but lightweight card_image_artifact="business_card.jpg"
// Turn 2 (hits brand new Instance B): User enters edit command 'Change title to CTO'
// Instance B automatically detects the active_interrupt_id left by Instance A from sharedSessionSvc!
res2, _ := instanceB.ResumeHITL(ctx, userID, "Change title to CTO")
// Verify title updated to 'CTO' and re-entered HITL pause waiting for confirmation
// Turn 3 (hits brand new Instance C): User clicks '✅ Confirm Save'
res3, _ := instanceC.ResumeHITL(ctx, userID, "action=confirm_save")
// Verify card successfully written to CardStore, AI icebreaker generated, and HasPendingHITL is zeroed for all instances!
}
Running go test -race -v ./...:
=== RUN TestCrossInstanceHITLResumeAndArtifactStorage
--- PASS: TestCrossInstanceHITLResumeAndArtifactStorage (0.00s)
=== RUN TestRootAgentDirectImageArtifactIngestAndHITL
--- PASS: TestRootAgentDirectImageArtifactIngestAndHITL (0.00s)
PASS
ok github.com/kkdai/line-card-assistant/internal/workflow 0.642s
Not only was the three-stage relay from instanceA $\rightarrow$ instanceB $\rightarrow$ instanceC seamless, but TestRootAgentDirectImageArtifactIngestAndHITL in app/agent_test.go also verified that when a genai.Part image is passed directly from ADK WebUI / A2A, RouterNode automatically saves it to ArtifactService and pauses at HITLConfirmNode!
4. Summary and Next Steps
Through this practical guide (PR #11, completing Issue #3 and Issue #4 in the Roadmap), we've solved the two most critical persistence challenges for moving an AI Agent from a "toy prototype" to a "cloud stateless multi-instance production environment":
-
Separation of Large Files and State (Artifact vs. Session State): Using ADK Go v2.6.0's
artifact/gcsartifactandctx.Artifacts(), business card photos no longer bloatSession.State. Instead, they have independent GCS versioned storage, with Session State only keeping a few dozen bytes of pointers (card_image_artifact+card_image_version). -
Stateless Cross-Instance HITL Recovery: Combining
session/vertexai(Agent Platform Sessions) andresolvePendingHITL, even if a container scales to 0 or switches instances during the minutes spent waiting for human confirmation, the system can still resume the "edit field loop" or "confirm save" perfectly.
In the next article, we will continue with cloud database persistence and a two-layer decoupled architecture, and use agents-cli eval to build an automated evaluation flywheel. Stay tuned!
Resources and Further Reading
-
Previous articles in the series:
- [Agent Platform in Action (1)] Building a LINE AI Business Card King with Go and Google ADK 2.0 Graph Workflow: From Fan-Out Parallel Retrieval to HITL Pause Loop Correction
- [Agent Platform in Action (2)] From Monolithic Cloud Run to Google Cloud Agent Runtime: Refactoring LINE AI Business Card King with agents-cli and ADK Go
-
Open source code and PRs for this project:
- GitHub Repository: kkdai/line-card-assistant
- Phase 2 Implementation Pull Request: PR #11: feat(phase2): integrate GCS Artifact Service and cross-instance HITL session recovery
- Completed Issues: Issue #3, Issue #4
- Google ADK and Agent Platform Official Documentation:

Top comments (1)
This is a really solid breakdown — the "HITL amnesia" framing lands for me. I recently wrote a postmortem about an agent that stayed green on every health check while its memory was silently gone, so I've lived a version of Pain Point 2. The resolvePendingHITL approach (local cache first, shared session state as the source of truth) is the same shape we ended up with: never trust an in-memory map across instances, always re-hydrate from the shared store. One thing worth adding from that experience: the orphan-pending case. If the human never comes back, active_interrupt_id lingers; we added a sweep so a stale interrupt can't get "resumed" into something unexpected days later. Looking forward to the persistence follow-up.