Hummingbird Agent Design — Decisions
Design Decision Registry (DDR) for the Hummingbird Agent — every major architectural choice, the alternatives considered, why the chosen approach won, and what would break if reversed. For the full design context, see Hummingbird Agent Design. Provider adapter decisions are in Hummingbird AI Design.
Design Decisions
kubectl exec over kubernetes Python exec API
- Chosen:
kubectl execas subprocess for sandbox command execution. - Alternative: kubernetes Python client
stream()API. - Why: Three showstopper bugs in the Python client: (1) WebSocket
v1-v4 has no stdin EOF signal, so
cat > /filehangs forever; (2)BrokenPipeErroron stdin larger than ~1MB; (3)stream()accumulates all stdout in memory with no control.kubectlavoids all three and provides the same subprocess interface as Podman. - If reversed:
write_file()would hang or fail on large data. Pod log retrieval with large outputs would OOM. Sandbox reliability would drop significantly.
YAML config over env vars for operational settings
- Chosen: Single YAML file for workflows, projects, limits, data source declarations.
- Alternative: Everything in env vars (original design).
- Why: Env vars cannot express structured data (workflow-project mappings, per-project token overrides, data source config with multiple fields). YAML provides structure, validation, and audit trails.
- If reversed: Token scoping would be lost (no per-workflow-project token overrides). Project allowlists would be impossible. The config would be unauditable.
Token env var names in YAML, not values
- Chosen: YAML contains
token_env: GITLAB_TOKEN_RO(the env var name), not the actual token value. - Alternative: Inline secrets in YAML, or env vars for everything.
- Why: The YAML file can be committed, reviewed, and audited. Actual secrets stay in env vars (injected via K8s Secrets). Inline secrets would make the config file a secret itself.
- If reversed: The config file would become a secret, breaking audit trails and code review workflows.
Orchestrator token prefix convention (ORCHESTRATOR_*)
- Chosen: Orchestrator tokens use
ORCHESTRATOR_GITLAB_TOKEN_*env var names by convention. - Alternative: Same env var namespace as model tokens, distinguished by context.
- Why: Structural separation makes it impossible to accidentally pass
an orchestrator token to a model tool (or vice versa). The
ORCHESTRATOR_prefix is never used in YAMLtoken_envfields. - If reversed: A misconfiguration could leak write-capable tokens to the model, which could then expose them via tool calls.
Auto-spill over conversation compaction
- Chosen: Large outputs are saved to sandbox files with previews returned to the model.
- Alternative: Replace old tool results in
contentswith compact summaries (conversation compaction). - Why: Compaction requires modifying the
contentslist, which risks violating provider conversation constraints. Auto-spill achieves ~90% of the token reduction without modifying model history. See Hummingbird AI for adapter format requirements. - If reversed: Token usage would increase ~15-30%. Per-call context would grow unbounded. Sessions would hit the context limit much sooner. Conversation compaction could be added on top of auto-spill in the future, but is not needed with current workloads.
Iteration count over cumulative token budget
- Chosen:
max_iterationsas the primary cost control lever. - Alternative: Cumulative token budget (stop when total billed tokens exceed a threshold).
- Why: With full history replay, each API call re-sends everything. Cumulative billed tokens double-count: call 1 = 5K, call 2 = 10K (including 5K again), total = 15K billed but only 10K new content. With auto-spill keeping per-call size bounded, iteration count is a much simpler and more predictable proxy for actual cost.
- If reversed: The budget model would be confusing and inaccurate. Cost estimates would be wrong. The cumulative metric is still logged for observability, but it does not drive termination.
ToolDef notes in system prompt, not tool schema
- Chosen: Domain knowledge (Konflux dual-fetch, TF XML structure) goes
in
ToolDef.notes, injected into the system prompt. - Alternative: Put everything in the tool schema
description. - Why: Tool schemas have character limits and are sent in the
toolsfield of every API call. Long descriptions waste tokens on tool defs. System prompt notes are sent once and can be arbitrarily detailed. - If reversed: Tool descriptions would be bloated. Domain knowledge
would need to be duplicated in every workflow
.mdfile.
Full history replay (no compaction)
- Chosen: The
contentslist grows monotonically. Items are never removed or modified (except empty response retries). - Alternative: Compact old turns to reduce context size.
- Why: Provider APIs impose strict conversation structure. Modifying or removing items risks invalid history. Auto-spill handles the growth problem at the source (preventing large items from entering history).
- If reversed: Risk of provider API errors from malformed conversation structure and of confusing the model with summarized results.
fetch_to_sandbox kept alongside auto-spill
- Chosen: Both
fetch_to_sandboxand auto-spill coexist. - Alternative: Remove
fetch_to_sandboxsince auto-spill handles large outputs automatically. - Why:
fetch_to_sandboxprovides explicit path control (model can choose meaningful filenames), batch fetching (one tool call for multiple fetches), and no-preview responses (useful when the model will process with jq anyway). - If reversed: The model would lose path control and batch fetching would require multiple auto-spilled calls. The workflow would need more iterations to achieve the same result.
K8s library for lifecycle + kubectl for exec (hybrid)
- Chosen: Use the
kubernetesPython library for pod create/read/delete,kubectlsubprocess for exec. - Alternative: All-kubectl (subprocess for everything) or all-library.
- Why: The library provides typed pod status polling and clean error
handling for lifecycle.
kubectlprovides reliable exec (see “kubectl exec over kubernetes Python exec API” above). Using the library for exec would require solving the three WebSocket bugs. - If reversed: Either lifecycle management would be fragile (parsing kubectl JSON output for pod status) or exec would be unreliable.
Single-file YAML config over multiple config files
- Chosen: Everything in one
config.yamlwithsettingsandworkflowssections. - Alternative: Separate files per workflow, or settings.yaml + workflows.yaml.
- Why: Single source of truth. All project-workflow mappings visible in one place. Settings-level defaults flow down to all projects. No file discovery logic needed.
- If reversed: Token resolution would need cross-file lookups. Project index would need multi-file aggregation. Config validation would be more complex.
Workflow .md as pure prompt, metadata in YAML
- Chosen: The workflow
.mdfile is pure system prompt text. Metadata (action,model,max_iterations,data_sources,projects) lives in the YAML config. - Alternative: YAML frontmatter in the
.mdfile (original design). - Why: Separation of concerns. The
.mdfile is the model’s instructions – it should be readable and editable by anyone writing prompts. The YAML config is the orchestrator’s instructions – it controls routing, limits, and credentials. Mixing them in one file conflates two audiences. - If reversed: Prompt authors would need to understand YAML config
structure. Token/project config would be scattered across
.mdfiles instead of centralized.
Nudge via system prompt suffix, not user message
- Chosen: Empty response nudge and budget warnings are appended to the system prompt as suffixes.
- Alternative: Inject synthetic user messages into
contents. - Why: User messages in
contentsmust come from the actual user (the initial event, or a reply). Injecting synthetic messages pollutes the conversation history that is saved in sessions. System prompt suffixes are transient – they affect one API call without permanently modifying the conversation state. - If reversed: Saved sessions would contain synthetic user messages. Resumed conversations would be confusing. The model might respond to the synthetic messages instead of the user’s actual input.
Session ID reuse over new-ID-per-resume
- Chosen: Resumed sessions keep the original session ID. S3 state is overwritten in place.
- Alternative: Each resume generates a new UUID, saving alongside the original (append-only tree of sessions).
- Why: GitLab discussions are linear, not branching. There is no
scenario where two different sessions from the same thread are both
valid. A new UUID per resume creates orphaned S3 snapshots that are
never referenced again, since
find_session_for_reply()always picks the latest marker. Reusing the ID is simpler, uses less storage, and matches the linear conversation model. - If reversed: S3 would accumulate orphaned session snapshots. Each resume would need a new note marker, but the old markers would still be in the thread, creating confusion about which session is current.
Reply to unauthorized agent-directed notes instead of silent skip
- Chosen: When an unauthorized user sends a slash command or replies to an agent thread, the agent replies in the same discussion with a short access-denied message. Non-agent-directed notes are silently ignored (debug log, no access check).
- Alternative: Log a warning and skip silently for all unauthorized notes (the original behavior).
- Why: Silent skip gives no feedback to someone who intentionally tried to engage the agent, which is confusing. On the other hand, checking access and logging a warning for every random comment on a public project is noisy and pointless. Moving the access check after intent detection cleanly separates the two cases. The reply uses the discussions API so it automatically inherits confidentiality from the parent note/thread.
- If reversed: Users without sufficient access who try
/hummingbirdwould get no feedback. The agent log would be noisy with warnings for every comment on public MRs.
Deployment-backed pod pool over Python-thread pool
- Chosen: A Kubernetes Deployment maintains pre-warmed standby pods. The agent claims a pod by relabeling it; the Deployment replaces it.
- Alternative: A Python-side thread pool that pre-creates pods and queues them for use.
- Why: The Deployment provides self-healing (restart crashed pods),
native scaling (
kubectl scale), rolling updates (image changes), and monitoring via standard K8s tooling. A Python pool would need to reimplement all of these. - If reversed: Pod replenishment, crash recovery, and image updates would all need custom code. Scaling would require agent redeployment.
Pod isolation: never reuse sandbox pods
- Chosen: Each workflow run gets its own pod. After cleanup, the pod is deleted. Claimed pods are detached from the ReplicaSet.
- Alternative: Return used pods to the pool and reset them.
- Why: Residual state from a previous run (files, environment, running processes) could leak between MR investigations, creating security and correctness risks. Deletion is simple and foolproof.
- If reversed: Would need a reliable pod-reset mechanism and auditing that no state survives between runs.
Absolute reap-by deadline over relative claimed-at age
- Chosen: Active pods are annotated with
hummingbird/reap-by(an absolute UTC timestamp). The reaper deletes pods wherenow > reap-by. - Alternative: Annotate with
claimed-atand compute age relative tomax_active_seconds; or useactiveDeadlineSecondson the pod spec. - Why: An absolute deadline simplifies the reaper to a single
comparison. It also supports varying deadlines: claim sets
reap-by = now + max_active_seconds, while linger setsreap-by = now + linger_seconds. With a relative timestamp, the reaper would need to know which mode the pod is in.activeDeadlineSecondsapplies from pod creation, not from claim – standby pods would expire before being used. - If reversed: The reaper would need mode-aware age calculations. Lingering pods would require a separate annotation or reaper path.
Indefinite claim wait over timeout
- Chosen:
SandboxPool.claim()polls indefinitely (governed by shutdown event), logging at DEBUG then WARNING. - Alternative: Timeout after N seconds and raise an error.
- Why: The pool is typically smaller than
max_concurrent_agentsfor cost reasons. Waiting for a replacement pod is normal operational behavior, not an error. A timeout would cause spurious failures during burst traffic. The SQS semaphore already bounds concurrency. - If reversed: Burst traffic would cause avoidable failures instead of brief delays.
Pool config in Deployment only, not in agent configmap
- Chosen: In pool mode (
k8spool), the pod image, resources, labels, and security context are defined solely in the Deployment template. The agent config only needsnamespaceandactive_deadline_seconds. - Alternative: Keep image/resources/metadata in the agent configmap too
(as in
k8smode). - Why: Eliminates config duplication. The Deployment template is the single source of truth. Changes to pod resources or image only require updating one place and re-rolling the Deployment.
- If reversed: Config drift between the Deployment and the agent configmap would be a constant risk.
Pod lingering over immediate cleanup for reply latency
- Chosen: After a successful workflow, pool sandbox pods linger for
linger_seconds(default 300) with asession-idannotation. Replies within the window reclaim the pod viatry_reclaim(), skipping pod creation and S3 archive restoration. - Alternative: Always delete the pod immediately and restore from S3 on every reply.
- Why: Reply latency drops from seconds (pod claim + S3 restore) to
near-zero. The S3 archive is still saved as a fallback if the pod is
gone. The
reap-byannotation ensures lingering pods are cleaned up if no reply arrives. Thesession-idannotation doubles as a concurrency guard: it is only present while lingering, preventing active pods from being reclaimed. - If reversed: Every reply would pay full pod + restore latency, even for immediate follow-ups. User experience for conversational interactions would degrade noticeably.
linger() on Sandbox protocol over isinstance checks in runner
- Chosen: All sandbox backends implement
linger(session_id). Non-pool backends fall back tocleanup(). The runner callssb.linger()without type checks. - Alternative:
isinstance(sb, K8sPoolSandbox)inrun_workflow(). - Why: Keeps the runner backend-agnostic. Adding a new backend requires only implementing the protocol, not touching the runner. The fallback behavior is co-located with each backend.
- If reversed: The runner would need to know about every backend type and their linger capabilities.
GCP SA key over Workload Identity Federation
- Chosen: GCP service account key stored in Vault, rotated via cki-tools credential manager (prepare/switch/clean cycle).
- Alternative: Workload Identity Federation (WIF) with projected SA token. Two sub-options: (a) automatic OIDC discovery if the cluster issuer is public, (b) manual JWKS upload for internal issuers.
- Why: mpp-prod’s OIDC issuer is
https://kubernetes.default.svc(internal, not publicly reachable), so GCP STS cannot discover it automatically. Manual JWKS upload works but requires re-upload after SRE-triggered signing key rotations. SA key integrates with the existing credential manager rotation infrastructure (same pattern as AWS keys and GitLab tokens), needs no OIDC reachability, and enables automated validate/update viagoogle-authin CI. The application code (hummingbird_ai.http.VertexAuth/google.auth.default()) works identically with both approaches – switching to WIF later requires only infrastructure changes. - If reversed: Replace SA key with WIF projected token + credential
config ConfigMap. The
gcp_service_account_keytoken type in cki-tools would no longer be needed for this use case.
Developer+ trust filtering over unfiltered or sanitized discussion comments
- Chosen:
gitlab_get_mr_discussionschecks each note author’s project access level (Developer+ / >= 30). Untrusted notes are replaced with a fixed placeholder in mixed-trust discussions, or the entire discussion is dropped if all notes are untrusted. Agent notes are always trusted (detected by session marker) but have transcripts and metadata stripped. - Alternatives considered:
- (a) Include all comments unfiltered. Simplest, but any external contributor can craft comments that manipulate the model’s review output (prompt injection).
- (b) Sanitize/escape untrusted content (strip markdown, quote as code blocks, prefix with “[external]”). There is no reliable escaping mechanism for LLM prompts – the model interprets natural language regardless of formatting. Escaping gives a false sense of security.
- (c) Only include agent-authored notes (skip all human comments). Safe, but defeats the purpose: the model would never see developer responses to its own findings.
- (d) Include only discussions where the agent participated. Better, but still misses developer-initiated review threads that provide relevant context.
- Why: Developer+ is the same threshold used for pipeline trigger authorization (invariant #8) and slash command access. It matches the trust boundary already established: people who can push code and approve MRs are trusted to provide review context. The placeholder approach preserves discussion structure (the model sees that someone replied) without exposing the content. Full-drop for all-untrusted discussions avoids noise from discussions that contain zero useful context.
- If reversed: (a) opens a prompt injection vector on any project that accepts external MRs. (b) provides no actual protection. (c) makes follow-up reviews unable to see developer explanations, causing repeated false positives. (d) misses developer-initiated context.
Bot-author filtering for session marker parsing over unfiltered note scanning
- Chosen:
find_session_for_reply(),scan_agent_threads(), and thehandle_note()self-filter all resolve the bot user ID viaget_bot_user_id()(which callsgl.auth()on the orchestrator token) and only consider notes whereauthor.idmatches. Infind_session_for_reply(), the first matching marker wins. - Alternative: Parse markers from any note and keep the last match;
substring self-filter in
handle_note(previous behavior). - Why: The previous unfiltered approach allowed session hijacking: a
user reply or agent review prose containing an example marker
(
<!-- hummingbird-session: ... -->) was parsed as a real session, causing “session expired” errors in production. Forhandle_note, the substring check caused the reverse problem: a user embedding the marker prefix in a reply silently suppressed the event (denial of service). Thegl.auth()call is oneGET /userper invocation – negligible cost._get_client_with_bot_id()returns both the authenticated client and the bot user ID, avoiding redundant client construction. - If reversed: Any MR participant can inject a marker to hijack the session ID or inflate rate-limit counts. The agent’s own review prose containing example markers causes spurious “session expired” messages.
ijson streaming over buffered JSON for K8s list responses
- Chosen:
session.get(stream=True)+ijson.parse(resp.raw)foriter_paginated(). Items are yielded one at a time viaObjectBuilder; themetadata.continuepagination token is captured in the same parse pass.resp.raw.decode_content = Trueis required because Kubearchive returns gzip Content-Encoding; without itijsonsees compressed bytes. - Alternative:
session.get().json()loads the full page into memory (the original implementation). - Why: A single K8s list page can contain 500 PipelineRuns (80+ MB
JSON). Parsing this with
.json()creates a +247 MB RSS spike (raw bytes + decoded string + parsed dict coexist). With 4 concurrent workflows, this exceeds any reasonable pod memory limit.ijsonstream-parsing yields items one at a time, reducing the peak to +12 MB for the same data – a 95% reduction. The continue token appears in themetadataobject (before or afteritemsdepending on the server); the event-driven parser captures it regardless of order. - If reversed: Large MRs (100+ components) would OOM the agent pod. Concurrent workflows would multiply the problem. The pod memory limit would need to scale with the largest possible page size, which is unbounded.
Action token tier for per-workflow identity
- Chosen: A dedicated Tier 2 of workflow action tokens, separate from both orchestrator tokens (Tier 1) and model tool tokens (Tier 3). Each workflow gets a dedicated GitLab bot user per project for all write operations (notes, thread resolution).
- Alternative: Extend orchestrator tokens with a per-workflow fallback
chain (
ORCHESTRATOR_<WORKFLOW>_GITLAB_TOKEN_<PROJECT>→ORCHESTRATOR_GITLAB_TOKEN_<PROJECT>→ORCHESTRATOR_GITLAB_TOKEN). - Why: Orchestrator tokens serve a fundamentally different purpose
(operational reads + infrastructure notes) than workflow output (analysis
results, review comments). Mixing them in one fallback chain risks a
single bot identity posting notes on behalf of multiple workflows,
eliminating the audit trail. A separate tier with explicit
token: strparameters makes the code path unambiguous: callers must choose which tier they use. The naming convention (HUMMINGBIRD_AGENT_ACTION_<WORKFLOW>_GITLAB_TOKEN_<PROJECT>) groups tokens alphabetically by workflow, making env var auditing straightforward. - If reversed: All workflow notes would appear from the same bot user, making it impossible to distinguish which agent (code-review vs analyze-failures vs renovate-babysit) produced a given note in the MR timeline. GitLab’s audit log would attribute all actions to one identity.
Per-project-only action token config (no workflow-level default)
- Chosen:
action_tokensis defined at the project level only in YAML config. No workflow-levelaction_tokensdefault is supported. - Alternative: Allow workflow-level
action_tokensthat projects inherit by default, with per-project overrides. - Why: GitLab project access tokens are project-scoped. A workflow-level default would be misleading – a single token cannot write to multiple projects. Forcing per-project declaration makes the mapping explicit and prevents configuration errors where a cross-project default silently fails.
- If reversed: Operators could set a workflow-level token that only works for one project, causing silent 403 errors for other projects in the same workflow.
SSH over kubectl exec for KubeVirt VMs
- Chosen: SSH (
ssh -T sandbox@ip command) for VM command execution. - Alternative:
virtctl console(serial console), KubeVirt websocket API, orqemu-guest-agent exec. - Why: VMs have no
kubectl execequivalent.virtctl consoleprovides a serial terminal, not a programmable exec channel. The websocket API requires a VNC or serial connection.qemu-guest-agentis not installed in the VM image. SSH provides clean stdin/stdout/stderr separation, timeout control, and is already well-tested in subprocess pipelines. - If reversed: Would need
qemu-guest-agentin the image and theexecendpoint, which has poorer error reporting and no streaming stdin.
Secret volume for SSH key injection
- Chosen: Kubernetes Secret mounted as a virtio disk (serial
ssh-pubkeys) read by the VM’sinject-ssh-keys.serviceat boot. - Alternative:
cloudInitNoCloudoraccessCredentialswithqemu-guest-agent. - Why: The VM image already has
inject-ssh-keys.servicewhich reads from the virtio disk.cloudInitNoCloudrequires cloud-init in the image.accessCredentialsrequiresqemu-guest-agent. The Secret volume approach requires no additional software in the VM image. - If reversed: Would need cloud-init or guest agent in the bootc image, adding complexity and attack surface.
VMIRS for VM pool replenishment
- Chosen:
VirtualMachineInstanceReplicaSet(VMIRS) as the replenishment controller for the VM pool. - Alternative: Custom controller, or VirtualMachine with RunStrategy.
- Why: VMIRS is KubeVirt’s native equivalent of a Deployment’s ReplicaSet – it maintains a desired count of identical VMIs. When a VMI is claimed (ownerReferences cleared), the VMIRS creates a replacement automatically. Same pattern as the pod pool with Deployment.
- If reversed: Would need a custom controller to maintain the VMI pool, adding operational complexity.
Per-workflow sandbox backend
- Chosen:
sandbox:field in workflow config, resolved per-workflow with fallback to CLI--sandboxflag. - Alternative: Global-only sandbox selection via CLI.
- Why: Different workflows have different isolation requirements. Code review needs only a lightweight container, while failure analysis may need a full VM with root access. Per-workflow selection avoids running all workflows in VMs (wasteful) or all in containers (insufficient isolation).
- If reversed: All workflows would share the same sandbox backend, requiring either over-provisioning (VMs for everything) or accepting weaker isolation for some workflows.