跳到正文
DEV Community· Evan Lin·· 3 小時前AI 評分57

AI 實踐:LINE Research Bot 從 Python Managed Agents 重寫為 Go ADK v1.9.0,並由沙盒檔案改用 Session State

[AI in Practice] Rewriting a LINE Research Bot from Python Managed Agents to Go ADK v1.9.0: From Sandbox Files to Session State,

AI 導讀

Evan Lin 將開源 LINE Research Planner Bot 從 Python 3.11 + Managed Agents 重寫為 Go 1.27 + ADK for Go v1.9.0,並以 ADK Session State 配合 Firestore 保存跨輪研究資料。

正文

LINE Research Bot: Managed Agents (Python) → ADK for Go v1.9.0 on Cloud Run

Recap

In the previous post "Comprehensive Analysis of Managed Agents API", I created an open-source LINE Research Planner Bot: kkdai/line-research-bot.

How it works is simple:

  • Send " Research the selection of SOTA open-source vector databases " to the LINE Bot. The Bot will sequentially run through three stages: PLAN → SEARCH_COMPARE → WRITE_REPORT. First, it designs 4–8 sets of search keywords, then uses Google Search + URL Context to read real web pages and cross-compare "consensus / disagreement / gaps," and finally writes a Traditional Chinese Markdown report with footnote citations.
  • The report is rendered into HTML by Cloud Run and uploaded to Cloud Storage. LINE receives a Flex card with a summary, main sources, and a "Read Full Report" button.
  • If you later send " Go deeper into Chapter 2, add Japanese sources ", the Bot will deepen the same report, increment the version number by +1, and automatically save the old version as a snapshot.

The first version used Python 3.11 + FastAPI + google-genai (enterprise=True) to call the Pre-GA Managed Agents API. It relied on the official sandbox's persistent filesystem: /workspace/plan.json, /workspace/sources.json, and /workspace/report.md survived across turns under the same environment_id, allowing the Agent to read the files itself when deepening the research.

