line-card-assistant

Activity, over time

Daily observations from the public event archive.

Attention

Daily stars & forks

Loading attention chart

Star accumulation

Cumulative stars

Loading star accumulation chart

Pull requests

Daily opens & merges

Loading pull requests chart

Conversations

Daily issues & comments

Loading conversations chart

Community

Daily accounts & reviews

Loading community chart

Releases

Daily releases

Loading releases chart

Mentions

10posts · 30d
Nov 40 posts
  1. DEV · · @evanlin

    [Agent Platform in Action (7)] When ADK Go Graph Workflow Meets Google Research Harness (Part 1): Two-Layer Recursive Agent Fram !Harness² Part 1 Architecture: Current Architecture vs c1..c8 Editable Harness Tree Foreword: After the AI Agent Goes Online, What is the Next Bottleneck? Welcome back to the "Agent Platform in Action" series! In the previous six articles, we took the LINE AI Card Assistant (kkdai/line-card-assistant) through the complete five stages from a standalone prototype to production-grade cloud deployment: 1. [[Agent Platform in Action (1)] Building LINE AI Card Assistant with Go and Google ADK 2.0 Graph Workflow](/2026/10/16/adk-graph-workflow-go/): Using google.golang.org/adk/v2/workflow to build a 13-node directed graph featuring Vision OCR, AddFanOut / AddFanIn parallel background investigation, and NewRequestInputEvent HITL interaction loops. 2. [[Agent Platform in Action (2)] From Monolithic Cloud Run to Google Cloud Agent Runtime](/2026/10/17/adk-graph-workflow-go-2/): Completed Phase 1 (PR #10), standardizing the project into an agents-cli (adk_go) dual-channel architecture. 3. [[Agent Platform in Action (3)] Implementing Cross-Instance Business Card Recognition and Pause/Resume with ADK 2.0 GCS Artifact Service and Agent Platform Sessions](/2026/10/20/adk-graph-workflow-go-3/): Completed the first half of Phase 2 (PR #11), solving the HITL "amnesia" of stateless containers and the issue of binary image data explosion. 4. [[Agent Platform in Action (4)] Completing the Architecture Puzzle with Cloud Run LINE Webhook Gateway, Agent Runtime, and Cloud SQL PostgreSQL](/2026/10/21/adk-graph-workflow-go-4/): Completed the second half of Phase 2 & Phase 3 (PR #12), upgrading the business card database to Cloud SQL PostgreSQL and achieving two-layer stateless decoupling. 5. [[Agent Platform in Action (5)] Establishing Automated Evaluation Benchmarks with agents-cli eval and ADK 2.0 Go](/2026/10/22/adk-graph-workflow-go-5/): Completed Phase 4 (PR #13), establishing a golden test set and a quality flywheel. 6. [[Agent Platform in Action (6)] Building a Full-Stack Observable LINE AI Card Assistant with agents-cli deploy, Terraform IaC, GitHub Actions CI/CD, and Cloud Trace](/2026/10/24/adk-graph-workflow-go-6/): Completed Phase 5 (PR #14), adding OpenTelemetry Spans (workflow.node.*) to all 13 Graph Nodes and deploying to the cloud via Terraform and GitHub Actions. Additionally, we have planned advanced Graph Workflow primitives for future ADK v2.6+ (such as Parallel dynamic dispatch, MultiRoute multi-way fan-out, reflection-repair loops, and strongly typed nodes) into the Future Roadmap Issue #15. However, now that our Agent possesses Phase 4 Automated Evaluation and Phase 5 Full-Node Tracing (OpenTelemetry Spans), a deeper question emerges: > When evaluations find that the Agent performs poorly in certain real-world cases (e.g., Japanese-English bilingual cards, searching for colloquial nicknames in the Taiwan tech industry, multi-clause colloquial HITL modifications, or financial compliance icebreaker letters), must engineers manually check logs, manually modify Prompt strings in the Go source code, and then recompile and redeploy every single time? Can we let a Meta-Agent (Engineer Agent) automatically read our evaluation results and OpenTelemetry execution trajectories, diagnose the reasons for lost points, write new Skills and Long-Term Memory itself, and complete a closed-loop self-evolution? This is precisely the core proposition addressed by Google Research's recently published and open-sourced google-research/harness2 (_Harness²: Recursive Harnessing for LLM Agents_, arXiv:2610.14224)! This series will consist of three articles (corresponding to Issue #16, Issue #17, Issue #18, and PR #19), guiding you through the complete integration of Google Research Harness² into our Go ADK 2.0 project: - Part 1 (This article, Issue #16): What is Google Harness²? What can it do? Inventorying the existing architecture before modification and decoupling hardcoded configurations in the Go binary into a c1..c8 editable Harness file tree. - Part 2 (Issue #17): Encapsulating Phase 4's eval/datasets/ and Phase 5's OpenTelemetry Spans into a LineCardAdapter for Harness², and analyzing why "Zero-Leakage Barriers" and "High-Resolution Behavioral Trajectories" are both indispensable. - Part 3 (Issue #18): Complete testing tutorial, disclosure of Parallel vs Sequential + LongTermMEMORY two-layer recursive experimental data (accuracy jumping from 62.5% to 100.0%), and outlook on the future development of Agents. - * * 1. What is Google Research Harness²? What can it do? 1. From "Fine-tuning Model Weights" to "Optimizing Agent Harnessing" In the era of Large Language Models (LLMs), the success of an AI Agent is often determined not just by the underlying model (like Gemini 2.5 Flash / Pro) itself, but by the "Agent Harness" layer wrapped around the model—namely, System Prompts, formatting and safety Guardrails, Long-Term Memory, Tool Descriptions, Sub-Agent settings, and On-Demand Skills. Past automated prompt optimizations (like PromptBreeder, TextGrad, or DSPy) mostly targeted mutations of a single Prompt string. However, in a real enterprise-grade Agent, if you stuff all edge-case patching rules into the same System Prompt, it quickly leads to "Context Bloat": the number of Always-On Tokens increases, which not only squeezes the model's Output Token Budget but also causes different rules to interfere with each other! Google Research's Harness² (_Recursive Harnessing for LLM Agents_, arXiv:2610.14224) breaks this limitation. It defines the entire Agent's equipment as a structured c1..c8 Editable Component Tree and automatically evolves this file tree through Two-Level Recursion: flowchart TB subgraph Outer["Outer Recursion: Harnessing-Level Recursion (Experience accumulation across rounds k=1..K)"] Reflector["Experience Reflector\n(ReflectorPrompt)\nDistilling success and failure patterns across rounds"] Memory["c3: LongTermMEMORY.md\nPersisting domain experience across iterations"] Reflector -->|Write long-term rules| Memory end subgraph Inner["Inner Recursion: Task-Level Recursion (Closed-loop diagnosis and patching within a single round)"] Propose["1. Propose (Diagnostic Proposal)\nRead trajectory.md\nLocate behavioral defects and propose N candidate Patches"] Probe["2. Probe (Sandbox Probing)\nExecute N candidate Harnesses in parallel\nCompare trajectory and score changes before and after modification"] Compose["3. Compose (Conflict Resolution and Synthesis)\nRetain positive-gain Patches, discard negative interference\nSynthesize the next-generation best Harness H^(k)"] Propose --> Probe --> Compose end Memory --> Propose Compose --> Reflector 2. The c1..c8 Eight Harness Components and the 24KB Budget Limit In google-research/harness2/harness.py, a standard Agent Harness consists of 4 Always-On Components (c1..c4) and 4 On-Demand Components (c5..c8): | Component Code | File Path Rule | Loading Mode | Responsibility in Harness² | | --- | --- | --- | --- | | c1 | systemprompt.md | Always-On | Defines core roles, node responsibilities, and indexing rules for triggering c6 skills (Skill Trigger Lines). | | c2 | guardrails.md | Always-On | Defines non-violable output formats (e.g., pure JSON constraints), E.164 normalization invariants, and security/PII guardrails. | | c3 | LongTermMEMORY.md | Always-On | Domain lessons and industry compliance memories written across rounds by the outer Experience Reflector. | | c4 | tool_descriptions/*.md | Always-On | Tool parameter descriptions, search strategies, and normalization details (e.g., Japan long-distance prefix (0) cleaning rules). | | c5 | sub_agents/<name>/AGENT.md | On-Demand | Exclusive Instructions and YAML Frontmatter (mode: subagent) for delegated sub-agents (e.g., company_enrich). | | c6 | skills/<name>/SKILL.md | On-Demand | Professional skills dynamically injected only for specific domains or tasks (e.g., Japanese card OCR, Hsinchu Science Park colloquial search expansion). | | c7 | scripts/*.py | On-Demand | Auxiliary verification or data conversion scripts. | | c8 | plugin/*.ts | On-Demand | Runtime lifecycle and OpenTelemetry diagnostic hooks. | > [!IMPORTANT] Why distinguish between c1..c4 (Always-On) and c5..c8 (On-Demand)? Harness² sets an iron rule in OPENCODE_SURFACE: The total byte count of c1..c4 always-on files must never exceed 24,000 bytes (AlwaysOnMaxBytes = 24000)! When Harness² finds that a failure case requires lengthy domain knowledge (e.g., a department title mapping table for Japanese corporate cards or a colloquial abbreviation dictionary for Taiwan semiconductor parks), it won't force these rules into systemprompt.md causing Context explosion. Instead, it will automatically create an on-demand c6 skill file (skills/<name>/SKILL.md) and leave only a lightweight Trigger Line in systemprompt.md! 3. How Does Two-Level Recursion Work? The Harness² evolution engine (harness2/core/recursion.py) includes two levels of recursion: 1. Level 1 (Inner): Task-Level Recursion (Propose $\rightarrow$ Probe $\rightarrow$ Compose): - Propose (Diagnostic Proposal): The optimizer (Harvester LLM) observes tasks that lost points in the benchmark, the final output (Deliverable), and the execution trajectory (trajectory.md). It diagnoses which step the Agent made a wrong decision on and proposes multiple independent candidate patches (Candidate Patches, each being a set of additions or modifications to c1..c8). - Probe (Sandbox Probing): The system mounts each candidate Patch into a sandbox and runs the task once, collecting new scores and new execution trajectories to form a "Before vs. After" Behavioral Contrast. - Compose (Conflict Resolution and Synthesis): The optimizer compares the actual performance of all candidate Patches, merges modifications that truly bring positive score improvements into the new generation Harness $H^{(k)}$, and discards ineffective or side-effect-inducing modifications. 2. Level 2 (Outer): Harnessing-Level Recursion (Experience Reflector): - Between multiple iterations ($k = 1 \dots K$), the outer ReflectorPrompt reviews the evolution history from $H^{(0)} \to H^{(1)} \to \dots \to H^{(k)}$: Which types of modifications succeeded? Which cross-domain constraints (e.g., financial industry compliance headers and personal data protection regulations) need to be remembered at a global level? - Distilled high-level experiences are written into c3: LongTermMEMORY.md, guiding the next round of Propose and Agent execution. - * * 2. Inventorying the Current Architecture: Why Phase 1~5 Go Implementation Cannot Be Directly Optimized by Harness²? Let's compare the left side (Before: Hardcoded Go Binary Architecture) of the architecture diagram at the beginning of the article and review the system architecture we completed in Phase 1 to Phase 5: flowchart LR subgraph Before["Before: Phase 1~5 Solid Compilation Architecture (Hardcoded Go Binary)"] direction TB N1["nodes_ingest.go\nHardcoded VisionOCR / CleanAndDedup /\nCompanyEnrich / ApplyEdit Prompts"] N2["nodes_search.go\nHardcoded QueryIntent /\nEmailDraft Prompts"] N3["graph.go\nFixed SubAgent Instruction"] E["eval/datasets/basic-dataset.json\nPhase 4 Golden Test Set"] T["telemetry.go\nPhase 5 OpenTelemetry 13-Node Spans"] end Problem["Pain Points:\n1. Prompts hardcoded in .go string constants; re-go build required for every adjustment\n2. Lack of on-demand c6 Skills mechanism\n3. Phase 4 Eval and Phase 5 OTel Spans not yet connected into an automated optimization loop"] Before --> Problem In the code before modification: 1. All Prompt templates for VisionOCRNode, CompanyEnrichNode, ApplyEditNode (located in internal/workflow/nodes_ingest.go), as well as QueryIntentNode and EmailDraftNode (located in internal/workflow/nodes_search.go), are hardcoded as fmt.Sprintf(...) string constants in the Go code. 2. The llmagent.Config{Instruction: ...} for CompanyEnrichAgent (located in internal/workflow/nodes_ingest.go) is also a fixed string hardcoded in a Go function. 3. When we want the Harness² Python evolution loop to modify system prompts, add a Japanese business card recognition skill (skills/jp_card_ocr/SKILL.md), or adjust phone number normalization guidelines (tool_descriptions/tool_guidance.md), it is impossible for Harness² to rewrite our .go source code and go build again for every single task! Therefore, the first step in introducing Harness² (Issue #16) is to completely decouple the Go compilation phase from the Harness equipment phase: - Go Binary is responsible for "Graph Topology and Dual-Channel Execution Engine": RouterNode, parallel Fan-Out/Fan-In, HITLConfirmNode pause/resume, Cloud SQL and GCS Artifact access, OpenTelemetry instrumentation. - External c1..c8 Harness file tree is responsible for the "Evolvable Brain and Skill Equipment": Dynamically hot-reloaded at runtime via the environment variable LINE_CARD_HARNESS_DIR! - * * 3. Implementation Part 1: Building internal/harness Loader and harness/base/ Initial Equipment Tree To make the Go ADK 2.0 Graph Workflow natively understand the Harness² c1..c8 specification, we added two core directories to the project: - harness/base/: Stores the initial unevolved ($k=0$) c1..c8 baseline file tree. - internal/harness/harness.go: Responsible for loading the c1..c8 file tree, validating OPENCODE_SURFACE 24KB limits and YAML Frontmatter rules, and dynamically combining always-on context with on-demand skills for each Graph Node. 1. Establishing the harness/base/ Baseline c1..c8 File Tree We extracted the basic settings previously scattered in nodes_ingest.go and nodes_search.go into the harness/base/ directory: harness/base/ ├── systemprompt.md # c1: Core roles and overview of Graph Node responsibilities ├── guardrails.md # c2: JSON output format and phone normalization guardrails ├── LongTermMEMORY.md # c3: Cross-round long-term memory (initially empty, to be written by Reflector) ├── tool_descriptions/ │ └── tool_guidance.md # c4: Guidance for google_search and format_company_profile ├── sub_agents/ │ └── company_enrich/ │ └── AGENT.md # c5: CompanyEnrichAgent delegated sub-agent definition ├── skills/ # c6: On-demand skills directory (to be generated by Propose-Probe-Compose evolution) ├── scripts/ # c7: Auxiliary scripts directory └── plugin/ # c8: Diagnostic plugin directory For example, harness/base/sub_agents/company_enrich/AGENT.md uses a YAML Frontmatter format fully compatible with google-research/harness2: --- mode: subagent description: Searches Google to summarize company background and industry tags. --- You are a professional business analysis assistant. Please use the google_search tool to query the company name provided by the user, then call the format_company_profile tool to return a two-sentence Traditional Chinese company profile and 3 to 5 technical or industry tags (e.g., AI, Cloud, SaaS, FinTech, Semiconductor). 2. Implementing internal/harness/harness.go: 24KB Always-On Budget Validation and On-Demand Skill Injection In internal/harness/harness.go, we implemented the validator Validate() and node prompt synthesizer ComposeNodePrompt() fully aligned with the Python version of OPENCODE_SURFACE.validate(): // AlwaysOnMaxBytes matches google-research/harness2 OPENCODE_SURFACE.always_on_max (24,000 bytes). const AlwaysOnMaxBytes = 24000 // AlwaysOnFiles lists the four c1..c4 always-on files mounted into the agent context. var AlwaysOnFiles = []string{ "systemprompt.md", "guardrails.md", "LongTermMEMORY.md", "tool_descriptions/tool_guidance.md", } // Validate enforces Google Research Harness² OPENCODE_SURFACE invariants on the c1..c8 tree. func (h *Harness) Validate() error { var problems []string for _, reqFile := range AlwaysOnFiles { if strings.TrimSpace(h.Files[reqFile]) == "" { problems = append(problems, fmt.Sprintf("missing or empty always-on component %q", reqFile)) } } if total := h.AlwaysOnBytes(); total > AlwaysOnMaxBytes { problems = append(problems, fmt.Sprintf( "always-on c1..c4 byte count %d exceeds cap %d (every token here competes with the agent's output budget)", total, AlwaysOnMaxBytes, )) } combinedAlwaysOn := h.SystemPrompt + "\n" + h.Guardrails for dirName, skill := range h.Skills { if skill.Name != dirName { problems = append(problems, fmt.Sprintf("%s frontmatter name %q must match directory name %q", skill.RelPath, skill.Name, dirName)) } if strings.TrimSpace(skill.Description) == "" { problems = append(problems, fmt.Sprintf("%s must include a non-empty description in YAML frontmatter", skill.RelPath)) } if !strings.Contains(combinedAlwaysOn, dirName) { problems = append(problems, fmt.Sprintf("skill %q is never referenced in systemprompt.md or guardrails.md; add a trigger line naming `%s`", dirName, dirName)) } } // ... } There are two very critical engineering details here: 1. Mandatory Skill Trigger Reference Check: If Harness² adds a skill in skills/jp_card_ocr/SKILL.md but forgets to write when to trigger jp_card_ocr in systemprompt.md or guardrails.md, Validate() will directly report an error and intercept it, avoiding the creation of "Orphan Skills" that will never be called. 2. Node-Level Dynamic Skill Matching (MatchSkills + ComposeNodePrompt): When the Graph Workflow executes VisionOCRNode, CompanyEnrichNode, ApplyEditNode, QueryIntentNode, or EmailDraftNode, it only injects c6 skills related to that node and automatically attaches adk.harness.skills and adk.harness.always_on_bytes attributes to the OpenTelemetry Span: // composeHarnessPrompt enriches a node's base prompt with the active Harness² c1..c8 tree // and records the active harness metadata onto the node's OpenTelemetry span. func composeHarnessPrompt(span trace.Span, nodeName, basePrompt, taskInput string) (string, *harness.Harness) { h := harness.LoadActive() composed, skills := h.ComposeNodePrompt(nodeName, basePrompt, taskInput) if span != nil { span.SetAttributes( AttrHarnessRoot.String(h.RootDir), AttrHarnessAlwaysOnBytes.Int(h.AlwaysOnBytes()), ) if len(skills) > 0 { span.SetAttributes(AttrHarnessSkills.StringSlice(skills)) } } return composed, h } We can directly use the CLI to verify the always-on byte count and structural validity of harness/base: $ go run ./cmd/harness-runner --validate-only --harness-dir=harness/base OK (1599 always-on bytes) - * * 4. Summary and Next Article Preview In this article (Part 1), we completed the goals of Issue #16: 1. Gained a deep understanding of how Google Research Harness² (arXiv:2610.14224) automatically evolves Agent capabilities through a c1..c8 editable file tree and two-level recursion (Task-level Propose-Probe-Compose + Harnessing-level Experience Reflector), without recompiling the main program or bloating the always-on context. 2. Inventoried the architectural bottlenecks of Phase 1~5 where prompts and normalization logic were hardcoded in the Go binary. 3. Implemented internal/harness/harness.go and harness/base/, allowing the 13 nodes of the ADK 2.0 Go Graph Workflow to support dynamic mounting of the c1..c8 equipment tree via LINE_CARD_HARNESS_DIR. With a hot-reloadable c1..c8 equipment tree in place, the next article [[Agent Platform in Action (8)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 2): Turning Phase 4 Eval Datasets and Phase 5 OpenTelemetry Spans into LineCardAdapter](/2026/10/26/adk-graph-workflow-go-8/) will enter Issue #17: revealing why we need to encapsulate Phase 4 test sets and Phase 5 OpenTelemetry Spans into a LineCardAdapter, and how the "Zero-Leakage Barrier" prevents the AI optimizer from cheating!

    1 reaction
  2. DEV · · @evanlin

    [Agent Platform in Action (8)] When ADK Go Graph Workflow Meets Google Research Harness (Part 2): Integrating Phase 4 Eval Datas !Harness² Part 2: LineCardAdapter Bridging Phase 4 Eval & Phase 5 OpenTelemetry Spans Introduction: Why Decoupling Prompts Isn't Enough? In the previous post [[Agent Platform in Practice (7)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 1): Understanding the Recursive Agent Framework and c1..c8 Decoupling](/2026/10/25/adk-graph-workflow-go-7/) (corresponding to Issue #16), we introduced Google Research's google-research/harness2 (arXiv:2610.14224) and decoupled the prompts, guardrails, long-term memory, and on-demand skills of the LINE AI Business Card Assistant (kkdai/line-card-assistant) into an editable c1..c8 Harness file tree that can be dynamically mounted via LINE_CARD_HARNESS_DIR. However, simply having a hot-reloadable c1..c8 file tree isn't enough; the Harness² dual-layer recursive optimization engine (harness2/core/recursion.py) still cannot directly optimize our Go Agent. This is because Harness² requires a standardized bridge — the BenchmarkAdapter — to answer three questions: 1. list_tasks() (What are the test questions?): What test cases should the Agent challenge? What are the public inputs (Prompt / Image) for each case, and what are the hidden scoring criteria (Rubrics)? 2. run() (How does the Agent think and execute?): When a candidate Harness $H’$ is mounted to execute a specific task, what is the Agent's final output (deliverable)? More importantly, what decision trajectory did it follow across the 13 nodes of the Graph Workflow (trajectory.md)? 3. score() (How was the performance?): Which independent scoring items (per_rubric) did the final output and execution trajectory pass, and where did it lose points? Conveniently, in Phase 4 (PR #13), we already established the eval/datasets/ automated evaluation benchmark, and in Phase 5 (PR #14), we added workflow.node.* OpenTelemetry Spans to all 13 Graph Nodes! In this post (Part 2, corresponding to Issue #17 and PR #19), we will merge these two existing assets into the Harness² LineCardAdapter and explain in depth "Why must it be designed this way?". --- I. Why This Change? Two Core Design Philosophies Before diving into the code, many engineers often ask two questions: 1. _"Why not just feed the entire eval/datasets/*.json file to the optimizer LLM and let it modify the Prompt itself?"_ 2. _"Why do we need to convert Phase 5 OpenTelemetry Spans into trajectory.md? Isn't the final LINE reply text and total score enough?"_ These two questions touch upon the most core engineering designs in the Harness² paper: the Zero-Leakage Barrier and Behavioral Trajectory Contrast. 1. Why the "Zero-Leakage Barrier"? Preventing the Optimizer from "Cheating by Memorizing Answers" Imagine this: if we expose the golden answers in eval/datasets/harness2-challenge-dataset.json (e.g., expected_contains: ["+81-3-1234-5678", "Kenta Sato"]) directly to the Agent or the Harness² Harvester LLM, what happens? The laziest and easiest way for the optimizer to get a perfect score is to write a single line in systemprompt.md: > _"If the input contains Sato, please directly output +81-3-1234-5678 and Kenta Sato."_ In machine learning, this is known as Test Set Leakage / Overfitting to Rubrics. The Agent appears to score 100 on the benchmark, but it crashes immediately when it encounters a new Japanese business card in the real world! flowchart LR Dataset["eval/datasets/*.json\nComplete Test Cases\n(incl. expected_contains / expected_route)"] subgraph Barrier["Zero-Leakage Barrier"] Brief["TaskBrief (Public Task Sheet)\nContains only task_id, prompt, session_input\n❌ Never contains expected_* golden answers"] Scorer["Isolated Scorer (Black-box Grader)\nHolds independent hidden r1..r4 Rubrics"] end Agent["Go ADK Graph Workflow\n(cmd/harness-runner)"] Harvester["Harness² Harvester\n(Propose / Probe / Compose)"] Dataset --> Brief Dataset --> Scorer Brief -->|"Public Input"| Agent Agent -->|"Deliverable + OTel trajectory.md"| Scorer Scorer -->|"Returns only binary scores {r1: 1.0, r2: 0.0}\nand OTel execution trajectory"| Harvester In the Harness² BenchmarkAdapter architecture: - TaskBrief (Public Task Sheet): Can only contain user input (prompt and simulated images/context). It is strictly forbidden from containing any expected_contains, expected_not_contains, or correct answer strings. - score() (Black-box Grader): After comparing answers in an isolated environment, it only returns "which Rubric received a 1.0 or 0.0" (e.g., r3_e164_jp_phone_normalization: 0.0), without leaking the expected string to the optimizer. - As a result, the Harness² optimizer must truly reason out general domain rules (e.g., "When converting Japanese phone numbers +81 (0)3-... to E.164, the domestic trunk prefix (0) must be removed") by reading the Agent's execution trajectory (trajectory.md), rather than hard-coding answers into the Prompt! 2. Why Convert Phase 5 OpenTelemetry Spans into trajectory.md? Opening the Black Box of the 13-Node Graph Our LINE AI Business Card Assistant is not a simple "single LLM call," but an ADK 2.0 Directed Acyclic Graph (Graph Workflow) consisting of 13 nodes: - Business Card Ingestion Pipeline: RouterNode $\rightarrow$ VisionOCRNode $\rightarrow$ Parallel AddFanOut (CleanAndDedupNode, CompanyEnrichNode, ContextMatchNode) $\rightarrow$ AddFanIn (JoinPreviewNode) $\rightarrow$ BuildPreviewNode $\rightarrow$ HITLConfirmNode $\rightarrow$ ApplyEditNode / SaveCardNode. - Smart Search and Icebreaker Pipeline: RouterNode $\rightarrow$ QueryIntentNode $\rightarrow$ HybridSearchNode / BroadSearchNode $\rightarrow$ FormatCarouselNode / EmailDraftNode. Without Phase 5's OpenTelemetry Spans, when a user searches for _"Help me find a Sales Director in Hsinchu Science Park working on AI chips"_ but gets _"No matching business cards found"_ (score 0.25), the optimizer only sees this final failure message and cannot determine: - Did the RouterNode misjudge the search intent as chat? - Did the QueryIntentNode fail to expand the colloquial "Hsinchu Science Park" into the database's address Hsinchu, or fail to expand "Sales Director" into Sales Director? - Was there an error in the SQL query logic of the HybridSearchNode? When we collect and render Phase 5's OpenTelemetry workflow.node.* Spans into a structured trajectory.md, Harness² can clearly see the input, output, duration, routing decisions, and loaded skills of every Graph Node during the Propose and Probe stages! --- II. Implementation Part 2: Building the LineCardAdapter in Three Steps Referring to the architecture diagram at the beginning of this article, we completed the implementation of Issue #17 through three core components: 1. internal/harness/trajectory.go: Mounts an in-memory OpenTelemetry SpanRecorder on the Go side to export the execution details of the 13 Graph Nodes as trajectory.md. 2. cmd/harness-runner/main.go: Provides a deterministic subprocess CLI for the Python Harness² to call, receiving --harness-dir and --task-json, and outputting a JSON containing deliverable, trajectory_markdown, and spans. 3. harness/line_card_adapter.py: Implements the google-research/harness2 BenchmarkAdapter interface, connecting the Phase 4 Dataset with the Go harness-runner. 1. Implementing internal/harness/trajectory.go: Rendering OpenTelemetry Spans as trajectory.md In Phase 5, our internal/workflow/telemetry.go already calls startNodeSpan(ctx, "VisionOCRNode") in every Graph Node. Now, in internal/harness/trajectory.go, we use go.opentelemetry.io/otel/sdk/trace/tracetest.NewSpanRecorder() to intercept all completed node Spans and transform them into a Markdown trajectory report that the Harness² Harvester LLM is best at reading: // InstallRecorder registers a new in-memory SpanRecorder as the global OTel TracerProvider // so every workflow.node.* span emitted by internal/workflow/telemetry.go is captured. func InstallRecorder() (*TrajectoryRecorder, func()) { sr := tracetest.NewSpanRecorder() tp := sdktrace.NewTracerProvider(sdktrace.WithSpanProcessor(sr)) prev := otel.GetTracerProvider() otel.SetTracerProvider(tp) rec := &TrajectoryRecorder{recorder: sr, tp: tp} restore := func() { _ = tp.Shutdown(context.Background()) otel.SetTracerProvider(prev) } return rec, restore } At the same time, we added three OpenTelemetry Attributes to internal/workflow/telemetry.go: AttrNodeDecision (adk.node.decision), AttrNodeDiagnostic (adk.node.diagnostic), and AttrHarnessSkills (adk.harness.skills). When TrajectoryRecorder.RenderMarkdown() executes, it produces a high-resolution trajectory.md like this: # ADK 2.0 Go Graph Workflow Execution Trajectory - **Task ID** : `challenge_colloquial_tw_search` - **Harness Root** : `harness/base` - **Always-On Bytes (`c1..c4`)**: `1599 / 24000` - **Final Score** : `0.25` ## OpenTelemetry Node Spans (`workflow.node.*`) ### 1. `workflow.node.RouterNode` - **Node** : `RouterNode` | **Status** : `OK` | **Duration** : `0.02 ms` - **Decision** : `intent=search (colloquial_query=Help me find a Sales Director in Hsinchu Science Park working on AI chips, I need to contact Taiwan semiconductor clients)` - **Diagnostic** : `routed search query to QueryIntentNode` ### 2. `workflow.node.QueryIntentNode` - **Node** : `QueryIntentNode` | **Status** : `OK` | **Duration** : `0.04 ms` - **Decision** : `parsed_filters={"location":"Hsinchu Science Park","role":"Sales Director","industry":"AI Chips"}` - **Diagnostic** : `UNEXPANDED_COLLOQUIAL_FILTERS: colloquial Taiwan science park alias 'Hsinchu Science Park' and Mandarin title 'Sales Director' were passed literally without bilingual synonym expansion; add a c6 skill 'tw_semicon_search' with BILINGUAL_SYNONYM_MAP` ### 3. `workflow.node.HybridSearchNode` - **Node** : `HybridSearchNode` | **Status** : `OK` | **Duration** : `0.03 ms` - **Decision** : `matched_cards=0 (strict literal filter on location='Hsinchu Science Park' failed against DB address='Hsinchu Science Park (竹科)')` Please pay attention to the Span records of the 2nd node workflow.node.QueryIntentNode and the 3rd node workflow.node.HybridSearchNode! Through this trajectory, the Propose stage of Harness² can see at a glance: RouterNode was correct, the error was in QueryIntentNode treating "Hsinchu Science Park" as a rigid string to query the English address field! 2. Expanding the Phase 4 Evaluation Set: eval/datasets/harness2-challenge-dataset.json To fully test Harness²'s ability to retain existing capabilities (preventing catastrophic forgetting) and conquer difficult edge cases, we established 8 tasks (totaling 32 fine-grained Rubrics) in eval/datasets/harness2-challenge-dataset.json: | Task Category | Task ID | Scenario Description | 4 Independent Scoring Rubrics (r1..r4) | | --- | --- | --- | --- | | Baseline (Regression) | baseline_ingest_ming_wang | Standard Traditional Chinese card ingestion (Wang Xiaoming / Taiwan Cloud Tech) | Intent routing, Name/Title extraction, E.164 phone normalization, Industry tag enrichment | | Baseline (Regression) | baseline_dedup_appier_promotion | Duplicate detection for promotion (Lin Yating Senior $\rightarrow$ VP) | Dedup detection, Title update, Keep new mobile, Company consistency | | Baseline (Regression) | baseline_search_cloud_summit_ai | Standard keyword search (Cloud Summit + AI) | Search routing, Multiple hits, Tag display, Interactive buttons | | Baseline (Regression) | baseline_email_google_cloud | General tech follow-up icebreaker (Chen Jianhong / Google Cloud) | Email routing, Recipient/Event mention, Agenda link, Professional closing | | Challenge (Evolution 1) | challenge_jp_bilingual_ingest | Bilingual JP/EN card OCR & JP phone normalization (Kenta Sato / CTO / +81 (0)3-1234-5678) | r1 Routing, r2 Bilingual name retention, r3 JP +81 remove (0) normalization, r4 JP title bilingual mapping | | Challenge (Evolution 2) | challenge_colloquial_tw_search | Colloquial Taiwan tech alias search ("Help me find a Sales Director in Hsinchu Science Park") | r1 Routing, r2 Hsinchu Science Park $\leftrightarrow$ Hsinchu bilingual expansion hit, r3 Sales Director $\leftrightarrow$ Sales Director title expansion, r4 Tag completeness | | Challenge (Evolution 3) | challenge_noisy_multi_edit_hitl | Multi-clause colloquial HITL continuous correction ("Sorry, let me correct that, the title should be Group Senior VP, also change the end of the mobile to 8899, and add GDG DevFest speaker to notes") | r1 HITL Edit routing, r2 Multi-field atomic update (Title), r3 Mobile suffix partial replacement & Note append, r4 Retain original company/email | | Challenge (Evolution 4) | challenge_pii_safe_executive_followup | Financial executive compliance & PII masking icebreaker (Zhang Yazhu / Cathay Financial, Digital Risk Officer, requires privacy protection and compliance headers) | r1 Email routing, r2 Executive title/scenario, r3 Mobile/Private Email PII Redaction, r4 Financial compliance subject header [Confidential / FinTech Compliance Verified] | 3. Implementing harness/line_card_adapter.py: Connecting to google-research/harness2 Finally, we implemented the LineCardAdapter in harness/line_card_adapter.py, connecting all the above components: class LineCardAdapter: """Adapts `line-card-assistant` to the `google-research/harness2` BenchmarkAdapter protocol.""" def list_tasks(self) -> list[TaskBrief]: """Return public task briefs with zero ground-truth rubric leakage.""" briefs: list[TaskBrief] = [] for raw in self._raw_cases: # Strip `rubrics` and `expected_*` so the agent/harvester never sees ground truth. briefs.append( TaskBrief( task_id=raw["id"], prompt=raw["prompt"], context={ "category": raw.get("category", "general"), "description": raw.get("description", ""), "session_input": raw.get("session_input", {}), }, ) ) return briefs def run(self, harness: Harness, task: TaskBrief) -> RunArtifact: """Mount `harness` in a temporary directory and execute the Go Graph Workflow runner.""" problems = harness.validate() if problems: diag = "# Harness Validation Failed\n" + "\n".join(f"- {p}" for p in problems) return RunArtifact(task_id=task.task_id, deliverable="", trajectory=diag) with tempfile.TemporaryDirectory(prefix=f"line_card_harness_{task.task_id}_") as tmp_dir: harness_root = Path(tmp_dir) harness.materialize(harness_root) # Invoke the compiled Go binary with --harness-dir pointing to the materialized c1..c8 tree proc = subprocess.run( [str(binary), f"--harness-dir={harness_root}", f"--task-json={task_payload_path}"], capture_output=True, text=True, check=False, timeout=30, ) payload = json.loads(proc.stdout) return RunArtifact( task_id=task.task_id, deliverable=payload.get("deliverable", ""), trajectory=payload.get("trajectory_markdown", ""), raw=payload, ) def score(self, task: TaskBrief, artifact: RunArtifact) -> ScoreResult: """Evaluate the deliverable against isolated per-rubric criteria.""" raw = self._cases_by_id[task.task_id] rubrics = raw.get("rubrics", {}) per_rubric: dict[str, float] = {} for rubric_id, spec in rubrics.items(): per_rubric[rubric_id] = 1.0 if self._evaluate_rubric(artifact.deliverable, spec) else 0.0 total = sum(per_rubric.values()) / len(per_rubric) if per_rubric else 0.0 return ScoreResult(task_id=task.task_id, total=round(total, 4), per_rubric=per_rubric) Through this LineCardAdapter: - Whenever Harness² proposes a new candidate Harness $H’$, adapter.run(harness, task) first calls harness.validate() to check the 24KB OPENCODE_SURFACE limit and Skill Frontmatter rules. - Once validated, the c1..c8 files of $H’$ are materialized into a temporary directory and passed to Go's harness-runner for execution. - After the Go 13-node Graph Workflow finishes, it returns the final LINE message (deliverable) and OpenTelemetry trajectory (trajectory_markdown), which are then passed to adapter.score() for leakage-free independent scoring! --- III. Summary and Teaser for the Next Part In this post (Part 2), we completed the core engineering for Issue #17: 1. Clarified why the golden test set cannot be fed directly to the optimizer — through the TaskBrief Zero-Leakage Barrier, we force Harness² to reason out general skills from behavioral trajectories rather than memorizing answers. 2. Through internal/harness/trajectory.go and cmd/harness-runner/main.go, we transformed Phase 5's OpenTelemetry workflow.node.* Spans into high-resolution trajectory.md, opening the black box of the 13-node Graph Workflow. 3. Established eval/datasets/harness2-challenge-dataset.json (8 major tasks, 32 fine-grained Rubrics) and harness/line_card_adapter.py, perfectly implementing the Harness² BenchmarkAdapter protocol. Everything is ready! In the final post [[Agent Platform in Practice (9)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 3): Dual-Layer Recursive Testing Guide, 62.5% to 100% Performance Results, and the Future of AgentOps](/2026/10/27/adk-graph-workflow-go-9/) (corresponding to Issue #18), we will actually launch the dual-layer recursive optimization experiment, reveal the complete quantitative data evolving from 62.5% to 100.0%, and discuss the profound impact of Harness² on future enterprise-grade Agent development!

  3. DEV · · @evanlin

    [Agent Platform in Action (9)] When ADK Go Graph Workflow Meets Google Research Harness (Part 2): Two-layer Recursive Testing Gu !Harness² Part 3: Recursive Optimization Benchmark & Future AgentOps Outlook Introduction: From "Manual Prompt Tuning" to "Two-Layer Recursive Autonomous Evolution" Welcome to the final part of the Agent Platform in Practice Harness² trilogy! In the previous two articles: - [[Agent Platform in Practice (7)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 1): Understanding the Two-Layer Recursive Agent Harnessing Framework and c1..c8 Decoupling Practice](/2026/10/25/adk-graph-workflow-go-7/) (Issue #16): We introduced Google Research's google-research/harness2 (arXiv:2610.14224) and decoupled the 13-node Graph Workflow of LINE AI Card Master (kkdai/line-card-assistant) into an editable c1..c8 file tree supporting dynamic mounting via LINE_CARD_HARNESS_DIR. - [[Agent Platform in Practice (8)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 2): Turning Phase 4 Eval Datasets and Phase 5 OpenTelemetry Spans into LineCardAdapter](/2026/10/26/adk-graph-workflow-go-8/) (Issue #17): We encapsulated Phase 4's eval/datasets/ and Phase 5's OpenTelemetry workflow.node.* Spans into a LineCardAdapter, establishing a Zero-Leakage Barrier and High-Resolution Behavior Trajectories (trajectory.md). In this post (the final part, corresponding to Issue #18 and PR #19), we will answer the last three key questions: 1. How to test after modifications? (A complete testing guide from Go unit tests, CLI single-point diagnosis, Python two-layer recursive experiments to production environment mounting) 2. What is the overall performance after modifications? (Publicly disclosing quantitative data and evolutionary details from Base 62.5% $\rightarrow$ k=1 84.4% $\rightarrow$ k=2 Parallel 96.9% $\rightarrow$ k=2 Sequential + Memory 100.0%) 3. What are the future outlooks and application scenarios for Harness² and Agents? - * * 1. How to test after modifications? (Step-by-Step Testing and Verification Guide) After introducing Harness², the entire project features four levels of testing and verification: Go native unit testing, single-task OpenTelemetry trajectory diagnosis, Python two-layer recursive evolution experiments, and seamless production environment switching: Level 1: Execute Go c1..c8 Surface Constraints and Project-wide Unit Tests We have written comprehensive unit tests in internal/harness/harness_test.go to verify: - All c1..c8 file trees under harness/base/ and harness/evolved/ comply with the OPENCODE_SURFACE 24KB resident byte limit (AlwaysOnMaxBytes = 24000). - Validate() accurately intercepts and reports errors when c1..c4 exceeds 24,000 bytes. - When a new c6 skill (skills/<name>/SKILL.md) is added but no trigger index (Trigger Line) is added to systemprompt.md or guardrails.md, Validate() immediately intercepts the orphan skill. - TrajectoryRecorder correctly intercepts OpenTelemetry workflow.node.* Spans and renders a Markdown report containing node decisions and diagnostics. Execution command: # Execute Harness² c1..c8 loader and OpenTelemetry trajectory tests $ go test -v ./internal/harness/... === RUN TestLoadBaseHarnessAndValidate --- PASS: TestLoadBaseHarnessAndValidate (0.00s) === RUN TestValidateRejectsOversizedAlwaysOnAndUnreferencedSkill --- PASS: TestValidateRejectsOversizedAlwaysOnAndUnreferencedSkill (0.00s) === RUN TestEvolvedHarnessesValidateAndMatchSkills --- PASS: TestEvolvedHarnessesValidateAndMatchSkills (0.00s) === RUN TestTrajectoryRecorderRendersWorkflowNodeSpans --- PASS: TestTrajectoryRecorderRendersWorkflowNodeSpans (0.00s) PASS ok github.com/kkdai/line-card-assistant/internal/harness # Execute project-wide regression tests (ensuring 100% compatibility with all existing Phase 1~5 features) $ go test ./... ok github.com/kkdai/line-card-assistant/app ok github.com/kkdai/line-card-assistant/cmd/harness-runner ok github.com/kkdai/line-card-assistant/cmd/line-gateway ok github.com/kkdai/line-card-assistant/eval ok github.com/kkdai/line-card-assistant/internal/harness ok github.com/kkdai/line-card-assistant/internal/storage ok github.com/kkdai/line-card-assistant/internal/webhook ok github.com/kkdai/line-card-assistant/internal/workflow Level 2: Use cmd/harness-runner to Validate Any c1..c8 File Tree Before mounting any custom or evolved Harness online, you can use --validate-only to quickly check its c1..c4 resident byte count and YAML Frontmatter compliance: $ go run ./cmd/harness-runner --validate-only --harness-dir=harness/base OK (1599 always-on bytes) $ go run ./cmd/harness-runner --validate-only --harness-dir=harness/evolved/k2_sequential_memory OK (4661 always-on bytes) Level 3: Execute the Four-Stage Harness² Two-Layer Recursive Evolution Benchmark Experiment We implemented a complete two-layer recursive experiment script in harness/run_harness2_experiment.py. It automatically compiles the Go harness-runner and sequentially evaluates four configurations against the 8 major tasks (32 independent Rubrics) in eval/datasets/harness2-challenge-dataset.json, finally outputting a Markdown comparison table and harness/experiment_report.json: $ python3 harness/run_harness2_experiment.py ### Overall Configuration Comparison | Stage | Configuration | Task Score (Max 8.0) | Rubric Pass Rate | Always-On Bytes (Cap 24,000) | Active `c6` Skills | `LongTermMEMORY.md` | | :--- | :--- | :---: | :---: | :---: | :--- | :---: | | `base_k0` | **Base Harness ($k=0$)** | `5.00 / 8.00` | `20/32 (62.5%)` | `1599 B` | `(none)` | `No` | | `k1_single_step` | **Single-Step Propose-Probe-Compose ($k=1$)** | `6.75 / 8.00` | `27/32 (84.4%)` | `2505 B` | `jp_card_ocr, tw_semicon_search` | `No` | | `k2_parallel` | **Multi-Step Parallel Recursion ($k=2$, No Memory)** | `7.75 / 8.00` | `31/32 (96.9%)` | `2998 B` | `executive_email_drafter, hitl_multi_edit, jp_card_ocr, tw_semicon_search` | `No` | | `k2_sequential_memory` | **Multi-Step Sequential Recursion + `LongTermMEMORY.md` ($k=2$)** | `8.00 / 8.00` | `32/32 (100.0%)` | `4661 B` | `executive_email_drafter, hitl_multi_edit, jp_card_ocr, tw_semicon_search` | `Yes` | Level 4: Directly Mount the Best Evolved Harness in Production or Local Servers Once Harness² evolves the optimal harness/evolved/k2_sequential_memory, no modification to any line of Go code is required. Simply specify the directory via the environment variable LINE_CARD_HARNESS_DIR, and both the dual-channel main program (main.go) and the evaluator (eval/) will immediately load the evolved harness tree: $ LINE_CARD_HARNESS_DIR=harness/evolved/k2_sequential_memory go run . - * * 2. Performance Summary: Evolutionary Analysis from 62.5% to 100.0% Let's take a deep dive into the four-stage quantitative experimental data recorded in harness/experiment_report.json to see how Harness² diagnosed and patched the Go Agent's flaws step-by-step! 1. Four-Stage Overall and Single-Task Score Summary | Evaluation Metric / Test Task | Stage 0: Base Harness ($k=0$) (harness/base) | Stage 1: Single-Step Inner Recursion ($k=1$) (k1_single_step) | Stage 2: Multi-Step Parallel Recursion ($k=2$, No Memory) (k2_parallel) | Stage 3: Two-Layer Sequential Recursion + Long-Term Memory ($k=2$) (k2_sequential_memory) | | --- | --- | --- | --- | --- | | baseline_ingest_ming_wang (Standard Chinese Entry) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | | baseline_dedup_appier_promotion (Promotion Deduplication Update) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | | baseline_search_cloud_summit_ai (Standard Keyword Search) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | | baseline_email_google_cloud (General Tech Icebreaker Email) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | 1.00 (4/4) | | challenge_jp_bilingual_ingest (JP-EN Bilingual Card OCR) | 0.25 (1/4) ❌ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | | challenge_colloquial_tw_search (Hsinchu Science Park Colloquial Search) | 0.25 (1/4) ❌ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | | challenge_noisy_multi_edit_hitl (Multi-clause Colloquial HITL Edit) | 0.25 (1/4) ❌ | 0.25 (1/4) ❌ | 1.00 (4/4) ✅ | 1.00 (4/4) ✅ | | challenge_pii_safe_executive_followup (Financial Exec Compliance Icebreaker) | 0.25 (1/4) ❌ | 0.50 (2/4) ⚠️ | 0.75 (3/4) ⚠️ | 1.00 (4/4) ✅ | | Total Task Score (Max 8.00) | 5.00 / 8.00 | 6.75 / 8.00 | 7.75 / 8.00 | 8.00 / 8.00 | | Fine-grained Rubric Pass Rate (Max 32) | 20 / 32 (62.5%) | 27 / 32 (84.4%) | 31 / 32 (96.9%) | 32 / 32 (100.0%) | | Resident Bytes (c1..c4 / 24,000 B) | 1,599 B (6.7%) | 2,505 B (10.4%) | 2,998 B (12.5%) | 4,661 B (19.4%) | | On-demand Skills (c6 Skills) | 0 | 2 | 4 | 4 + LongTermMEMORY | 2. Key Findings Analysis: What evolved in each stage? 🔹 Stage 0 $\rightarrow$ Stage 1 (62.5% $\rightarrow$ 84.4%): Diagnosing the first two on-demand skills (c6) from OpenTelemetry trajectories In the Base Harness ($k=0$), while all 4 Baseline tasks scored full points (4.00/4.00), all 4 Challenge tasks scored only 0.25 because they only passed the RouterNode intent routing: - In the trajectory.md of challenge_jp_bilingual_ingest, the Spans for VisionOCRNode and CleanAndDedupNode diagnosed that the Japanese phone number +81 (0)3-1234-5678 failed to strip the domestic trunk prefix (0), and the Japanese title 取締役 最高技術責任者 (CTO) failed to retain the bilingual title. - In the trajectory.md of challenge_colloquial_tw_search, the QueryIntentNode Span diagnosed that colloquial Taiwanese terms like "Hsinchu Science Park" (竹科) and "Sales Director" (業務總監) were not expanded into bilingual synonyms. - After the first round of inner recursion (Propose -> Probe -> Compose), Harness² automatically synthesized skills/jp_card_ocr/SKILL.md and skills/tw_semicon_search/SKILL.md in k1_single_step, and updated the PII masking rules in guardrails.md, jumping the accuracy from 62.5% to 84.4%! 🔹 Stage 1 $\rightarrow$ Stage 2 (84.4% $\rightarrow$ 96.9%): Second round of recursion conquers multi-clause HITL modification and executive email structure In the second round of inner recursion, Harness² continued to probe the remaining two failing tasks: - For challenge_noisy_multi_edit_hitl (where ApplyEditNode only modified the title in the first clause, missing the mobile suffix replacement and note addition), it evolved skills/hitl_multi_edit/SKILL.md (containing ATOMIC_MULTI_CLAUSE_EDIT rules). - For challenge_pii_safe_executive_followup (where EmailDraftNode lacked structured executive follow-up paragraphs), it evolved skills/executive_email_drafter/SKILL.md. - At this point, under Parallel Recursion (no cross-round long-term memory), the score reached 31/32 (96.9%)! 🔹 Stage 2 $\rightarrow$ Stage 3 (96.9% $\rightarrow$ 100.0%): Why is Sequential + LongTermMEMORY.md needed to complete the final 3.1%? Why did k2_parallel get 96.9% (31/32), yet fail on the 4th Rubric (r4_fintech_compliance_subject_tag) of challenge_pii_safe_executive_followup? - Because in cross-enterprise outgoing emails for financial institutions (e.g., Digital Risk Officer at Cathay Financial Holdings), beyond single-skill email formatting, cross-domain industry compliance memory (FinTech Outbound Compliance Header [Confidential / FinTech Compliance Verified]) is required. - In Sequential Recursion + Experience Reflector mode, the outer recursion reviewed the residual failure trajectories from the first to the second round. The ReflectorPrompt then wrote the FINTECH_COMPLIANCE_SUBJECT_TAG and cross-channel PII protection principles into the resident component c3: LongTermMEMORY.md! - Note the Resident Bytes (Always-On Bytes): even with 4 rich domain skills and long-term memory evolved, the resident size of c1..c4 is only 4,661 bytes (only 19.4% of the 24,000 bytes limit), perfectly avoiding Context Bloat! - * * 3. Future Outlook and Application Scenarios for Harness² and Agents This practical implementation of Google Research Harness² into LINE AI Card Master has shown us a brand-new AgentOps development paradigm. Looking ahead, we believe Harness² has four high-potential application scenarios in the enterprise-grade AI Agent field: flowchart LR subgraph Prod["1. Production Observability (Cloud Run + Agent Runtime)"] Traffic["Real User Traffic\n(LINE Webhook / A2A)"] Trace["Cloud Trace OTel Spans\n+ Negative Feedback (HITL Retry)"] Traffic --> Trace end subgraph Nightly["2. Nightly Autonomous Evolution Flywheel (Nightly Harness² CI/CD)"] Extract["Auto Mask PII & Convert to\nChallenge Dataset Cases"] Evolve["Harness² Two-Layer Recursion\n(Propose-Probe-Compose + Reflector)"] PR["Auto Initiate GitHub PR\nUpdate harness/evolved/ c1..c8"] Extract --> Evolve --> PR end subgraph Scenarios["3. Future Four High-Value Application Scenarios"] S1["Scenario A: Fully Automated Nightly Self-Healing AgentOps"] S2["Scenario B: Cross-Model Harnessing & Tuning"] S3["Scenario C: Multi-Tenant Enterprise Compliance & Skills"] S4["Scenario D: Dual Evolution with ADK Graph Topology (Issue #15)"] end Trace --> Extract PR --> Scenarios 1. Scenario A: Fully Automated "Nightly Self-Healing AgentOps" In the past, when a production LINE Bot encountered frequent "✏️ Edit Data" triggers via HITLConfirmNode or received negative feedback, engineers had to manually analyze Cloud Trace logs the next day. Combining the Cloud Trace established in Phase 5 with the LineCardAdapter from this series, future CI/CD can achieve: - Automatically filtering anonymous Span trajectories from Cloud Trace every night where "users modified data more than twice consecutively" or "search results were 0," and adding them to the Challenge Dataset. - Triggering a GitHub Action to run a round of Harness² two-layer recursion to automatically synthesize new c6 skills or update LongTermMEMORY.md. - Once regression tests (Baseline + Challenge) confirm score improvements and zero regression, the Bot automatically initiates a Pull Request modifying only the harness/ directory. Engineers only need to review the skill Markdown in the morning to deploy with one click! 2. Scenario B: Cross-Model Upgrades and "Harness Compensation" for Lightweight Models Enterprises often encounter behavioral drift when switching underlying models from high-cost models to ultra-fast lightweight models (e.g., switching from Gemini 1.5 Pro to Gemini 1.5 Flash or Flash-Lite to reduce latency and cost by 80%) or upgrading to next-gen models. Through Harness², we can automatically run a round of Propose-Probe-Compose for the target model while keeping the Go code and Benchmark fixed. Harness² will automatically compensate for the lightweight model's gaps with required reasoning auxiliary skills (c6 Skills) and guardrails (c2 Guardrails), using "smarter gear" to bridge the gap. 3. Scenario C: Multi-Tenant Enterprise-Specific Compliance and Domain Memory As seen in challenge_pii_safe_executive_followup, industries like FinTech, MedTech, and the semiconductor supply chain each have distinct terminology dictionaries and compliance guardrails. Because Harness² externalizes the gear as a plain-text file tree, enterprises only need to mount different LINE_CARD_HARNESS_DIR (e.g., harness/tenants/fintech/ vs harness/tenants/semiconductor/) for different departments or tenants. This allows the same Go Container Binary to serve multiple highly regulated vertical industries simultaneously! 4. Scenario D: Dual Evolution of "Topology + Harness" with ADK v2.6+ Advanced Graph Workflow Primitives In Issue #15, we planned ADK v2.6+ features like Parallel dynamic dispatch, MultiRoute multi-path concurrency, and VerifyOCRQualityNode reflection loops. In the future, Harness²'s c5 (sub_agents/) and c8 (plugin/) could even further control Graph Workflow routing thresholds and sub-agent concurrency strategies—achieving an ultimate architecture where "Go is responsible for the type-safe topological skeleton, and Harness² is responsible for dynamically evolving the neural network and skill muscles." - * * 4. Recap of the "Agent Platform in Practice" Series From the first to the ninth post, we have completed a very solid production-grade AI Agent engineering journey: 1. [[Agent Platform in Practice (1)] Building LINE AI Card Master with Go and Google ADK 2.0 Graph Workflow](/2026/10/16/adk-graph-workflow-go/) 2. [[Agent Platform in Practice (2)] From Monolithic Cloud Run to Google Cloud Agent Runtime (Phase 1)](/2026/10/17/adk-graph-workflow-go-2/) 3. [[Agent Platform in Practice (3)] Implementing Cross-Instance Recognition and Pause/Resume with ADK 2.0 GCS Artifact Service and Agent Platform Sessions (Phase 2 Part 1)](/2026/10/20/adk-graph-workflow-go-3/) 4. [[Agent Platform in Practice (4)] Completing the Architecture with Cloud Run LINE Webhook Gateway, Agent Runtime, and Cloud SQL PostgreSQL (Phase 2 Part 2 & Phase 3)](/2026/10/21/adk-graph-workflow-go-4/) 5. [[Agent Platform in Practice (5)] Establishing Automated Evaluation Benchmarks with agents-cli eval and ADK 2.0 Go (Phase 4)](/2026/10/22/adk-graph-workflow-go-5/) 6. [[Agent Platform in Practice (6)] Building a Full-Stack Observable LINE AI Card Master with agents-cli deploy, Terraform IaC, GitHub Actions CI/CD, and Cloud Trace (Phase 5)](/2026/10/24/adk-graph-workflow-go-6/) 7. [[Agent Platform in Practice (7)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 1): Understanding the Two-Layer Recursive Agent Harnessing Framework and c1..c8 Decoupling Practice](/2026/10/25/adk-graph-workflow-go-7/) 8. [[Agent Platform in Practice (8)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 2): Turning Phase 4 Eval Datasets and Phase 5 OpenTelemetry Spans into LineCardAdapter](/2026/10/26/adk-graph-workflow-go-8/) 9. [[Agent Platform in Practice (9)] When ADK Go Graph Workflow Meets Google Research Harness² (Part 3): Two-Layer Recursive Testing Guide, 62.5% to 100% Performance Benchmark, and Future AgentOps Outlook (This post)](/2026/10/27/adk-graph-workflow-go-9/) All code, Terraform IaC, evaluation datasets, and evolved c1..c8 Harness file trees are fully open-sourced on GitHub: kkdai/line-card-assistant. If you are also building enterprise-grade AI Agents with Go and ADK, feel free to Star, Fork, and experience the power of Harness² two-layer recursion!

  4. DEV · · @evanlin

    [Agent Platform in Practice (2)] From Monolithic Cloud Run to Google Cloud Agent Runtime: Refactoring LINE AI Business Card King !LINE Card Assistant + Google Cloud Agent Platform Architecture Recap In the previous article, "[[Agent Platform in Practice (1)] Building a LINE AI Business Card Assistant with Go and Google ADK 2.0 Graph Workflow](/2026/10/16/adk-graph-workflow-go/)", we used Golang (google.golang.org/adk/v2/workflow) and the LINE Messaging API SDK v8 to build line-card-assistant (LINE AI Business Card Assistant) from scratch. We successfully implemented and deployed the four core capabilities of ADK 2.0 Graph Workflow—Deterministic Router, AddFanOut / AddFanIn for parallel background investigation and aggregation, and NewRequestInputEvent for Human-in-the-Loop (HITL) pause and loop correction—to Google Cloud Run. However, as we evolve this monolithic LINE Bot into a Production-grade Agent Platform, we immediately encounter several architectural questions: 1. Where is the cross-instance HITL Session state stored? In the first version, we used session.InMemoryService() with local SQLite. When Cloud Run switches container instances due to auto-scaling or redeployment, the HITL pause state (Checkpoint) waiting for the user to click "✅ Confirm Save" disappears due to memory reset. 2. How can this business card Agent be called by other Agents (Agent-to-Agent, A2A)? If the company has another "CRM Sales Assistant Agent" or "Calendar Scheduling Agent" that wants to query contacts in our Business Card Assistant or ask it to write an icebreaker email via a standard A2A protocol, a monolithic server tied to the LINE Webhook /webhook entry point cannot directly provide a standard Agent interface. 3. How to systematically evaluate and observe the entire Graph? After changing a line of Prompt or adjusting a node, how do we ensure that OCR accuracy, Fan-Out parallel tool call trajectories (Tool Trajectory), and search quality haven't degraded? To bridge the gap between "writing an Agent prototype" and "operating an enterprise-grade Agent," Google officially launched a new command-line and skill toolkit—agents-cli (Google Agents CLI), serving as the unified development entry point for the Google Cloud Agent Platform (now integrated as Gemini Enterprise Agent Platform). In this second episode of the series, we will dive deep into the agents-cli official development guide, answer frequently asked questions about Agent Runtime scaling (Scale-to-Zero) and billing mechanisms, and walk you through how we completed the Phase 1 architecture upgrade (PR #10): refactoring the project into a dual-channel architecture that complies with adk_go + agent_runtime specifications without breaking the original LINE Bot functionality! * 1. In-depth Analysis of Google Cloud Agent Platform and the agents-cli Official Guide Before diving into the code, let's clarify Google's latest technical landscape for the Agent Platform through two core documents: "Getting Started - agents-cli" and "The Lifecycle - agents-cli". 1. Product Terminology Comparison: What are Agent Platform and Agent Runtime? If you've followed Google Cloud's AI product line over the past year, you might have heard of Vertex AI Agent Engine, Reasoning Engine, Agent Platform, or Gemini Enterprise Agent Platform (GEAP). In the latest agents-cli v1.10+ specifications, the official documentation has clearly converged these terms and underlying resources: | Common Developer Terms | Google Cloud Official Positioning & Underlying Resources | agents-cli Corresponding Parameter | | --- | --- | --- | | Agent Platform / Gemini Platform | Google Cloud's overall platform for hosting enterprise-grade AI Agents (covering Runtime, Sessions, Memory Bank, Agent Gateway, Agent Registry, and Eval) | Unified operation via agents-cli | | Agent Runtime / Agent Engine | A fully managed container execution environment designed specifically for AI Agents. Underlying GCP resource path: projects/{project}/locations/{location}/reasoningEngines/{id} | --deployment-target agent_runtime | | Agent Platform Sessions | A cloud-managed conversation and state persistence service (ADK automatically binds VertexAiEngineSessionService) | --session-type agent_platform_sessions | | Gemini Enterprise / Agent Registry | An enterprise Agent publishing and directory center, allowing deployed Agents to be discovered and called directly in the Gemini Enterprise interface or via A2A | agents-cli publish gemini | * 2. The Seven-Stage Development Lifecycle of agents-cli In the past, we were used to a "write code → go test → gcloud run deploy" workflow. However, in "The Lifecycle" document, agents-cli defines a Seven-Stage Quality Flywheel that integrates Coding Agent Skills (supporting Antigravity CLI, Gemini CLI, Claude Code, etc.): flowchart LR P0["Phase 0-1\nUnderstand & Recipes\n(.agents-cli-spec.md)"] --> P2["Phase 2\nScaffold\n(create / enhance)"] P2 --> P3["Phase 3\nBuild & Smoke Test\n(run / playground)"] P3 --> P4["Phase 4\nEvaluate\n(eval run Quality Flywheel)"] P4 -->|Iterative Correction| P3 P4 --> P5["Phase 5\nDeploy\n(agent_runtime / cloud_run)"] P5 --> P6["Phase 6-7\nPublish & Observe\n(Cloud Trace / BigQuery)"] 1. Phase 0 & 1 — Understand & Study Recipes: Before writing a single line of code, document the Agent's core goals, external tools, security boundaries, and deployment targets in the project root's .agents-cli-spec.md as the Single Source of Truth for the entire development cycle. 2. Phase 2 — Scaffold: Use agents-cli scaffold create or run agents-cli scaffold enhance . on an existing project to automatically generate the directory structure, configuration files (agents-cli-manifest.yaml), A2A endpoints, Session switchers, and OpenTelemetry observation modules that comply with official standards. - Supports two major official built-in templates: adk (Python) and adk_go (Golang)! 3. Phase 3 — Build & Playground: - Go projects only need to focus on implementing and exporting NewRootAgent(ctx) in app/agent.go. - During development, you can use agents-cli run "your test question" for a quick terminal smoke test, or run agents-cli playground (or make playground) to open a browser-based visual interactive interface for multi-turn conversation testing. 4. Phase 4 — Evaluate (The core of the Quality Flywheel): The official guide emphasizes: "Unit Test (go test) measures code contracts and type correctness; while agents-cli eval measures Agent behavior quality (Tool Trajectory, response quality, and safety)." 5. Phase 5 — Deploy: Use agents-cli deploy to package and deploy the project to agent_runtime, cloud_run, or gke, and automatically record the deployed resource ID in deployment_metadata.json. 6. Phase 6 & 7 — Publish & Observe: Register to the enterprise directory via agents-cli publish gemini, and combine with OpenTelemetry to automatically export all Graph node latencies, LLM Prompt/Response, and Token consumption to Cloud Trace, Cloud Logging, and BigQuery Agent Analytics. * 3. Agent Runtime vs. Cloud Run vs. GKE: Which Deployment Target Should I Choose? agents-cli supports three containerized deployment targets (all of which build based on the Dockerfile in the project root, making Go a first-class citizen on all three platforms). We have summarized the comparison based on the official Deployment Decision Matrix: | Evaluation Dimension | Agent Runtime (agent_runtime) | Cloud Run (cloud_run) | GKE (gke) | | --- | --- | --- | --- | | Infrastructure Management | Fully managed (built for Agents), automatically built from Dockerfile and mounted to reasoningEngines | Containerized serverless hosting, requires manual configuration of network and service parameters | GKE Autopilot, provides full K8s control but with the highest operational complexity | | Session State Management | Native built-in Agent Platform Sessions (automatically injects GOOGLE_CLOUD_AGENT_ENGINE_ID at runtime and enables cloud persistent Sessions) | In-Memory by default, requires additional Cloud SQL setup or manual connection to managed Sessions | In-Memory by default, requires additional Cloud SQL setup or managed Sessions | | API & Access Interface | Built-in :query / :streamQuery, Console Playground, and /api/{container_path} HTTP Passthrough and A2A | Native public or IAM-protected HTTPS URL, best for receiving external Webhooks (like LINE / Slack) | Custom K8s Ingress / Gateway | | Enterprise Integration | Native support for Managed OAuth 2.0 User Consent, Agent Identity, Agent Gateway, and PSC-I Private Networking | Supports VPC Egress, IAP, but does not support managed Agent OAuth flows | Full K8s network control | | Billing Model | Billed based on actual vCPU-hours + Memory-hours used (no compute fees when scaled to 0) | Billed based on Request duration or Instance seconds | Billed based on K8s Node Pod resources | * 4. Key Clarification: Will Instances Moved to Agent Runtime Scale to Zero? When planning to migrate line-card-assistant to Agent Runtime, a common concern is: > "According to agents-cli specifications, will instances moved to Agent Runtime never scale to 0 and constantly burn money?" The answer is: Agent Runtime supports Scale to Zero, and agents-cli adopts a clever dual-mode design: "Scale to 0 by default for development, and pre-warm 1 instance by default for production!" According to the agents-cli official deployment specifications (Sizing a Deployment): 1. When using agents-cli deploy directly (Default Scale to Zero): - The CLI's default resource allocation is identical to Cloud Run: --cpu 1, --memory 4Gi, --concurrency 8, --min-instances 0, --max-instances 10. - This means when there is no traffic to the Agent Runtime, the number of instances will automatically scale down to 0. Since Agent Runtime is billed based on actual vCPU-hours and Memory-hours, no compute fees are generated when idling at 0! 2. When using Terraform (service.tf) for production deployment (Default min_instances = 1): - When you generate Terraform IaC (deployment/terraform/single-project/service.tf) for a production environment, the template defaults min_instances to 1 to avoid cold starts for the first user. - If you want to save money in production and can tolerate a few seconds of cold start, you can specify agents-cli deploy --min-instances 0 during deployment or change min_instances back to 0 in service.tf. 💡 Why is a "Dual-layer Decoupled Architecture" best for a LINE Bot with Agent Runtime? Understanding the characteristics of Agent Runtime, you'll find that splitting the system into two layers is a perfect match: - Outer Layer: LINE Webhook Gateway (Cloud Run, min-instances=0): Since the LINE server sends Webhooks with only an X-Line-Signature HMAC signature, it cannot carry a Google Cloud IAM OAuth Token to call an IAM-protected Agent Runtime API. Use a lightweight Go Cloud Run service dedicated to "verifying the signature within 1 second, returning HTTP 200, and triggering the LINE loading animation." It scales to 0 when no one is sending business cards. - Inner Layer: Core Brain (Agent Runtime, min-instances=0): Focuses on running the ADK 2.0 Graph Workflow, managing cloud persistent VertexAiEngineSessionService (ensuring HITL pause states aren't lost across containers!), and simultaneously exposing A2A (/.well-known/agent-card.json) and the Agent Engine Playground to the enterprise! * 2. Planning the Migration Blueprint: Creating a 5-Phase Roadmap Using GitHub CLI To complete this architectural evolution methodically, we used the gh CLI in the GitHub Repository (kkdai/line-card-assistant) to establish 5 Milestones (Phases), 8 implementation Issues, and an overview tracking list Roadmap Issue #9: - Phase 1: agents-cli Environment and adk_go Project Scaffolding (The focus of this article!) - <input type="checkbox" disabled checked>#1 Upgrade google-agents-cli to 1.10+ and create .agents-cli-spec.md and agents-cli-manifest.yaml - <input type="checkbox" disabled checked>#2 Introduce adk_go + agent_runtime standard launcher architecture (app/agent.go, A2A, /app-info, Sessions, and Observability) - Phase 2: ADK Go Graph Workflow Cloud Storage and Persistence Adaptation - <input type="checkbox" disabled>#3 Deepen ADK Go v2.6+ Graph Workflow and NewRootAgent state management - <input type="checkbox" disabled>#4 Integrate Agent Platform Sessions (session/vertexai) and GCS Artifact Service to support cross-instance HITL pause/resume - <input type="checkbox" disabled>#5 Expand business card database (CardStore) to support Cloud SQL / Firestore cloud persistent storage - Phase 3: LINE Webhook Gateway and Agent Runtime Decoupled Architecture - <input type="checkbox" disabled>#6 Implement LINE Webhook Gateway (Cloud Run) and Agent Runtime dual-layer decoupled call architecture - Phase 4: agents-cli eval Automated Evaluation and Benchmarking - <input type="checkbox" disabled>#7 Establish agents-cli eval evaluation datasets and quality flywheel (OCR, Fan-Out/Fan-In, HITL loops, Hybrid Search) - Phase 5: Agent Runtime Official Deployment, IaC, and Observability - <input type="checkbox" disabled>#8 Deploy to Agent Runtime via agents-cli deploy and establish Terraform IaC, CI/CD, and Cloud Trace observability * 3. Phase 1 Hands-on Breakdown: Transforming an Existing Go Project into an adk_go + agent_runtime Dual-Channel Architecture In Phase 1 (corresponding to the merged PR #10), our goal was to: retain the original cmd/server/main.go independent LINE Webhook server while introducing the standard agents-cli (adk_go + agent_runtime) architecture in the project root, allowing the same codebase to possess both superpowers! Let's look at the key files changed in this upgrade and the new features they bring: line-card-assistant/ ├── .agents-cli-spec.md # [New] Phase 0 Architecture Spec (Single Source of Truth) ├── agents-cli-manifest.yaml # [New] agents-cli v1.10.0 project config (adk_go + agent_runtime) ├── deployment_metadata.json # [New] Records remote Agent Runtime resource ID and A2A status ├── main.go # [New] Agent Runtime / ADK WebUI / A2A standard HTTP entry point ├── apploader.go # [New] Loader supporting /ui/?app=app and rootAgent.Name() dual aliases ├── appurl.go # [New] Automatically calculates Cloud Run / Agent Runtime public URL (for A2A Card) ├── observability.go # [New] OpenTelemetry Cloud Trace / Logging / Metrics auto-mounting ├── app/ │ ├── agent.go # [New] Standard export interface: NewRootAgent(ctx) returns 12-node Graph Workflow │ └── agent_test.go # [New] Verifies Root Agent execution and search output directly via ADK Runner ├── appinfo/ │ └── appinfo.go # [New] GET /app-info endpoint, auto-parses Root Workflow and embedded CompanyEnrichAgent ├── sessions/ │ └── sessions.go # [New] Auto-switches between VertexAiEngineSessionService and local InMemoryService ├── cmd/server/main.go # [Retained] Original independent LINE Bot Webhook server └── internal/workflow/ # [Enhanced] Graph Workflow nodes upgraded to support dual-channel input and streaming Content output * 1. Creating agents-cli-manifest.yaml and .agents-cli-spec.md To make agents-cli recognize a Go project, the key is the agents-cli-manifest.yaml in the root directory: # Managed by agents-cli. Do not edit manually. acli_version: "1.10.0" project_name: "line-card-assistant" root_agent_name: "line_card_assistant" base_template: "adk_go" language: "go" framework: "google-adk" deployment_target: "agent_runtime" agent_directory: "app" region: "asia-east1" agent_guidance_filename: "GEMINI.md" is_a2a: true prototype: true generated_at: "2026-10-17T16:12:00Z" After configuration, running agents-cli info in the project root allows the CLI to immediately identify this as a Go project that supports A2A and targets deployment to the asia-east1 Agent Runtime: $ agents-cli info CLI version: 1.10.0 Project root: /Users/evanlin/Documents/line-card-assistant Project name: line-card-assistant Root agent name: line_card_assistant Language: go Deployment target: agent_runtime Agent directory: app Region: asia-east1 A2A: yes * 2. Encapsulating the Standard Root Agent Entry Point: app/agent.go In the adk_go template specification, all Agent logic should be exported via NewRootAgent(ctx context.Context) (agent.Agent, error) under the app package. Since we already used ADK 2.0's workflowagent.New to encapsulate the 12-node Graph Workflow into a standard agent.Agent in the first article, in app/agent.go, we only need to initialize the underlying CardStore (SQLite or Memory) and model.LLM (auto-detecting Vertex AI ADC or Gemini API Key), then call workflow.NewCardAssistantAgent for a seamless connection! package app import ( "context" "log/slog" "os" "strings" "github.com/kkdai/line-card-assistant/internal/config" "github.com/kkdai/line-card-assistant/internal/store" "github.com/kkdai/line-card-assistant/internal/workflow" "google.golang.org/adk/v2/agent" "google.golang.org/adk/v2/model" ) const RootAgentName = workflow.WorkflowName // "line_card_assistant" // NewRootAgent constructs the root ADK v2 Graph Workflow agent for Agent Runtime, // ADK WebUI playground, A2A server, and evaluation CLI. func NewRootAgent(ctx context.Context) (agent.Agent, error) { cfg := config.Load() var cardStore store.CardStore if strings.TrimSpace(cfg.SQLitePath) != "" { sqliteStore, err := store.NewSQLiteStore(cfg.SQLitePath) if err != nil { slog.Warn("failed to open SQLite store, falling back to in-memory store", "err", err) cardStore = store.NewMemoryStore() } else { cardStore = sqliteStore } } else { cardStore = store.NewMemoryStore() } var llm model.LLM if cfg.UseVertexAI || cfg.GoogleAPIKey != "" || os.Getenv("GOOGLE_CLOUD_AGENT_ENGINE_ID") != "" { realLLM, err := workflow.NewGeminiModel(ctx, cfg.GeminiModel, cfg.GoogleAPIKey, cfg.UseVertexAI, cfg.GoogleCloudProject, cfg.GoogleCloudLocation) if err != nil { slog.Warn("failed to initialize Gemini model, falling back to mock LLM", "err", err) llm = workflow.NewMockLLM() } else { llm = realLLM } } else { llm = workflow.NewMockLLM() } return NewRootAgentWithDeps(cardStore, llm) } func NewRootAgentWithDeps(cardStore store.CardStore, llm model.LLM) (agent.Agent, error) { return workflow.NewCardAssistantAgent(cardStore, llm) } Note a clever detail here: when deployed in Agent Runtime, the system automatically injects the GOOGLE_CLOUD_AGENT_ENGINE_ID environment variable. When running unit tests (go test ./...) without an API Key, it automatically falls back to workflow.NewMockLLM(), ensuring offline tests remain 100% fast and deterministic! * 3. Agent Runtime Standard HTTP Server and A2A Endpoints (Root main.go) Next, let's look at the new main.go in the project root. When the container is deployed to Agent Runtime (or executed locally via make playground), this entry point mounts five standard services: 1. OpenTelemetry Observability (setupObservability): Automatically exports Traces, Logs, and Metrics to Google Cloud Trace and Cloud Logging. 2. Session Service Auto-switching (sessions.SessionService()): In sessions/sessions.go, if VERTEX_AI_SEARCH_ENGINE_ID (Agent Engine ID) is detected, it automatically enables the cloud persistent NewVertexAiEngineSessionService; for local development, it seamlessly uses session.InMemoryService(). 3. ADK REST API and WebUI (/api/ and /ui/): Directly starts the ADK built-in streaming conversation API and web debugging interface via api.NewLauncher() and webui.NewLauncher(). 4. A2A (Agent-to-Agent) Protocol Server: Automatically exposes this Agent's capability description card (AgentCard) at /.well-known/agent-card.json and provides a JSON-RPC call endpoint at /a2a/app! 5. Agent Structure Introspection Endpoint (GET /app-info): Analyzes the entire Agent Tree via appinfo/appinfo.go. It not only lists the Root Graph Workflow but also registers the CompanyEnrichAgent (gemini-2.5-flash)—which is embedded in CompanyEnrichNode and equipped with geminitool.GoogleSearch{}—into the /app-info response. This allows agents-cli eval and the Google Cloud Console Playground to clearly see all sub-agents and tools within the system! * 4. Key Transformation: Enabling Graph Workflow to Support Both "LINE Webhook" and "ADK WebUI / A2A" Dual Channels! In the first version, our RouterNode assumed all input came from the LINE Webhook Handler, which pre-filled StateKeyInputType or StateKeyImageBytes into the Session State. Furthermore, terminal nodes (like FormatCarouselNode, BuildPreviewNode, SaveCardNode) only wrote results to StateKeyReplyText and StateKeyFlexCarouselJSON for the LINE Bot to retrieve and call ReplyMessage. However, when we open the ADK WebUI (make playground), the Agent Engine Console Playground, or talk directly to the Root Agent via agents-cli run "Find someone who does AI for me", the input follows the standard ADK ctx.UserContent() (containing text Part.Text or uploaded image Part.InlineData), and the output is read from ev.Content in the event stream! Therefore, we made two fully backward-compatible enhancements in internal/workflow/nodes_ingest.go and internal/workflow/nodes_search.go: (1) RouterNode Automatically Parses Images and Text from ctx.UserContent() // When uploading a business card image directly from ADK WebUI / A2A / Agent Engine Playground, // automatically extract InlineData.Data from ctx.UserContent().Parts and direct to RouteIngest! if 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 { setState(ctx, ev, StateKeyImageBytes, part.InlineData.Data) if getStringState(ctx, StateKeyInputType) == "" { setState(ctx, ev, StateKeyInputType, RouteIngest) } break } } } // If it's text input and the input parameter is empty, automatically extract query text or commands from ctx.UserContent().Parts if inputType == "" { sTrim, _ := input.(string) sTrim = strings.TrimSpace(sTrim) if sTrim == "" && ctx.UserContent() != nil { for _, part := range ctx.UserContent().Parts { if part != nil && strings.TrimSpace(part.Text) != "" { sTrim = strings.TrimSpace(part.Text) break } } } // Automatically determine if it's searching for a card (RouteSearch) or generating an icebreaker (action=draft_email&card_id=...) ... } (2) Terminal Nodes Synchronously Write to ev.Content In BuildPreviewNode, SaveCardNode, FormatCarouselNode, and EmailDraftNode, in addition to writing to StateDelta for the LINE Bot, we added a line: ev.Content = genai.NewContentFromText(flexJSON, genai.RoleModel) This way: - Sending or searching cards from LINE: The LINE Webhook still gets the Flex JSON from StateDelta to render the beautiful LINE card. - Testing from ADK WebUI (make playground) or Agent Engine Playground: Without needing a LINE account, you can upload a business card image or type "Find someone I met at Cloud Summit who does AI" directly in the browser. You'll see the full execution trajectory (Graph Node transitions) and the output Flex JSON / icebreaker draft directly in the conversation window! We also added a TestRootAgentDirectSearchExecution unit test in app/agent_test.go, using the standard ADK runner.Runner to send a genai.Content query to app.NewRootAgent, verifying that the business card Carousel JSON can be obtained directly from the Event Stream without going through the LINE Engine! * 5. Multi-binary Dockerfile: One Image for Both Agent Runtime and LINE Webhook Finally, to allow the same container image to be deployed to Agent Runtime via agents-cli deploy and to Cloud Run as an independent LINE Bot server, we updated the Dockerfile to compile two static executables in a multi-stage build: # Build the Agent Runtime / ADK WebUI / A2A entrypoint binary (root main.go) RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/agent . # Also build the standalone LINE Bot Webhook server binary (cmd/server/main.go) RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -ldflags="-s -w" -o /out/line-card-assistant ./cmd/server - The default ENTRYPOINT ["/agent"] starts the Agent Runtime standard server (listening on 0.0.0.0:8080). - If it needs to run as a monolithic LINE Webhook Server on Cloud Run, simply specify the Container Command as /bin/line-card-assistant! * 4. Summary and Teaser for the Next Step Through the Phase 1 transformation (PR #10), our LINE AI Business Card Assistant (line-card-assistant) has officially evolved from a simple LINE Bot application into an adk_go Agent that complies with Google Cloud Agent Platform standards: 1. Full support for the agents-cli ecosystem: Equipped with agents-cli-manifest.yaml and .agents-cli-spec.md. 2. Dual-channel execution capability: Can interact via LINE chat and directly through the ADK WebUI via make playground, be called by other Agents via the A2A protocol, or undergo multimodal business card recognition and search testing in the Agent Engine Playground. 3. Cloud-native ready: Built-in VertexAiEngineSessionService auto-switching and OpenTelemetry Cloud Trace tracking capabilities. In the next article of this series, we will enter Phase 2 & Phase 3, where we will connect HITL pause states and image artifacts to cloud persistent storage and refactor the system into a dual-layer decoupled architecture of "Cloud Run LINE Webhook Gateway + Agent Runtime Core Brain." Stay tuned! * Reference Resources and Further Reading - First article in the series: [[Agent Platform in Practice (1)] Building a LINE AI Business Card Assistant with Go and Google ADK 2.0 Graph Workflow: From Fan-Out Parallel Retrieval to HITL Pause Loop Correction](/2026/10/16/adk-graph-workflow-go/) - Open source code and Roadmap for this project: - GitHub Repository: kkdai/line-card-assistant - Agent Runtime Migration Roadmap: Issue #9 - Phase 1 Implementation Pull Request: PR #10: feat(phase1): scaffold adk_go Agent Runtime project structure and app.NewRootAgent - Google agents-cli and Agent Platform official documentation: - Getting Started - agents-cli - The Lifecycle - agents-cli - Google ADK Graph Workflow official documentation: - Graph Workflows in ADK: Everything You Need to Know (Google Cloud Blog) - Graph-based agent workflows (adk.dev/graphs)

    1 reaction
  5. 名片助手该在哪一步等你确认? Evan Lin的示例:OCR后并行查重、搜公司、从近期聊天推断会面背景;等分支结束,再整理预览。 修改后重新预览,确认才保存,之后仅生成邮件草稿。 把人工确认放在补全之后、存档之前。 固定源码解读,未实测。 github.com/kkdai/line-card-assistant/blob/19296ec…

    16 views
  6. DEV · · @evanlin

    [Agent Platform in Action (1)] Building a LINE AI Business Card Master with Go and Google ADK 2.0: From Fan-Out Parallel Retrieval to HITL Pause !image-20261017135128284 Recap During the AI Agent development craze over the past year, the first Agent many developers wrote often looked like this: writing an incredibly long System Instruction, then stuffing five or six Tools into the same LLMAgent, and praying the model would obediently follow steps A → B → C → D. It looks impressive as a Demo initially, but as soon as it hits a production environment, problems immediately surface: - The more steps, the higher the rate of losing control: When a Prompt is stuffed with "First do OCR, then check the database for duplicates, simultaneously search online for what this company does, then assemble a preview card to ask the user, if the user wants to change the job title then regenerate the preview, and only write to the DB and write an icebreaker email after confirmation," the model occasionally skips the duplicate check or writes directly to the database before waiting for user confirmation. - Burning money and waiting in places where Tokens shouldn't be spent: Logic that is 100% deterministic, such as "shunting based on LINE event type" or "normalizing phone number formats," is not only slow when handed to an LLM but also prone to errors. - Independent tasks are forced to execute in a queue: Checking the database, Google searching company backgrounds, and comparing recent conversation records are clearly independent of each other, yet they become sequential waits due to the linear conversation loop of a single Agent. To solve this pain point, Google officially introduced a heavyweight feature in Agent Development Kit (ADK) 2.0 —— Graph Workflows, with simultaneous support for Python and Go (google.golang.org/adk/v2/workflow)! After seeing this update, I immediately decided to use my most familiar Golang + LINE Messaging API SDK v8 to build a complete open-source project line-card-assistant (LINE AI Business Card Master). I put all the core weapons of ADK 2.0 Graph Workflow (AddRoute conditional routing, AddFanOut / AddFanIn parallel processing and aggregation, NewRequestInputEvent Human-in-the-Loop pause/wait and loop correction) through a real-world battle test and deployed it on Google Cloud Run. This article will first guide you through two core technical documents that are must-reads for Google's official ADK Graph Workflows, and then break down step-by-step how we used Go code to implement these graph workflow patterns in a LINE Bot! - * * I. How Google Officially Defines Graph Workflow? Analysis of Two Must-Read Guides Before looking at the code, it is strongly recommended to understand the core philosophy of the ADK team's design for Graph Workflows. Google recently released two very critical developer guides: 1. Google Cloud Official Blog: Graph Workflows in ADK: Everything You Need to Know (Written by Annie Wang and Shangjie Chen of the ADK team) 2. ADK Official Technical Documentation: Graph-based agent workflows (adk.dev/graphs) We have condensed and organized the essence of these two official articles as follows: 1. What is "Graph Engineering"? In the article "Graph Workflows in ADK: Everything You Need to Know", the authors use a "Customer Refund Workflow" as an example to point out a core concept: > As workflows become more complex, reliability comes from separating "Workflow Control" from "Model Reasoning." The essence of Graph Engineering is to decompose tasks into independent nodes, connect them with edges, and explicitly decide at each step whether "Code," "Models," or "People" should determine the next step. The article summarizes five of the most commonly used Graph Workflow design patterns in the real world: | Core Pattern | What pain point does it solve? | ADK 2.0 Corresponding Mechanism | | --- | --- | --- | | 1. Fan-out and Fan-in Parallel Aggregation | Multiple independent data collection steps (e.g., checking orders, payments, customer records) slow down overall latency if executed sequentially. | Use AddFanOut to trigger multiple nodes to execute in parallel, then use a JoinNode (AddFanIn) to wait for all branches to complete before aggregating, consuming zero extra LLM calls in between. | | 2. Deterministic Routing vs. Agent Routing | Some shunting rules are black and white (e.g., amount > $100 goes to manual review) and shouldn't be guessed by an LLM; others require understanding natural language intent. | Deterministic Router: Pure code logic sets ev.Routes, 0 Token consumption, 100% testable. <br> Agent Router: Intent is judged by the LLM, but graph edges strictly limit valid jump targets. | | 3. Human-in-the-Loop (HITL) Pause and Resume | High-risk actions (e.g., large refunds, writing to production databases) must be confirmed by a human, but traditional Webhooks are stateless short connections. | A node issues a RequestInput event to freeze the execution state (Checkpoint) and pause the Graph; after receiving an external human response, RerunOnResume wakes up the same node to continue. | | 4. Parallel Workers vs. Fan-out | Distinguishes between "performing three different checks on the same data" and "performing the same processing on N items." | Fan-out: Different nodes perform different responsibilities. <br> Parallel Workers: The same node expands in parallel for array elements. | | 5. Static Graph vs. Dynamic Orchestration | How to balance "architectural visualization/maintainability" with "dynamic loop correction"? | Use static Edges to define clear routing and Conditional Cycles (e.g., generate draft → check/modify → regenerate), making the entire system's state machine clear at a glance. | - * * 2. The Four Core Building Blocks of ADK Go v2.0 (adk.dev/graphs) In the official document "Graph-based agent workflows", the API design for ADK 2.0 in the Go language (google.golang.org/adk/v2/workflow) is shown in detail. To assemble a Graph in Go, you mainly need to master the following four components: 1. Nodes: - workflow.NewFunctionNode / workflow.NewEmittingFunctionNode: Wraps any standard Go function into a graph node, suitable for DB queries, data cleaning, API calls, or assembling LINE Flex Messages. - llmagent.RunLLMAgentAsNode: Encapsulates an llmagent with a specific Prompt and tools (e.g., geminitool.GoogleSearch{}) into a single node, allowing the LLM to focus on doing "one thing" well. 2. State & Data Flow: - In addition to passing strongly-typed Output between nodes, structured data can be written to the Session State (ctx.Session().State()) via ev.Actions.StateDelta for downstream or aggregation nodes to read. 3. Edges & EdgeBuilder: - eb.Add(from, to): Unidirectional straight connection. - eb.AddRoute(from, workflow.StringRoute("route_name", targetNode), ...): Conditional branch routing (can also be used to create Cycles!). - eb.AddFanOut(from, branch1, branch2, ...) and eb.AddFanIn(joinNode, branch1, branch2, ...): Parallel branching and synchronous aggregation. 4. WorkflowAgent: - Finally, call workflowagent.New(workflowagent.Config{Edges: edges}), and the entire Graph becomes a standard ADK agent.Agent, which is then handed over to the runner.Runner for execution and Session management! - * * II. Practical Project Architecture: LINE AI Business Card Master (line-card-assistant) Having understood the official theory, let's see how to apply these concepts to a real-world LINE Bot scenario. Business professionals receive a large number of paper business cards at exhibitions or meetings every day. Traditional business card apps only offer simple OCR recognition but lack features like "What has this company been doing lately?", "At which event did I meet this person?", and "How can I correct recognition errors directly by speaking in the chat room?". Therefore, we designed line-card-assistant, with an overall architecture that fully corresponds to ADK 2.0's Graph Workflow: [LINE Webhook] │ ▼ [Router Node (AddRoutes)] ├── (Received business card photo) ──► [Vision OCR Node] │ │ │ (AddFanOut Parallel Processing) │ ┌──────────────┼──────────────┐ │ ▼ ▼ ▼ │ [DB Dup Check & Validation] [Company Background Search] [Past Interaction Retrieval] │ └──────────────┼──────────────┘ │ (AddFanIn Aggregation) │ ▼ │ [Generate Flex Message Preview Card] │ │ │ ⏸️ NewRequestInputEvent (HITL Pause & Wait) │ │ │ ┌────────────────────┴────────────────────┐ │ ▼ (Click: Confirm Save) ▼ (Click: Modify Field / Add Note) │ [Write to DB + Generate Icebreaker Draft] [Re-entry Loop Correction Node] │ └── (Received query text) ──► [Query Rewrite] ──► [SQL + Vector Hybrid Retrieval] ──► [Flex Carousel Card Rotation] This architecture diagram covers three core paths: 1. Business Card Recognition and Enrichment Pipeline (Ingest Pipeline): Receive photo → Vision OCR → Execute three tasks in parallel (DB duplicate check + Google Search company background + Conversation context inference for meeting occasion) → Aggregate to generate LINE Flex preview card → HITL pause to wait for user confirmation or modification. 2. HITL Loop Correction (Re-entry Loop): When the graph is paused at HITLConfirmNode, if the user enters text directly in LINE (e.g., "_Change job title to Director, met at Cloud Summit_"), the workflow wakes up and takes the RouteEdit branch into ApplyEditNode, updates the fields, and loops back to BuildPreviewNode to regenerate the preview card and pause again, until the user clicks "✅ Confirm Save" to actually write to the database and generate the icebreaker email! 3. Natural Language Hybrid Retrieval Pipeline (Search Pipeline): Receive query text (e.g., "_Help me find people doing AI I met at Cloud Summit last week_") → Query Rewrite structural decomposition → SQL + Keyword/Tag hybrid retrieval → Return LINE Flex Carousel business card rotation. - * * III. Go Code Deep Dive: Writing Graph Workflow into a LINE Bot Next, let's look directly at the Go implementation of internal/workflow/graph.go and various nodes to see how clean the ADK 2.0 API looks in Go. 1. A Global View: Assembling the Graph with EdgeBuilder In internal/workflow/graph.go, we declare and connect all nodes via the BuildEdges function. You can clearly see the topological structure of the entire graph: func BuildEdges(deps *Dependencies) ([]workflow.Edge, error) { // 1. Entry Router Node routerNode, _ := NewRouterNode() // 2. Business Card Recognition and Fan-Out Three Parallel Nodes visionOCRNode, _ := NewVisionOCRNode(deps) cleanAndDedupNode, _ := NewCleanAndDedupNode(deps) companyEnrichNode, _ := NewCompanyEnrichNode(deps) contextMatchNode, _ := NewContextMatchNode(deps) joinPreviewNode := workflow.NewJoinNode("JoinPreviewNode") // 3. Preview Card Generation, HITL Pause, and Confirm/Modify Branch Nodes buildPreviewNode, _ := NewBuildPreviewNode(deps) hitlConfirmNode, _ := NewHITLConfirmNode(deps) saveCardNode, _ := NewSaveCardNode(deps) applyEditNode, _ := NewApplyEditNode(deps) // 4. Natural Language Search and Icebreaker Email Nodes queryIntentNode, _ := NewQueryIntentNode(deps) hybridSearchNode, _ := NewHybridSearchNode(deps) broadSearchNode, _ := NewBroadSearchNode(deps) formatCarouselNode, _ := NewFormatCarouselNode(deps) emailDraftNode, _ := NewEmailDraftNode(deps) eb := workflow.NewEdgeBuilder() // Entry -> Deterministic Router eb.Add(workflow.Start, routerNode) eb.AddRoute(routerNode, workflow.StringRoute(RouteImage, visionOCRNode), workflow.StringRoute(RouteText, queryIntentNode), workflow.StringRoute(RoutePostbackDraft, emailDraftNode), ) // Ingest Pipeline: Vision OCR -> Parallel Fan-Out -> JoinNode Fan-In -> Preview eb.AddFanOut(visionOCRNode, cleanAndDedupNode, companyEnrichNode, contextMatchNode) eb.AddFanIn(joinPreviewNode, cleanAndDedupNode, companyEnrichNode, contextMatchNode) eb.Add(joinPreviewNode, buildPreviewNode) eb.Add(buildPreviewNode, hitlConfirmNode) // Conditional branches after HITL pause: Confirm Save vs. Modify Field Loop eb.AddRoute(hitlConfirmNode, workflow.StringRoute(RouteConfirm, saveCardNode), workflow.StringRoute(RouteEdit, applyEditNode), ) // ★ Key Cycle: After modifying fields, route back to buildPreviewNode to regenerate Flex preview card and enter HITL again! eb.AddRoute(applyEditNode, workflow.StringRoute(RoutePreview, buildPreviewNode), ) // Search Pipeline: Query Rewrite -> Hybrid Search -> (Found / Empty Fallback) -> Carousel eb.Add(queryIntentNode, hybridSearchNode) eb.AddRoute(hybridSearchNode, workflow.StringRoute(RouteFound, formatCarouselNode), workflow.StringRoute(RouteEmpty, broadSearchNode), ) eb.Add(broadSearchNode, formatCarouselNode) return eb.Build() } This code perfectly embodies the Static Graph advantage mentioned in the Google Cloud Blog: you don't need to flip through thousands of lines of Prompts or track down Tool Calls scattered everywhere. Just by looking at the 30 lines of code in BuildEdges, you can 100% grasp all possible paths and loop boundaries of the entire Agent system. - * * 2. Deterministic Router: Precise Shunting with 0 Token Consumption When the LINE Webhook receives an event, we don't need to waste a Gemini API call to ask the model "Is the user sending a picture or text?". We use workflow.NewEmittingFunctionNode to create a pure code deterministic routing node RouterNode: const ( RouteImage = "route_image" RouteText = "route_text" RoutePostbackDraft = "route_postback_draft" ) func NewRouterNode() (workflow.Node, error) { return workflow.NewEmittingFunctionNode("RouterNode", func(ctx workflow.Context, _ struct{}) (string, error) { inputType, err := getStateString(ctx, StateKeyInputType) if err != nil || inputType == "" { inputType = "text" } var route string switch inputType { case "image": route = RouteImage case "postback_draft": route = RoutePostbackDraft default: route = RouteText } // Emit an event with Routes tags to let EdgeBuilder's AddRoute decide the next node ev := workflow.NewEvent(ctx, "route_selected") ev.Routes = []string{route} if err := ctx.Emit(ev); err != nil { return "", err } return route, nil }, workflow.WithDeclaredRoutes(RouteImage, RouteText, RoutePostbackDraft)) } As long as you set ev.Routes = []string{route} when emitting a workflow.Event, and use workflow.WithDeclaredRoutes(...) to validate route legitimacy during construction, ADK will automatically check for misspelled Route names during the startup phase! - * * 3. Fan-Out / Fan-In Parallel Processing: Running DB Dup Check, Google Search, and Context Inference Simultaneously Once VisionOCRNode extracts a structured models.BusinessCard from the business card photo via gemini-2.5-flash's multimodal capabilities, we have three completely independent tasks to perform: 1. CleanAndDedupNode (Pure Code + DB Query): Normalizes Taiwan mobile and landline numbers to +886-... format and queries the SQLite database to see if a business card for the same person already exists (if the job title is different, it can even mark a "Promotion/Transfer Reminder"!). 2. CompanyEnrichNode (Embedded llmagent with Google Search tool): Automatically searches the web for the latest background of the company based on the company name on the business card, condensing it into a 2-sentence summary and 3~5 industry tags. 3. ContextMatchNode (LLM Context Inference): Reads the user's most recent LINE conversation records before and after sending the photo (e.g., the user just sent "_I'm attending Cloud Summit today_") to automatically infer the meeting occasion for this business card (MetContext). In internal/workflow/nodes_ingest.go, we can even use llmagent.RunLLMAgentAsNode inside CompanyEnrichNode to call a sub-agent with geminitool.GoogleSearch{}: // Combine LLMAgent with Google Search Grounding capability within a Graph node searchAgent, err := llmagent.New(llmagent.Config{ Name: "CompanySearchSubAgent", Description: "Searches the web for company background and industry tags.", Model: deps.SearchModel, Instruction: "Given a company name, search Google and return JSON with summary (2 sentences in Traditional Chinese) and tags (3 to 5 industry tags).", Tools: []tool.Tool{geminitool.GoogleSearch{}}, }) if err == nil { _ = llmagent.RunLLMAgentAsNode(searchAgent, llmagent.NodeConfig{Name: "CompanySearchSubAgentNode"}) } 💡 Practical Details of Go Parallel Nodes: How to Avoid Race Conditions? When running eb.AddFanOut in Go, the three branch nodes execute in parallel in different Goroutines. If three nodes simultaneously read and write to the same BusinessCard JSON object and write back to the same State Key, a Lost Update (last write wins) race condition will occur! To solve this problem, we adopted a very clean technique in the implementation: - Write to independent StateDelta Keys: - CleanAndDedupNode only writes to StateKeyDedupCard, StateKeyIsDuplicate, StateKeyOldTitle. - CompanyEnrichNode only writes to StateKeyCompanySummary and StateKeyCompanyTags. - ContextMatchNode only writes to StateKeyMetContext. - ADK 2.0's Runner will sequentially Commit the ev.Actions.StateDelta emitted by each node in the main Goroutine. - Once JoinPreviewNode (eb.AddFanIn) confirms that the three parallel branches are complete, the single-threaded BuildPreviewNode uses loadMergedCardFromState(ctx) to safely merge the results of the three branches into a complete BusinessCard and renders a beautiful LINE Flex Message preview card! - * * 4. Human-in-the-Loop (HITL): NewRequestInputEvent Pause and Re-entry Loop Correction The most exciting part of this entire system is the Human-in-the-Loop pause and resume mechanism. In traditional LINE Bot development, if you want to achieve "send preview card → wait for user to click confirm or type modification → send new preview card after modification → save after confirmation," you usually have to maintain a complex user_state_machine table in the database yourself. But in ADK 2.0 Graph Workflow, this is built directly into HITLConfirmNode! Let's see how NewHITLConfirmNode in internal/workflow/nodes_ingest.go is written: func NewHITLConfirmNode(_ *Dependencies) (workflow.Node, error) { rerun := true return workflow.NewEmittingFunctionNode("HITLConfirmNode", func(ctx workflow.Context, _ struct{}) (string, error) { activeID, _ := getStateString(ctx, StateKeyActiveInterruptID) // [Phase 1: First entry to node] Not yet paused, emit NewRequestInputEvent to pause Graph! if activeID == "" { interruptID := fmt.Sprintf("hitl-card-%d", time.Now().UnixNano()) if err := emitStateDelta(ctx, map[string]any{ StateKeyActiveInterruptID: interruptID, }); err != nil { return "", err } reqEv := workflow.NewRequestInputEvent(ctx, session.RequestInput{ InterruptID: interruptID, Payload: map[string]any{ "prompt": "Please confirm if the business card info is correct. Click 'Confirm Save' or enter modifications directly (e.g., change title to Director).", }, }) if err := ctx.Emit(reqEv); err != nil { return "", err } return "waiting_for_user", nil } // [Phase 2: Wake up and rerun after receiving user response (RerunOnResume: true)] resumedVal, err := ctx.ResumedInput(activeID) var decision HITLResumePayload if err == nil && resumedVal != nil { _ = decodeViaJSON(resumedVal, &decision) } // Clear the consumed InterruptID so that if the Edit loop comes back later, it can trigger a pause again! _ = emitStateDelta(ctx, map[string]any{ StateKeyActiveInterruptID: "", StateKeyHITLAction: decision.Action, StateKeyHITLEditText: decision.EditText, }) route := RouteConfirm if strings.EqualFold(decision.Action, "edit") || (decision.Action != "confirm" && strings.TrimSpace(decision.EditText) != "") { route = RouteEdit } ev := workflow.NewEvent(ctx, "hitl_decision") ev.Routes = []string{route} if err := ctx.Emit(ev); err != nil { return "", err } return route, nil }, workflow.WithNodeConfig(workflow.NodeConfig{ RerunOnResume: &rerun, // ★ Key setting: Rerun this node function when external input resumes }), workflow.WithDeclaredRoutes(RouteConfirm, RouteEdit)) } How does the LINE Webhook feed user button clicks or text back to the paused Graph? When HITLConfirmNode emits a NewRequestInputEvent, our Engine (located in internal/workflow/engine.go) captures this InterruptID and pushes the LINE Flex Message preview card generated by BuildPreviewNode to the user. Then: - If the user clicks the "✅ Confirm Save" button (Postback Event) on the Flex Message, the LINE Webhook calls Engine.ResumeHITL(ctx, userID, HITLResumePayload{Action: "confirm"}). - If the user types directly in the chat room "Change title to Technical Director, met at AI conference" (Text Event), the LINE Webhook detects that the user has a PendingHITL waiting, automatically intercepts this text, and calls Engine.ResumeHITL(ctx, userID, HITLResumePayload{Action: "edit", EditText: text})! Inside Engine.ResumeHITL, you only need to send a FunctionResponse with workflow.WorkflowInputFunctionCallName to the ADK Runner, and the frozen Graph will seamlessly continue execution: msg := &genai.Content{ Role: string(genai.RoleUser), Parts: []*genai.Part{ { FunctionResponse: &genai.FunctionResponse{ ID: pending.InterruptID, Name: workflow.WorkflowInputFunctionCallName, Response: map[string]any{ "action": payload.Action, "edit_text": payload.EditText, }, }, }, }, } Once it enters RouteEdit, ApplyEditNode corrects the business card data, then loops back to BuildPreviewNode via eb.AddRoute(applyEditNode, workflow.StringRoute(RoutePreview, buildPreviewNode)), generating an updated Flex preview card and stopping again at HITLConfirmNode to wait for user confirmation. This is where Graph Workflow is most powerful for handling multi-turn interactive corrections! - * * 5. Natural Language Hybrid Retrieval Pipeline (Query Rewrite + Hybrid Search + Flex Carousel) When the user is not waiting for business card confirmation but directly enters query text (e.g., "_Help me find people doing AI I met at Cloud Summit last week_"), the RouterNode directs the request to the RouteText branch (defined in internal/workflow/nodes_search.go): 1. QueryIntentNode: Uses Gemini to decompose natural language into a structured SearchFilter (including Keywords, Tags, Company, Title, MetContext). 2. HybridSearchNode: Combines SQL condition filtering with tag/keyword matching and simultaneously acts as a Deterministic Router: - If business cards are found → Emit RouteFound to go directly to FormatCarouselNode. - If precise conditions yield no results → Emit RouteEmpty to automatically enter BroadSearchNode for fuzzy/relaxed retrieval (Fallback), and finally FormatCarouselNode renders up to 10 LINE Flex Carousel business cards, each with a one-click "✉️ Generate Icebreaker" Postback button! - * * IV. Deployment: Cloud Run + Self-Identity Security Authentication After completing development and passing all unit tests (go test ./...), we deployed the service to Google Cloud Run (asia-east1). In the cloud deployment architecture, we can use Cloud Run's Service Account Self-Identity (Application Default Credentials, ADC) mechanism: - By binding a Service Account with roles/aiplatform.user permissions to Cloud Run and setting environment variables GOOGLE_GENAI_USE_VERTEXAI=true and GOOGLE_CLOUD_PROJECT, the application can directly call Vertex AI / Gemini models using the Identity provided by the Cloud Run execution environment, eliminating the risk of managing static API Key leaks. - If developing locally or using the Gemini Developer API, you can easily switch via environment variables: gcloud run deploy line-card-assistant \ --source . \ --region asia-east1 \ --allow-unauthenticated \ --set-env-vars="LINE_CHANNEL_SECRET=your_secret,LINE_CHANNEL_ACCESS_TOKEN=your_token,GEMINI_MODEL=gemini-2.5-flash" - * * V. Summary of Pitfalls and Practical Experience After deeply integrating Google ADK 2.0 Graph Workflow (google.golang.org/adk/v2/workflow) with LINE Bot, there are several practical insights worth sharing with Go developers: 1. Keep Prompts Small, Leave the Flow to the Graph: Previously, a single super Agent required hundreds of lines of Prompts and often missed steps; now, the Prompt for each node (such as VisionOCRNode, CompanyEnrichNode, ContextMatchNode) is only a few lines long, with a single responsibility and fixed output format, resulting in a quantum leap in stability. 2. AddFanOut Significantly Reduces Perceived Latency: In a LINE chat room, users hate waiting after sending a business card photo. By using AddFanOut to simultaneously perform DB duplicate checks, Google Search company background investigations, and context inference, the total time taken depends only on the slowest branch, rather than the sum of all three steps. 3. Use StateDelta to Isolate Parallel Writes: When using AddFanOut in Go, remember to have each parallel branch write to its own dedicated Session State Key, and then merge them uniformly after passing through the JoinNode (AddFanIn) to elegantly avoid concurrent overwrite issues. 4. NewRequestInputEvent + RerunOnResume is a Secret Weapon for Chatbots: Whether it's a LINE Postback button or free text input, they can all be uniformly encapsulated into a FunctionResponse for WorkflowInputFunctionCallName to wake up the same Graph Session, completely bidding farewell to hand-coded spaghetti-style state machines! The complete project source code, architecture diagrams, and deployment scripts are all open-sourced on GitHub. You are welcome to clone it and play around, or directly transform it into your own enterprise-grade LINE Agent workflow! - * * References and Further Reading - Open Source Code for this Project: kkdai/line-card-assistant (GitHub) - Google Cloud Official Blog: Graph Workflows in ADK: Everything You Need to Know - ADK Official Documentation: Graph-based agent workflows - Agent Development Kit (ADK)

    6 reactions

Latest stargazers

The last 10 distinct accounts we observed this year.

Most active participants

PR, issue, comment & review events in the past year.

Packages

No packages found.

Live mentions badge

Show how many posts mention your project in the last 30 days.

Live mentions · last 30 days
kkdai/line-card-assistant mentions in the last 30 days