bouncer-preflight

command
v2.9.0-dev.2 Latest Latest
Warning

This package is not in the latest version of its module.

Go to latest
Published: Aug 17, 2026 License: Apache-2.0 Imports: 29 Imported by: 0

README

bouncer-preflight — porting a Python ADK multi-agent system onto core-agent as a library

gke-demos/bouncer mines completed GKE TPU/JAX production workloads and derives a verified single-slice "preflight" smoke test from each one: same image, same framework, same ICI topology, one slice, one step, synthetic data. The point is to catch "this cluster can't actually run your workload" before a 256-chip job is queued.

Upstream is google-adk (Python): one agent per directory, launched as separate adk run subprocesses, agreeing on state through environment variables and on the verdict through a stdout grep for "success: True".

This example ports the generator + checker pair — the load-bearing half — to Go, with github.com/go-steer/core-agent/v2 as an ordinary library dependency.

The prompts are copied from upstream unedited. Everything under prompts/ is the original text with its original placeholders. If the port were unfaithful, the prompts would be the first thing to bend — so they are the fixed point, and the Go code is what moves.

Why a library port, not a config recipe

core-agent's config/recipe path (see examples/gke-parallel-triage) covers "give an agent a persona, some MCP tools, and a permissions policy". bouncer needs four things a recipe cannot express:

bouncer needs recipe library
A jail for model-authored shell (bwrap + sudo -u agent-runner) ✗ core-agent's built-in bash runs as the daemon's own uid ✓ don't register bash; register the jail as the only shell
Structured output from the checker (output_schema=CheckerResult) ✗ no such knob ✓ invert it: the terminal action is a typed tool
An agent calling an agent synchronously and reading its typed result ~ spawn_agent returns prose ✓ a plain Go function call
A model decorator (the upstream BaseApiClient.async_request monkeypatch) adkmodel.LLM is a two-method interface

The whole port is ~1,000 lines of Go against pkg/ only — no internal/ imports, so the surface it exercises is the real external API.

Run it hermetically

No cluster, no credentials, no cost: two scripted transcripts stand in for the models and a fake kubectl stands in for the cluster.

PATH="$PWD/examples/bouncer-preflight/testdata/bin:$PATH" \
go run ./examples/bouncer-preflight \
  --source examples/bouncer-preflight/testdata/prod-jobset.yaml \
  --generator-script examples/bouncer-preflight/testdata/generator.jsonl \
  --checker-script examples/bouncer-preflight/testdata/checker.jsonl \
  --sandbox none \
  --state-dir /tmp/bouncer-preflight

You should see the hand-off and the verdict on stderr, then:

stop reason: completed
turns:       1
library:     maxtext-v5e-256-16x16

with /tmp/bouncer-preflight/library/maxtext-v5e-256-16x16.yaml (the manifest that was actually verified) next to its .json provenance sidecar.

go test ./examples/bouncer-preflight/ runs the same path as a test, plus the jail, retry, slug and fail-closed unit tests.

Run it live

GOOGLE_API_KEY=... go run ./examples/bouncer-preflight \
  --source /path/to/completed-jobset.yaml \
  --namespace test-preflight \
  --service-account preflight-sa \
  --policy-file /path/to/cluster-policy.md \
  --max-turns 40 --max-cost 25

Prerequisites, all of them upstream's too:

  • bubblewrap on PATH. This is checked at boot and the run fails closed if it is missing — --sandbox=none is the only way to run uncontained, and it prints a warning to stderr when you do.
  • sudo configured for a passwordless drop to an unprivileged agent-runner uid (sudo -n -E -u agent-runner).
  • kubectl (plus jq/yq if your policy bullets mention them) inside the jail, and cluster credentials the jail can see — in-cluster that is the projected service-account token at /var/run/secrets/kubernetes.io/serviceaccount, which is bind-mounted read-only when present.
  • A test namespace the agents may freely create and delete Jobs in, with real TPU node pools behind it. The preflights are only meaningful on hardware.