It was fun, but after running it for a while, a few things kept me uneasy:

  • Pre-GA, no SLA: Names like base_agent: antigravity-preview-05-2026 clearly indicate they will change one day.
  • gsutil in the sandbox is mocked (Pitfall #2 from the previous post). As a result, after writing the report, the Agent still had to stuff the entire Markdown back into a JSON for Cloud Run to upload. The value of the sandbox filesystem was essentially reduced to "remembering three files across turns."
  • Must use background=True + manual polling loop: If Stage 2/3 was slow, the timeout had to be adjusted (I once tuned it from 240 seconds all the way to 600 seconds).

So this time, I decided: Rewrite the entire service in Go, use Google's official ADK for Go (google.golang.org/adk v1.9.0) for everything Agent-related, and deploy it back to the original Cloud Run service using the exact same environment variables. I recorded the entire process in the original repo's Issue #1.

After completing the rewrite, I separated the Go version into a new repo so that the Python version cited in the previous article remains unchanged. The two versions can be directly compared:

Repo Content
kkdai/line-research-bot Original Python 3.11 + FastAPI + Managed Agents version (example from the previous article), and the Issue #1 record of this migration
kkdai/line-research-bot-go The protagonist of this post: Go 1.27 + ADK for Go v1.9.0, currently running on Cloud Run

I. Clarification: It wasn't actually "Python ADK" before

Many people (including myself initially) mix up Managed Agents and ADK, but they are two different things:

Comparison Item Original Python Version New Go Version
Agent Execution Location Google-managed sandbox (Managed Agents API) Within your own Cloud Run container (ADK Runner)
SDK google-genai (enterprise=True), not using google-adk google.golang.org/adk v1.9.0 + google.golang.org/genai
Agent Definition deploy/create-agent.sh uses curl to call REST API to create a research-planner resource In-code llmagent.New(llmagent.Config{...}) × 3
Tools code_execution, filesystem, google_search, url_context geminitool.GoogleSearch{} + url_context
Cross-turn State Sandbox filesystem (environment_id) ADK Session State + Firestore line_bot_workspaces
Structured Output Prompt says "Return ONLY the JSON" + manual code fence stripping OutputSchema (ADK automatically injects set_model_response)
Invocation Method interactions.create(background=True) + polling every 3s runner.Run() returns Go 1.23+ iterator, synchronous event consumption

So this time, rather than "Python ADK → Go ADK," it's more accurate to say: Replacing the hosted Managed Agent with a self-controlled ADK Agent. The benefit is that you no longer have to worry about mocked gsutil in the sandbox, background polling modes, or Pre-GA uncertainty.


II. Setting Rules Before Rewriting: One Issue, Feasibility Verification First

This project is much smaller than the previous linebot-helper (Python version ~1,400 lines, Go version ~2,000 lines, excluding tests), so instead of splitting it into 11 issues, I opened one Tracking Issue #1. I wrote a checklist covering "Current Status Audit → Design → Feasibility Verification → Rewriting → Testing → Deployment → Verification," commenting on the issue as each section was completed.

Before writing a single line of Go, I did two things:

  1. gcloud run services describe to pull the online service configuration (Service Account, 11 environment variables, Secret Manager bindings, memory, timeout) to ensure a 1:1 migration.
  2. Wrote a 30-line probe program to confirm if ADK for Go on Vertex AI could enable " Google Search + URL Context + OutputSchema all at once ". This was the key to whether the design would work; the results are discussed in Section IV.

Once confirmed feasible, the Go version (module path github.com/kkdai/line-research-bot-go) was split into the following modules:

cmd/server/ main: Dependency injection, HTTP server, graceful shutdown
internal/research/ ADK for Go: Three llmagents + runner + session state
  prompts/ plan / search_compare / write_report (go:embed)
internal/worker/ PLAN → SEARCH_COMPARE → WRITE_REPORT → publish, deepen, retry
internal/server/ /webhook, /tasks/run-research, /readyz
internal/state/ Firestore (users / reports compatible + new workspaces)
internal/publisher/ goldmark (GFM + footnote) → HTML → GCS (including snapshots)
internal/linebot/ line-bot-sdk-go v8 + HMAC verification
internal/tasks/ Cloud Tasks (OIDC)
internal/intent, flex/ Intent classification, Flex cards

The overall data flow is as follows:

flowchart LR
    LINE["LINE App"] -->|"POST /webhook<br/>(HMAC)"| WH["internal/server<br/>Intent classification + Locking"]
    WH -->|"enqueue"| CT[("Cloud Tasks<br/>research-jobs")]
    CT -->|"POST /tasks/run-research<br/>(OIDC token)"| WK["internal/worker"]
    WK -->|"seed / save"| FS[("Firestore<br/>line_bot_workspaces")]
    WK -->|"runner.Run()"| ADK["ADK for Go<br/>plan / search_compare / write_report"]
    ADK -->|"Google Search + URL Context"| VX["Vertex AI<br/>gemini-3.8-flash"]
    WK -->|"goldmark → HTML"| GCS[("Cloud Storage")]
    WK -->|"Push Progress + Flex Card"| LINE

III. From Managed Agents to ADK for Go: Four Core Rewrites

1. From "REST Agent Resource Creation" to "In-code llmagent.New"

In the Python version, the Agent was created via REST API using a shell script. The system instructions for the three stages were concatenated into one large block, relying on a stage tag (PLAN / SEARCH_COMPARE / WRITE_REPORT) in the first line of every input for the Agent to find the correct section:

{
  "id": "research-planner",
  "base_agent": "antigravity-preview-05-2026",
  "tools": [{"code_execution": {}}, {"filesystem": {}}, {"google_search": {}}, {"url_context": {}}],
  "system_instruction": "...concatenated instructions for three stages..."
}

The Go version uses three distinct llmagent instances (internal/research/agents.go). Prompts are stored in internal/research/prompts/ using go:embed, and tools are only provided to the stages that need them (the planning stage doesn't need internet access):

func urlContextTool() tool.Tool {
    return geminitool.New("url_context", "Reads the content of web pages by URL.",
        &genai.Tool{URLContext: &genai.URLContext{}})
}

ag, err := llmagent.New(llmagent.Config{
    Name: "research_planner_search_compare",
    Description: "Searches the web and cross-compares sources.",
    Model: llm, // gemini.NewModel(ctx, cfg.GeminiModel, &genai.ClientConfig{Backend: genai.BackendVertexAI, ...})
    Instruction: searchPrompt,
    Tools: []tool.Tool{geminitool.GoogleSearch{}, urlContextTool()},
    OutputSchema: searchSchema,
    OutputKey: "search_output",
})
r, err := runner.New(runner.Config{AppName: appName, Agent: ag, SessionService: sessions})

ADK for Go currently only has geminitool.GoogleSearch built-in. URL Context must be wrapped manually using geminitool.New with a genai.Tool, which I discovered by digging through the tool/geminitool source code. Additionally, I kept the AGENT_ID environment variable but repurposed it as the ADK AppName and agent name prefix, so the online environment variables didn't need to change.

2. From background=True + Polling to runner.Run() Iterator

The most tedious part of the Python version was this: Managed Agents didn't support synchronous mode, so you had to create and then get every 3 seconds until completed:

created = self._client.interactions.create(
    agent=self._agent_id, input=instruction, environment=env_arg,
    stream=False, background=True, store=True,
)
while time.monotonic() < deadline:
    polled = self._client.interactions.get(created.id, include_input=False)
    if polled.status == "completed":
        return AgentResponse(...)
    time.sleep(self._poll_interval)

ADK for Go's runner.Run() directly returns a Go 1.23 iter.Seq2[*session.Event, error]. The Agent runs in its own process, events flow out one by one, and timeout is handled by context.WithTimeout:

for ev, err := range st.runner.Run(ctx, userID, sid,
    genai.NewContentFromText(input, genai.RoleUser), agent.RunConfig{}) {
    if err != nil {
        return "", invocationID, fmt.Errorf("%w: %s: %v", ErrAgent, st.name, err)
    }
    invocationID = ev.InvocationID
    // Use final response text as fallback; read formal result from OutputKey
}

I changed the last_interaction_id stored in Firestore to ADK's InvocationID. The field name remains the same, making it just as useful for log cross-referencing.

3. Sandbox /workspace Files → ADK Session State + Firestore (The Most Critical Step)

The biggest selling point of Managed Agents was the cross-turn persistent sandbox filesystem. ADK has no sandbox, so when a user asks to "go deeper into Chapter 2," where does the Agent find the previous sources and report?

The answer is ADK Session State + Instruction Templates. ADK's Instruction replaces {key} with the value from session state, and {key?} means "it's okay if it doesn't exist":

## Cross-compared sources (JSON)
{sources}

## Current report Markdown (empty for a new report)
{report_md?}

The mapping becomes:

Sandbox File (Python) ADK Session State (Go) Persistence Location
/workspace/plan.json {plan} Firestore line_bot_workspaces/{userId}.plan
/workspace/sources.json {sources} ...sources
/workspace/report.md {report_md?} ...report_md

However, Cloud Run scales horizontally and can scale to 0; items in session.InMemoryService() can disappear at any time. My approach: Every time a stage is executed, create a new ADK session, seed the initial state from the Firestore workspace, write the results back to Firestore after running, and then delete the session.

created, err := a.sessions.Create(ctx, &session.CreateRequest{
    AppName: a.appName,
    UserID: userID,
    State: map[string]any{KeySources: in.SourcesJSON, KeyReportMD: in.ReportMD},
})

This makes the Cloud Run instance completely stateless; a deepening request hours later will work on any instance. Added benefits:

  • "Republishing" no longer requires the LLM: The Python version's retry_publish had to ask the Agent to read report.md from the sandbox and return it; the Go version simply takes the Markdown from Firestore and re-renders/uploads it, saving a model call.
  • Handling sandbox expiration remains consistent: Reports created in the old Managed Agents version cannot access their sandbox files in the Go version. I handle this as the original "environment expired" path: push a message saying "Previous research files have expired, restarting research from scratch 🔄" and rerun.

⚠️ A small ADK template pitfall: ADK treats all strings matching {identifier} in the instruction as state variables and errors out if they aren't found. The original prompts had many JSON examples; these must be cleaned up. I wrote a unit test to scan the three prompts and only allow {plan}, {sources}, and {report_md?} placeholders.

4. "Return ONLY the JSON" → OutputSchema

To get JSON in the Python version, the prompt ended with "Return ONLY the JSON. No prose, no markdown fences.", and the code required a _parse_agent_json function to try the raw text,

```json fence content, or the first { to the last }.

ADK for Go's OutputSchema is much cleaner:

When OutputSchema is set, the framework transparently injects a set_model_response tool so the model can still invoke other tools (function tools, RAG, agent transfer, etc.) while producing structured output.

In other words, ADK automatically injects a set_model_response tool. The model can use Google Search / URL Context to find data first, then call this tool to submit results matching the schema, which are then written back to session state via OutputKey. I defined the output structures for all three stages using genai.Schema in internal/research/schema.go, keeping the original JSON parser as a fallback.


IV. Model Selection: Not every Gemini can do "Search + Read Web + Structured Output" simultaneously

The probe program mentioned earlier was for this exact moment. Results from running the same program in my GCP project at location global:

Model Google Search + URL Context + OutputSchema Result
gemini-2.5-flash ❌ 400 INVALID_ARGUMENT: controlled generation is not supported with URL Context tool
gemini-3-flash-preview ✅ Used in the first live version
gemini-3.1-flash ❌ 404 NOT_FOUND (This project doesn't have this model)
gemini-3.8-flash ✅ Current Default
gemini-3.8-pro / -flash-preview / -pro-preview / -flash-lite ❌ All 404 NOT_FOUND

Two takeaways:

  1. The model list must be verified by actually calling them from your project. Just because a model name exists in documentation doesn't mean your project or location has access to it; 404 Publisher model ... was not found or your project does not have access to it is a common response.
  2. Model names must be environment variables. The Go version adds an optional GEMINI_MODEL variable; if not set, it uses the code default. This means the 11 original environment variables on the live service didn't need to change.

A bug caught when switching to gemini-3.8-flash

After upgrading to gemini-3.8-flash, reports became significantly longer and deeper (approx. 5k → 11–12k characters), but integration tests caught a subtle issue: when asked to deepen a non-existent "Chapter 99," it returned:


plaintext
error="chapter_not_found_chapter_not_found" available_chapters=[]


The old code used == "chapter_not_found" for validation; this value didn't match, so it would publish an empty report_md as a new version. I fixed this in three layers:

  1. Changed error in the schema to an enum (only allowing chapter_not_found) to restrict the model at the source.
  2. The worker now uses strings.Contains for loose matching; if available_chapters is empty, it extracts chapters from the ## headers in the report itself.
  3. If report_md is empty, it never publishes and instead pushes "Deepening failed, please try again later."

After adding the enum and rerunning, gemini-3.8-flash correctly returned chapter_not_found and the full four chapters. Switching models isn't just about changing a string; edge cases for structured output must be re-verified with integration tests.

To be honest about the cost: the total time for the three stages increased from about 60 seconds to 150–190 seconds. It's still within timeout, but users have to wait a bit longer.


V. gcloud and Cloud Run Deployment: Ten Things to Watch Out For

After finishing the code, passing go test ./..., and running integration tests against the real Vertex AI, the real challenge began. This is a bot serving real LINE users live. Below are the points I hit (or narrowly avoided) during deployment.

Note 1: describe the online settings first; don't trust the deploy.sh in the repo

I initially thought I could just follow the deploy/deploy.sh in the repo, but a describe of the live service revealed the two were already different (I had tuned them online during previous debugging):

Setting deploy.sh in repo Actual Live Service
GCS_BUCKET line-reports A different bucket
Memory 1Gi 2Gi
Timeout 600 3600
--min-instances 1 0 (not set)

If I had just run deploy.sh, reports would have gone to the wrong bucket, and long tasks would have been killed at 600 seconds. When rewriting a service, the "actual online state" is the specification:


shell
gcloud run services describe line-research-bot --region=asia-east1 --format=json \
  | jq '{sa: .spec.template.spec.serviceAccountName,
         env: [.spec.template.spec.containers[0].env[].name],
         resources: .spec.template.spec.containers[0].resources,
         timeout: .spec.template.spec.timeoutSeconds}'


Note 2: To "reuse the same environment variables," the simplest way is to pass nothing

The previous linebot-helper was deployed as a new service, so environment variables had to be converted to an --env-vars-file. This time, deploying back to the same service, there's a simpler way:

gcloud run deploy --source=. without --set-env-vars / --update-secrets will cause the new revision to fully inherit the environment variables and Secret Manager bindings from the previous revision.

This ensures the LINE Channel Secret / Access Token never appear in the command line or terminal output. After deploying, I described it again to confirm the list of 11 environment variables remained the same.

Note 3: Use --no-traffic --tag for Canary; tags must be at least 3 characters

Since it's the same service, I didn't want all LINE users hitting the new version immediately. The Cloud Run way:


shell
gcloud run deploy line-research-bot --region=asia-east1 --source=. \
  --no-traffic --tag=golang


The new revision gets 0% traffic but has a dedicated tag URL for direct testing. I hit a silly snag here: I initially tried --tag=go, which was blocked:


plaintext
ERROR: (gcloud.run.deploy) service.spec.traffic[1].tag: must be at least 3 characters long.


After performing smoke tests (listed below) on the tag URL, I switched traffic and recorded the previous revision name as a rollback point:


shell
gcloud run services update-traffic line-research-bot --region=asia-east1 \
  --to-revisions=<new revision>=100

# Rollback is just one line
gcloud run services update-traffic line-research-bot --region=asia-east1 \
  --to-revisions=<previous revision>=100


Smoke Test Expected Result
GET /readyz 200 ✅
POST /webhook wrong signature 401 ✅
POST /tasks/run-research no OIDC token 401 ✅
Model field in startup logs Expected model name ✅

Note 4: Cloud Tasks default dispatch deadline is only 10 minutes

This was a potential issue I found while reviewing the Python code during the rewrite. Research tasks are executed via Cloud Tasks calling back to the /tasks/run-research endpoint. While Cloud Run's timeout is 3600 seconds, Cloud Tasks' default dispatch deadline for HTTP targets is only 10 minutes. If there's no response after 10 minutes, Cloud Tasks marks it as failed and retries (queue setting max-attempts=3), causing the research to rerun and doubling the Gemini bill.

In the Go version, I set the task creation in internal/tasks/dispatcher.go to the maximum 30 minutes:


go
Task: &cloudtaskspb.Task{
    DispatchDeadline: durationpb.New(30 * time.Minute),
    MessageType: &cloudtaskspb.Task_HttpRequest{HttpRequest: &cloudtaskspb.HttpRequest{ /* ... */ }},
},


Additionally, the Python version's user lock TTL was only 300 seconds, meaning a user could send another query and grab the lock before the task finished, leading to two tasks modifying the same workspace. The Go version sets the lock TTL to 35 minutes (greater than the dispatch deadline) and actively releases it when the task finishes.

Note 5: Under --allow-unauthenticated, Cloud Tasks endpoints must verify OIDC themselves

The LINE Webhook must be accessible from outside, so the entire service is --allow-unauthenticated. /webhook is protected by X-Line-Signature, but the Python version's /tasks/run-research had no verification at all: anyone who knew the URL could POST a JSON and trigger a full research task that burns Gemini credits.

Cloud Tasks naturally carries an OIDC token when creating a task; the Python receiver just didn't verify it. The Go version adds this in cmd/server/main.go using google.golang.org/api/idtoken:


go
func oidcVerifier(audience, saEmail string) server.TokenVerifier {
    return func(ctx context.Context, bearer string) error {
        p, err := idtoken.Validate(ctx, bearer, audience) // audience = service URL + /tasks/run-research
        if err != nil {
            return err
        }
        if email, _ := p.Claims["email"].(string); email != saEmail {
            return fmt.Errorf("unexpected token email %q", email)
        }
        return nil
    }
}


Details to align:

  • Audience must match on both sides: Explicitly specify OidcToken.Audience when creating the task, and verify the same value on the receiving end (I use CLOUD_RUN_SERVICE_URL + "/tasks/run-research").
  • Service Account must be able to actAs itself: Cloud Tasks needs to sign the OIDC token using line-bot-sa. The line-bot-sa needs roles/iam.serviceAccountUser on itself, plus roles/cloudtasks.enqueuer.
  • CLOUD_RUN_SERVICE_URL is a chicken-and-egg problem: You don't know the URL before the first deployment. The deploy.sh strategy is to deploy first, then gcloud run services update --update-env-vars=CLOUD_RUN_SERVICE_URL=.... When deploying back to the same service, this value is inherited and doesn't need resetting.

Note 6: --no-cpu-throttling is NOT needed this time; min-instances can be 0

In the previous linebot-helper post, I spent a whole section explaining how background goroutines get frozen without --no-cpu-throttling. This architecture is the opposite:

  • /webhook only does signature verification, classification, locking, and enqueueing. It returns 200 only after finishing synchronously, with no background goroutines.
  • The truly time-consuming research runs within the HTTP request sent by Cloud Tasks. The request stays open from start to finish, so Cloud Run won't throttle the CPU or scale down the instance.

Thus, the default "allocate CPU only during request processing" is perfectly sufficient, and --min-instances can stay at 0. Go cold starts in less than 1 second, and the LINE reply token is valid for about 1 minute, which is plenty of time. Whether to use --no-cpu-throttling depends on whether the work is done inside or outside the request.

The only thing to watch is releasing the lock: the r.Context() might already be canceled when the handler ends, so I use context.WithoutCancel(ctx) to release the Firestore lock, ensuring it always gets released.

Note 7: Deployment is possible without local Docker; --source=. uses Cloud Build

I don't have Docker installed on my laptop. gcloud run deploy --source=. sees a Dockerfile in the directory and hands it to Cloud Build. Since this Bot doesn't need Chromium or ffmpeg, I can safely use the minimal distroless image:


docker
FROM golang:1.27 AS build
WORKDIR /src
COPY go.mod go.sum ./
RUN go mod download
COPY cmd/ ./cmd/
COPY internal/ ./internal/
RUN CGO_ENABLED=0 go build -trimpath -ldflags="-s -w" -o /out/server ./cmd/server

FROM gcr.io/distroless/static-debian12:nonroot
COPY --from=build /out/server /server
USER nonroot:nonroot
ENTRYPOINT ["/server"]


The Go version has no external executable dependencies, and prompts are compiled into the binary using go:embed, so the final image is just a static executable. No extra data files need to be copied.

Note 8: Same service, no need to change LINE Webhook URL, but verify with API

After deploying, I thought about going to the LINE Developers Console to change the Webhook URL, then realized: The new version is deployed to the same Cloud Run service; the URL hasn't changed. Instead of visually comparing in the Console, it's better to ask LINE using the Channel Access Token:


shell
TOKEN=$(gcloud secrets versions access latest --secret=LINE_CHANNEL_ACCESS_TOKEN)

# Current webhook setting and status
curl -s -H "Authorization: Bearer $TOKEN" \
  https://api.line.me/v2/bot/channel/webhook/endpoint

# Ask LINE to actually hit the webhook
curl -s -X POST -H "Authorization: Bearer $TOKEN" -H 'Content-Type: application/json' \
  -d '{"endpoint":"<YOUR_SERVICE_URL>/webhook"}' \
  https://api.line.me/v2/bot/channel/webhook/test


In my script, I directly compare if endpoint equals "Service URL + /webhook", printing only MATCH or DIFFERENT so the URL itself doesn't appear in the terminal. The test API returning {"success":true,"statusCode":200} means LINE traffic is already hitting the Go version.

Note 9: When E2E testing with a fake user, don't let LINE push failures trigger a full pipeline rerun

Smoke tests only prove the service is alive. I also wanted to verify the entire path: webhook → Cloud Tasks (OIDC) → ADK → Firestore → GCS in the production environment without using a real user. The trick is to use the Channel Secret from Secret Manager to correctly sign a message for a fake userId and hit /webhook directly:


shell
SIG=$(printf '%s' "$BODY" | openssl dgst -sha256 -hmac "$SECRET" -binary | base64)
curl -X POST -H "X-Line-Signature: $SIG" -d "$BODY" "$URL/webhook"


LINE's reply and push will inevitably return 400 because the userId/replyToken is fake. This is expected, but it highlighted a design issue: If the final Flex card push fails and the worker returns an error, the handler returns 500, causing Cloud Tasks to rerun the entire 3-minute, paid research process. So, the Go version logs all LINE push failures but doesn't return an error; only infrastructure errors like Firestore failures trigger a Cloud Tasks retry.

The E2E test in production took about 33 seconds. Firestore reports/workspaces were correctly written, the lock was released, and the index.html on GCS had the correct title and version banner. Remember to clean up the fake user's Firestore documents and GCS objects after testing.

Note 10: Upgrade to the latest Go patch and use govulncheck

My local machine was on Go 1.26.1 during the rewrite, but the latest version was already 1.27.3. Upgrading is easy; go get modifies go.mod and automatically downloads the toolchain:


shell
go get go@1.27.3 toolchain@none
go run golang.org/x/vuln/cmd/govulncheck@latest ./...


Remember to update the Dockerfile build image from golang:1.26 to golang:1.27. govulncheck reported "Vulnerabilities affecting your code: 0", though there were 3 vulnerabilities in dependencies that weren't being called. Two in golang.org/x/net were fixed by upgrading to v0.62.0; the one in golang.org/x/crypto hasn't had an official fix released yet. govulncheck distinguishes between "called" and "not called," which is much more practical than just scanning go.mod version numbers.


VI. Things Fixed Along the Way: Rewriting is a Great Time to Re-examine Design

Item Python Version Go Version
/tasks/run-research Verification None Cloud Tasks OIDC (audience + SA email)
Cloud Tasks Dispatch Deadline Default 10 mins (long tasks rerun) 30 minutes
User Lock TTL 300 seconds 35 minutes, active release on finish
retry_publish Asks Agent to read report.md again Uses Markdown from Firestore directly (no LLM)
Deepen Version Number Relies on model's new_version Calculated by code (previous + 1)
Report HTML Raw HTML from model rendered; <title> not escaped goldmark doesn't render raw HTML by default; title escaped
LINE Push Failure Exception bubbles up → 500 → Cloud Tasks rerun Logged only; doesn't trigger rerun
Execution Environment Python 3.11 slim Go 1.27.3 distroless/static, nonroot

Regarding testing, the Go version has two layers:

  • Unit Tests (go test ./..., no GCP needed): Worker uses fake store / agents / LINE / publisher. Covers new research, stage failures, retry_write, retry_publish, deepening, chapter_not_found (including model hallucination cases), and missing workspaces. Server covers signatures, locks, enqueue failure rollback, OIDC, recall / reset / small talk.
  • Integration Tests (go test -tags integration): Hits the real Vertex AI to run through all three stages, then tests deepening a non-existent chapter. When switching models or upgrading ADK, this is the only way to catch the model behavior differences mentioned earlier.

VII. One Last Security Reminder: Don't let Cloud Run URLs appear in public Issues

Both repos are public, as are the Issues and PRs. I deliberately did a few things:

  • The first line of the Issue states: "This issue is a public record and will not post any Cloud Run service URLs, Secret values, or internal identifiers."
  • All gcloud command outputs that would print URLs were masked using sed -E 's#https?://[^]+#<url>#g', or the URL was stored in a shell variable and only the comparison result was printed.
  • Before sending a PR, I ran git diff main | grep run.app to ensure no real URLs were in the new code or documentation (only a fake https://x.run.app from a deleted Python test was caught).
  • Before opening the new public repo, I scanned the entire git history (git log --all -p) to ensure the real service hostname never appeared once before pushing with gh repo create --public --source=. --push. If a repo is public, everything in old commits is also public.

/webhook has LINE signatures and /tasks/run-research has OIDC, so even if the URL leaks, unauthorized access is blocked. However, a public .run.app URL still attracts scanners and meaningless traffic. If you don't have to make it public, don't.


VIII. Python Managed Agents → Go ADK Migration Checklist

  • 1. Audit online settings first: Use gcloud run services describe to get the SA, env vars, Secret bindings, memory, and timeout. Don't trust outdated deploy scripts in the repo.
  • 2. Feasibility verification for the model: Call it from your project and location to confirm the model exists and supports your tool combination (e.g., Google Search + URL Context + OutputSchema).
  • 3. Sandbox Files → Session State: Map the cross-turn files from the Managed Agents sandbox to ADK {state} placeholders and persist them to external storage like Firestore to keep Cloud Run stateless.
  • 4. Check curly braces in instructions: ADK treats {identifier} as a state variable. Clean up JSON examples when moving prompts, and ideally use a test to catch them.
  • 5. Use OutputSchema instead of prompt constraints, but keep a loose JSON parser as a fallback. Use enums for error code fields.
  • 6. Use --no-traffic --tag (tag ≥ 3 chars) for the same service. Only update-traffic after smoke tests pass, and record the revision for rollback.
  • 7. Omitting --set-env-vars inherits env vars and Secrets from the previous version. Describe again after deployment to confirm.
  • 8. Cloud Tasks: Set dispatch deadline to the required length (up to 30 mins). Lock TTL must be longer than this. The receiver must verify the OIDC token.
  • 9. --no-cpu-throttling is only needed if work happens outside the request. If work is done within the Cloud Tasks request, min-instances 0 is sufficient.
  • 10. Verify webhook with LINE API: GET /v2/bot/channel/webhook/endpoint + POST .../webhook/test is more reliable than checking the Console.
  • 11. External notification failures shouldn't trigger a full pipeline retry. Only return 5xx for infrastructure errors.
  • 12. Upgrade to the latest Go patch and run govulncheck. Ensure no real service URLs remain in public repo Issues / PRs / READMEs.

Summary and Open Source Project Links

Migrating kkdai/line-research-bot from Python + Managed Agents to the Go + ADK for Go version kkdai/line-research-bot-go provided several key takeaways:

  1. Managed Agents and ADK are different trade-offs: The former manages the sandbox and filesystem for you; the latter lets the Agent run within your own code. When the sandbox's value is just "remembering three files," ADK's Session State + Firestore is simpler and easier to test.
  2. OutputSchema + Built-in Tools is a powerful combo in ADK for Go: set_model_response allows the model to search the web first and then submit strongly-typed results, provided your chosen model supports it.
  3. --no-traffic --tag is the safest way to switch when deploying to the same service: Env vars, Secrets, and Webhook URLs don't need to change, and rollback is a single line.
  4. Rewriting is the best time to re-examine design: The OIDC verification, 30-minute dispatch deadline, and push failure handling added this time were all potential issues in the Python version.

The full Go code is at kkdai/line-research-bot-go. The step-by-step record of the rewrite is in Issue #1. Feel free to reference and discuss!

Further Reading

來源:DEV Community · dev.to