Both agents share one credentialled provider (--provider gemini|vertex, --model, default gemini-3.1-pro-preview — bouncer's DEFAULT_MODEL).

What maps to what

examples/bouncer-preflight/
├── main.go        # flags, prompt rendering, event log, autonomous.Run
├── generator.go   # parent agent + save_if_validated (the hand-off)
├── checker.go     # checker agent + report_verdict + submit_candidate_preflight
├── retry.go       # adkmodel.LLM decorator (the monkeypatch)
├── exec.go        # bwrap-backed sandbox_run_command + wait_seconds
├── store.go       # scratch dir, preflight library, experience log
├── prompts/       # VERBATIM from upstream
└── testdata/      # scripted transcripts + fake kubectl + a real-shaped JobSet

The five plumbing changes, and why each one is a change rather than a translation:

  1. output_schema=CheckerResult → the report_verdict tool (checker.go). core-agent has no output-schema knob. Instead of constraining the final message, the terminal action becomes a Go function whose signature is the schema: report_verdict(success bool, details string). The model cannot report an outcome in a shape the compiler didn't approve. The fail-closed behaviour is preserved exactly — a checker that stops without calling it is a FAILURE, just as a missing "success: True" in the upstream stdout was. First verdict wins, so a second call can't flip a FAILURE to a SUCCESS.

  2. subprocess.Popen(["adk","run","checker"]) → an in-process call (generator.go). save_if_validated writes the candidate and calls runChecker directly, getting a typed verdict back. No second interpreter, no environment-variable handshake, no stdout parsing. A transport failure inside the checker surfaces as a tool error, not as "your manifest is broken" — a distinction the stdout grep could not make.

  3. BaseApiClient.async_request monkeypatch → an adkmodel.LLM decorator (retry.go). ADK's model interface is two methods, so retry is an ordinary wrapper: 10 attempts, 60s backoff, retryable-only (429/503/RESOURCE_EXHAUSTED/ UNAVAILABLE/DEADLINE_EXCEEDED/connection reset/EOF), with a per-attempt deadline the Python version doesn't have. One deliberate semantic difference: once a response chunk has reached the consumer, a mid-stream error is surfaced rather than retried, because replaying would duplicate it in the transcript.

  4. sandbox_run_command ported as-is, as the only shell (exec.go). The bwrap flags are upstream's: --unshare-{user,pid,ipc,cgroup}, read-only /, private /proc, /dev, /tmp, and exactly one writable bind (/workspace). core-agent's built-in bash tool is never registered, so there is no uncontained shell for the model to reach. TestSandboxArgvIsAJail asserts every flag and the bind ordering: the jail is the security boundary, so a silently dropped flag must fail a test rather than a pentest.

  5. One-shot adk runautonomous.Run (main.go). The generator is a goal-driven loop with turn, wallclock and cost budgets, report_done injected as the terminal tool, and both agents' events in one SQLite event log (the checker under a derived <session>:checker id), so one audit query returns both halves of a derivation.

The tools both agents get

Derived from the prompts, and enforced by TestPromptsNameOnlyToolsWeRegister: every backticked snake_case name in prompts/ must resolve to a tool the port actually registers, or the test fails. Copying in a newer upstream prompt breaks the build, not a live run.

Tool Agent Notes
sandbox_run_command both the bwrap jail; non-zero exits are data, not errors
wait_seconds both capped at 900s/call so a model can't sleep for a day
read_source_manifest generator the completed production workload
get_original_objective generator verbatim task recall
get_conversation_history generator every hand-off + verdict so far
bouncer_docs_retriever generator substring grep over library + experience log, exactly as upstream
reuse_existing_preflight generator the "don't re-derive" fast path
append_experience_log generator durable lessons, grouped by topic
save_if_validated generator the hand-off; runs the checker in-process
read_candidate_manifest checker
submit_candidate_preflight checker takes no arguments — always applies the saved manifest into the configured namespace, so the model can't smuggle in a different one
save_derived_preflight_to_library checker takes no manifest — reads it from disk, so what's stored is what was tested
report_verdict checker the structured-output replacement
report_done generator injected by autonomous.Run

Three places where the port is deliberately stricter than upstream, all because a model authors the inputs:

  • Library filenames are slugified (slugify in store.go), so a preflight named ../../etc/cron.d/pwn cannot escape the library directory.
  • checker-instruction: lines the generator writes to itself are stripped before the candidate is written, so they can never reach an API server or a stored artifact.
  • Namespace and manifest for the submit are fixed by the operator's flags, not by tool arguments.

What is not ported

This is a spike of the derivation core, not of bouncer. Left in Python (or left out entirely):

  • bouncer.py — the outer poller that watches for completed production jobs and starts a derivation per job. core-agent's counterpart would be autonomous.Run + a Scheduler (see examples/scheduled-monitor), but the trigger here is a --source file you point at.
  • The Dreamer, Lookup and Monitor agents — the "what should we test next", reference-lookup and live-monitoring halves of upstream.
  • The Google Chat bridge and the git sync of the preflight library. The library here is a directory of YAML + JSON sidecars; wiring it to a repo is your git invocation, not this example's.
  • The iptables egress lockdown upstream applies to its sandbox pod. The bwrap jail is ported; the network policy around the pod is deployment config.
  • get_conversation_history is re-scoped: upstream replays the ADK session, this returns the generator↔checker hand-off history (candidate names, sizes and verdicts), which is the part its prompt actually asks for. core-agent already keeps the live transcript in the model's context; this survives compaction.

Composing further

  • Budgets: --max-turns, --max-wallclock, --max-cost. A derivation that can't converge stops costing money.
  • Audit: --state-dir <dir> puts eventlog.db next to the library; every tool call from both agents is queryable after the fact.
  • Cheaper checker: the checker does far less reasoning than the generator. Splitting resolveModels to give it a Flash-class model is a two-line change and the obvious first cost lever.
  • Policy: --policy-file splices operator bullets into the generator prompt at upstream's {{CLUSTER_POLICY_RULES}} placeholder — the supported way to add cluster rules without editing the vendored prompt.

Provenance

prompts/generator_prompt.md and prompts/checker_prompt.md are copied from gke-demos/bouncer (Apache 2.0) with only the placeholder substitution the upstream code performs at agent-construction time. testdata/prod-jobset.yaml is a synthetic MaxText-on-v5e-256 JobSet written for this example, not a captured customer workload.

Documentation

Overview

Example: the generator + checker half of gke-demos/bouncer, ported onto core-agent as a Go library.

bouncer mines completed GKE TPU/JAX production workloads and derives verified single-slice "preflight" smoke tests from them. Upstream that is python google-adk: two agents in two directories, launched as two `adk run` subprocesses, agreeing on state through environment variables and on the verdict through a stdout grep.

Here both agents are ordinary core-agent Agents in one process. The upstream prompts are copied verbatim under prompts/; what changes is the plumbing:

  • the checker's `output_schema=CheckerResult` becomes a report_verdict tool whose Go signature is the schema (checker.go)
  • `subprocess.Popen(["adk","run","checker"])` + `grep "success: True"` becomes an in-process call returning a typed verdict
  • the BaseApiClient.async_request monkeypatch becomes an adkmodel.LLM decorator (retry.go)
  • bwrap/sudo containment stays exactly as upstream wrote it, registered as the only shell the model can reach (exec.go) — core-agent's built-in bash tool is never wired in
  • the one-shot `adk run` becomes autonomous.Run with turn, wallclock and cost budgets

Hermetic run from the repo root — no cluster, no credentials, no cost (testdata/bin holds a fake kubectl):

PATH="$PWD/examples/bouncer-preflight/testdata/bin:$PATH" \
go run ./examples/bouncer-preflight \
  --source examples/bouncer-preflight/testdata/prod-jobset.yaml \
  --generator-script examples/bouncer-preflight/testdata/generator.jsonl \
  --checker-script examples/bouncer-preflight/testdata/checker.jsonl \
  --sandbox none --state-dir /tmp/bouncer-preflight

Live run against a real test cluster (needs bwrap, sudo, kubectl and an agent-runner uid on the host — see README.md):

GOOGLE_API_KEY=... go run ./examples/bouncer-preflight \
  --source /path/to/completed-jobset.yaml \
  --namespace test-preflight --service-account preflight-sa

Jump to

Keyboard shortcuts

? : This menu
/ : Search site
f or F : Jump to
y or Y : Canonical URL