Hummingbird Agent Design — Runtime
Data sources, event pipeline, sessions, workflows, and deployment for the Hummingbird Agent. This page covers the runtime: how the agent connects to external systems, processes events, manages sessions, and is deployed. For the execution engine (agent loop, tool system, sandbox), see Hummingbird Agent Design — Engine.
9. Data Sources
Data sources are external APIs wrapped as tool-calling functions. The model invokes them by name; the orchestrator executes them and returns results (inline or auto-spilled). Each data source module follows the same pattern:
- Define
TOOL_DEFS– a list ofToolDefobjects with names, descriptions, parameter schemas, and optional notes. - Define implementation functions that take a pre-configured client as the first argument.
- Provide a
register()function that creates the client, wraps implementations withfunctools.partial, and callsregistry.register_data_source()for each tool.
Credentials are captured at registration time via functools.partial
closures. They are never stored as global state and never leak into tool
definitions or model contents.
9.1. GitLab
Uses python-gitlab library with retry_transient_errors=True for
automatic retry on transient HTTP errors.
Tools:
gitlab_get_mr_details– MR metadata (title, author, SHA, labels, URLs)gitlab_get_commit_statuses– all CI/CD statuses for a commit SHA (paginated automatically). Covers both Konflux external statuses and native GitLab CI job statuses. Each status includestarget_url(job page link containing the job ID) andallow_failure.gitlab_get_mr_diff– changed files in the MRgitlab_get_file_at_ref– raw file content at a git refgitlab_get_repo_archive– repository tar.gz viarepository_archive(iterator=True). Returns aStreamingResponsewith chunked binary data (suffix.tar.gz), so the archive streams to the sandbox without buffering in orchestrator memory.gitlab_get_job_log– job trace (log output) for a GitLab CI job. Useslazy=Trueon the job object andtrace(iterator=True)for streaming. Each chunk is decoded witherrors='replace'and ANSI escape codes are stripped per-chunk before re-encoding. Returns aStreamingResponse(suffix.log). Per-chunk ANSI stripping is safe because escape sequences are <20 bytes and chunks are 1024+ bytes.
No response_metadata is set because GitLab commit statuses already contain
target_url fields that the model uses for linking.
gitlab_get_mr_discussions – trust filtering and redaction.
MR discussions are the first data source that feeds user-generated free text into the model prompt. Unlike diffs and CI logs (which are code or machine output), discussion comments can contain arbitrary prose written by anyone who can post on the MR – including external contributors on public projects. This creates a prompt injection vector: an attacker posts a comment containing instructions that manipulate the model’s review output.
The discussions tool addresses this with a three-layer defence:
-
Author trust gate. Each note’s author is checked for Developer+ access (level >= 30) on the project via
members_all.get(author_id). Results are cached perauthor_idwithin a single call to avoid repeated API lookups. System notes (merge events, label changes) bypass the author check. Agent-authored notes are trusted via their author’s Developer+ access (the bot account holds Developer access on configured projects). Note: agent note detection for redaction purposes (stripping transcripts and footers) uses session marker presence, but trust is always based on the author’s access level, not the marker. This prevents marker injection from granting trust to non-member notes. -
Redaction, not sanitization. Untrusted notes in a discussion that also contains trusted notes are replaced with a fixed placeholder (
[redacted: non-member comment]). The placeholder preserves the conversation structure (the model sees that someone replied, but not what they said). Discussions where all notes are untrusted are dropped entirely. No attempt is made to sanitize or escape untrusted content – there is no reliable escaping mechanism for LLM prompts, so the only safe option is to withhold the content entirely. -
Agent note stripping. Agent-authored notes contain large
<details><summary>Agent transcript</summary>...</details>blocks (15K-90K chars of raw tool calls), metadata footers, and session markers. These are stripped before the note enters the model prompt, leaving only the review text. This serves dual purposes: token efficiency and avoiding feeding the model its own raw tool call history (which could cause degenerate self-referential loops).
Residual risks and scope limitations:
- A compromised Developer+ account can inject adversarial content. This is accepted as equivalent to the existing risk of a compromised developer pushing malicious code (which the agent would also process).
- The trust threshold is project-level (Developer role on the project), not MR-level. A developer on the project can influence any MR’s review.
- Comments posted after the discussions are fetched but before the review is posted are not seen. This is a TOCTOU gap but has no security impact (the model simply misses late comments).
9.2. Konflux
Uses raw requests against K8s and Kubearchive APIs (not the kubernetes
Python SDK). This avoids the heavy kubernetes client dependency for what is
essentially bearer-token HTTP with label selectors.
Dual-fetch architecture:
flowchart LR
subgraph fetch [Fetch Phase]
KA["Kubearchive<br/>(historical)"]
K8s["K8s API<br/>(live)"]
end
Combine["Combine items"]
Dedup["Deduplicate<br/>by metadata.uid"]
KA --> Combine
K8s --> Combine
Combine --> Dedup
For each resource type (PipelineRuns, TaskRuns), the client fetches from
both Kubearchive (completed/historical resources) and the live K8s API,
combines the results, and deduplicates by metadata.uid. This ensures no
resources are missed regardless of whether they have been archived yet.
Two label selectors. Konflux uses different labels for BUILD and TEST PipelineRuns:
- BUILD:
pipelinesascode.tekton.dev/sha=<commit_sha> - TEST:
pac.test.appstudio.openshift.io/sha=<commit_sha>
Each iter_pipelineruns()/iter_taskruns() call queries both selectors and
combines the results. _iter_resources(endpoint, label_selectors) is the
generic implementation: it creates one session, iterates each selector through
_iter_both_sources(), and deduplicates across selectors by UID.
Per-PLR TaskRun fetching. iter_taskruns() accepts either commit_sha
(two-selector path) or pipelinerun (single selector:
tekton.dev/pipelineRun=<name>). When pipelinerun is set, only the
~15-21 TaskRuns for that PipelineRun are fetched (~600 KB) instead of all
TaskRuns for the commit (~530 MB for large commits). This is server-side
filtering — the K8s API and Kubearchive both support the
tekton.dev/pipelineRun label selector.
Status filtering and streaming counts. _items_to_jsonl(items, status, stats) accepts an optional status: set[str] | None allowlist and a
_StreamStats accumulator. For each item it increments stats.by_reason
(keyed by reason) regardless of the filter (so counts reflect all items
seen), then skips items whose status.conditions[0].status is not in the
allowlist. The allowlist values are "True" (success), "False" (failure),
or "Unknown" (in-progress) — only 3 possible values, covering every Tekton
reason without enumeration. Items without conditions always pass. The
_finalize() generator wrapper exhausts the chunks iterator, then writes
counts, included, and excluded into the StreamingResponse.metadata
dict — after the sandbox file is fully written. The filtering happens O(1)
per item with no buffering.
StreamingResponse.metadata. StreamingResponse (in tools.py) has a
mutable metadata: dict[str, Any] field (default empty dict). Both
_exec_fetch_to_sandbox() and _spill_streaming() merge it into the response
dict after the iterator is exhausted. The Konflux code populates it with
counts, included, and excluded; other data sources can use it for their
own post-streaming summaries without touching tools.py.
Pod log fetching. get_pod_log() accepts an optional container
parameter. When specified, it fetches logs for that single container. When
omitted, it discovers all containers from the pod spec and concatenates
their logs with === container_name === headers. Each individual log
fetch tries Kubearchive first, then falls back to the live K8s API. Logs
may be unavailable from both sources if the pod has expired.
Streaming pagination. K8s list endpoints can return pages of 80+ MB
when a commit touches many components (e.g. 500 PipelineRuns per page).
iter_paginated() uses session.get(stream=True) and ijson.parse() to
stream-parse each page: items are yielded one at a time via
ObjectBuilder, and the metadata.continue pagination token is captured
from the same parse pass. resp.raw.decode_content = True is set so
urllib3 transparently decompresses gzip/deflate Content-Encoding inline
(Kubearchive returns gzip-compressed responses). Only one item is in
memory at a time – O(single_item) regardless of page size. Truncated or
malformed streams raise IncompleteJSONError, which is caught and treated
like a network error (log a warning, stop iterating that source).
Credential resolution. KonfluxClient.__init__() parses the kubeconfig
file to extract the API server URL and bearer token for the cluster. The
cluster domain is extracted from the cluster_url config value. This
approach avoids depending on kubectl or the kubernetes Python library for
API authentication.
Response metadata: Static metadata {"konflux_ui": "...", "konflux_pipelinerun_url_pattern": "..."} is merged into every response so the model can build reviewer-facing links.
Dynamic per-call metadata (counts, included, excluded) is populated
into StreamingResponse.metadata by _finalize() during streaming and
merged by tools.py after the iterator is exhausted.
9.3. Testing Farm
Uses requests.Session with a module-level retry adapter (429, 5xx) for
resilience against transient errors.
Tools:
tf_get_results– JUnit XML results for a request IDtf_get_test_log– individual test log by URL (from results.xml); restricted to theARTIFACTS_BASEprefix to prevent SSRFtf_get_request_status– request state, queue/run times
ToolDef notes on tf_get_results document the XML structure:
//testsuites/@overall-result, //testcase/@result, //testcase/logs/log
with @name and @href. This domain knowledge goes into the system prompt
so the model knows how to parse the XML with sandbox_exec using
python3 xml.etree.ElementTree.
Response metadata: {"artifacts_base": "https://artifacts.osci.redhat.com/testing-farm"}
is merged into every response so the model can build artifact links like
[{request_id}]({artifacts_base}/{request_id}/).
9.4. Registration flow
data_sources.register_selected() is the entry point called by runner.py.
It iterates the workflow config’s data_sources dict and registers only the
declared sources:
for ds_name, ds_cfg in wf_cfg.data_sources.items():
if ds_name == "gitlab":
token = resolve_tool_token(proj_entry, "gitlab", wf_cfg)
gitlab.register(registry, gitlab_url, token)
elif ds_name == "konflux":
cluster_url = resolve_cluster_url(ds_cfg)
kubeconfig_path = os.environ.get(ds_cfg.kubeconfig_env, "")
konflux.register(registry, cluster_url, kubeconfig_path, ds_cfg.kubearchive_url)
elif ds_name == "testing_farm":
testing_farm.register(registry)
This selective registration means a workflow with data_sources: {gitlab: {...}}
only exposes GitLab tools to the model. Konflux and Testing Farm tools do
not appear in the tool definitions, preventing the model from attempting to
use unconfigured data sources.
9.5. Data flow: streaming vs buffered
Data moves from external APIs through the orchestrator to the model (inline or auto-spilled to sandbox). The memory profile of each tool depends on whether the HTTP response is consumed incrementally or buffered entirely.
Streaming (preferred for large/unbounded responses). The HTTP response
is consumed incrementally – via ijson stream parsing, iterator=True
in python-gitlab, or lazy pagination. Only one chunk or item is in memory
at a time. Used by:
iter_paginated()(Konflux) –session.get(stream=True)+ijsonget_commit_statuses()(GitLab) –statuses.list(iterator=True)via_iter_commit_statuses(), yielding JSONL bytes one status at a timeget_repo_archive()(GitLab) –repository_archive(iterator=True), returns chunked tar.gz binary viaStreamingResponse(suffix=".tar.gz", binary=True)– no text preview is generatedget_job_log()(GitLab) –trace(iterator=True)with per-chunk ANSI stripping, returnsStreamingResponse(suffix=".log")
All produce StreamingResponse objects that flow through
stream_to_file() into the sandbox without accumulating in orchestrator
memory. StreamingResponse.suffix controls the auto-spill filename
extension (.jsonl, .tar.gz, .log). StreamingResponse.binary
controls whether a head preview is captured (False for text, True to
skip for opaque binary formats).
Buffered (acceptable for small/bounded responses). The full response is loaded into memory as a string or dict. This is fine when the response size is bounded and small (typically < 1 MB). Used by:
get_mr_details()(GitLab) – single MR object, < 10 KBget_file_at_ref()(GitLab) – single file, bounded by repo constraintstf_get_results(),tf_get_test_log(),tf_get_request_status()(Testing Farm) – XML/text/JSON, typically < 1 MB
Buffered with risk (candidates for future streaming). Same buffered pattern but the response size is not bounded by design. Auto-spill mitigates the context growth problem (large outputs are written to sandbox files), but the orchestrator still spikes RSS during the fetch:
get_pod_log()(Konflux) –resp.textper container, concatenated. Multi-container pods accumulate all logs.get_mr_diff()(GitLab) – full diff JSON, scales with MR size. GitLab truncates server-side but the result can still be large.
Invariant: paginated K8s list endpoints must always use streaming. Page sizes scale with the number of components in the commit – a single page can contain hundreds of PipelineRuns or TaskRuns (80+ MB JSON). Buffering these responses risks OOM under normal production workloads, especially with concurrent workflows.
10. Event Pipeline and Production Operations
10.1. Two-stage SQS pipeline (ingress router + FIFO worker)
Events flow through a two-stage pipeline to serialize per-session work:
flowchart LR STD["Standard SQS<br/>(ingress)"] R["Router"] FIFO["SQS FIFO<br/>(work queue)"] W["Worker<br/>(poll_loop)"] STD -->|ReceiveMessage| R R -->|"SendMessage<br/>MessageGroupId"| FIFO W -->|"continuation<br/>group=session_id"| FIFO FIFO -->|ReceiveMessage| W
Ingress router. events.router_loop() is a single-threaded loop
that long-polls the standard (ingress) queue, peeks into each SNS
envelope to assign a MessageGroupId, forwards the raw message body
to the FIFO queue, then deletes from the standard queue.
- Note events:
MessageGroupId = discussion_idfrom the webhook body. This serializes replies to the same agent session – FIFO delivers at most one in-flight message per group. - Pipeline / MR events:
MessageGroupId = SQS MessageId(unique per message). No serialization – each event is its own group. - MessageDeduplicationId: Always the SQS
MessageIdfrom the standard queue. Absorbs duplicate deliveries from standard SQS’s at-least-once semantics (5-minute dedup window).
If SendMessage to FIFO fails, the message is not deleted from the
standard queue and retries via visibility timeout.
FIFO worker. events.poll_loop() long-polls the FIFO queue,
decodes SNS envelopes, and dispatches to handle_event. The
max_concurrent_agents semaphore caps parallel groups being processed.
FIFO guarantees that within a group, only one message is in-flight.
Two-phase processing. When the FIFO worker picks up a webhook
(Phase 1), the handler validates the event, resolves the session_id,
then posts a continuation message back to the same FIFO with
MessageGroupId = session_id. The Phase 1 message is deleted quickly
(~1-5s). Phase 2 processes the continuation: loading session state,
building WorkflowRequest, and calling _execute_workflow. Because
Phase 2 continuations share a MessageGroupId per session, all work
for a given session is serialized – even if it spans multiple GitLab
discussion threads.
Uniform message format. Both webhook messages (from SNS) and
continuation messages use the same SNS-style envelope with gzip+base64
compression. encode_envelope() is the inverse of
decode_sns_message(). The consumer doesn’t need to distinguish
between webhook and continuation messages at the transport layer.
Graceful shutdown. SIGTERM/SIGINT set a shutdown_event. Both
loops exit. Messages in SQS become visible again after the visibility
timeout for other consumers.
Why two queues. The standard queue receives events from SNS (via subscription). The FIFO queue serializes per-session work. The gitlab-event-forwarder and SNS subscription are unchanged – the routing logic lives entirely in the agent codebase.
10.2. SNS envelope decoding
Messages arrive as SNS notifications with:
MessageAttributes.source– event source (e.g.,"gitlab")MessageAttributes.event_type– event type (e.g.,"pipeline","merge_request","note")MessageAttributes.content_encoding–"gzip+base64"for gitlab-event-forwarder, absent for kubernetes-event-forwarderMessage– the actual event body (JSON string, or gzip+base64 encoded)
decode_sns_message() handles both encoding formats transparently.
10.3. Event routing
runner.handle_event() dispatches on (source, event_type):
flowchart TD
Event["Event arrives"]
Check{"source/type?"}
Cont["handle_continuation()"]
Pipeline["handle_pipeline()"]
MRHandler["handle_merge_request()"]
Note["handle_note()"]
Ignore["Ignore"]
Event --> Check
Check -->|"agent/continuation"| Cont
Check -->|"gitlab/pipeline"| Pipeline
Check -->|"gitlab/merge_request"| MRHandler
Check -->|"gitlab/note"| Note
Check -->|"other"| Ignore
Pipeline --> FilterSource{"source = merge_request_event?"}
FilterSource -->|"yes"| AutoResolve["auto-resolve on success<br/>(unconditional)"]
FilterSource -->|"no"| Skip1["Skip"]
AutoResolve --> PipeRules["evaluate trigger_rules<br/>(status + user + branch + title)"]
PipeRules -->|"allow"| Enqueue1["_enqueue_continuation()"]
PipeRules -->|"deny"| Skip1a["Skip"]
MRHandler --> FilterMRAction{"action in open/update?<br/>draft = false?"}
FilterMRAction -->|"yes"| MRRules["evaluate trigger_rules<br/>(user + branch + title)"]
FilterMRAction -->|"no"| Skip1b["Skip"]
MRRules -->|"allow"| Enqueue2["_enqueue_continuation()"]
MRRules -->|"deny"| Skip1c["Skip"]
Note --> FilterMRNote{"MR note?<br/>action = create?"}
FilterMRNote -->|"yes"| NoteType
FilterMRNote -->|"no"| Skip3["Skip"]
NoteType{"Note type?"}
NoteType -->|"author_id == bot_id"| Skip4["Skip (own note)"]
NoteType -->|"/hummingbird help"| Help["post_simple_reply (help)"]
NoteType -->|"/hummingbird wf-name"| SlashCmd["_resolve_slash_workflow()<br/>→ _enqueue_continuation()"]
NoteType -->|"DiscussionNote reply"| FindSession["find_session_for_reply()"]
NoteType -->|"other"| Skip5["Skip"]
FindSession --> Found{"session found?"}
Found -->|"yes"| Enqueue3["_enqueue_continuation()<br/>(resume_session)"]
Found -->|"no"| Skip6["Skip"]
Cont --> ContType{"work_type?"}
ContType -->|"new_session"| ExecNew["_execute_workflow()"]
ContType -->|"resume_session"| LoadS3["load session from S3<br/>→ _execute_workflow()"]
Phase 1 handlers (handle_pipeline, handle_merge_request,
handle_note) perform validation, auth checks, and session resolution,
then post a WorkOrder continuation message via _enqueue_continuation().
They do not call _execute_workflow directly.
Phase 2 (handle_continuation) receives the WorkOrder, resolves
the workflow config, builds a WorkflowRequest, and calls
_execute_workflow. For resume_session orders, it loads the previous
session from S3. _execute_workflow handles SHA dedup and per-workflow
rate limiting via scan_agent_threads.
Slash commands dispatch to a single named workflow via
_resolve_slash_workflow (exact match, then prefix match). Help and error
replies are posted via post_simple_reply (no session marker, no rate
limit impact).
10.4. Pipeline trigger design
Why gitlab::pipeline instead of kubernetes::PipelineRun: A GitLab
pipeline event fires once when the pipeline completes. Since Konflux
external stages are attached to the pipeline, the event naturally waits for
all builds and tests to finish before triggering. This means the agent sees
the full picture in one event, without needing to deduplicate or wait for
stragglers.
Trigger filters:
statusin{failed}– only failed pipelines trigger analysissource == "merge_request_event"– only MR pipelines, not branch/tag
10.4a. Merge request trigger design
The handle_merge_request handler fires on MR open, reopen, and update
events (action in {"open", "reopen", "update"}). Draft MRs are skipped
(object_attributes.draft == True); marking a draft as ready triggers a
review since the event arrives with draft: false.
Event classification is deliberately simple: the handler does not inspect
oldrev or changes.draft fields. Instead, SHA-based deduplication in
_execute_workflow (via JSON session markers) ensures each code revision
is reviewed at most once. Metadata-only updates (title, label changes) on
an already-reviewed SHA are silently skipped.
10.5. Note trigger design
Two sub-flows:
Slash command (/hummingbird <workflow-name>): Triggers a specific
named workflow, bypassing rate limiting. _resolve_slash_workflow performs
exact-match lookup first, then falls back to unique prefix matching. If
the subcommand is missing or is “help”, _format_help returns a list of
available workflows with descriptions. Ambiguous or unknown subcommands
produce an error message via post_simple_reply. The note author must
have Developer+ access (level >= 30) on the project, checked via
check_member_access(). If denied, the agent replies in the same
discussion thread with a short access-denied message.
Reply to agent note: When a user replies to an existing agent note (which contains a JSON session marker):
find_session_for_reply()walks the discussion thread, filtering by bot author ID (get_bot_user_id()), and returns the first matching session marker. All bot markers in a thread share the same session ID.handle_noteposts aresume_sessioncontinuation to the FIFO (grouped bysession_id).handle_continuationloads the session from S3 (conversation history and sandbox archive), builds aWorkflowRequest, and calls_execute_workflow()to resume with the user’s reply.- If the session is not found (expired/deleted), the agent replies with a message explaining the session has expired and suggesting to start a new run.
Reply authors are also subject to the Developer+ access check. If denied, the agent replies in the discussion thread with the same access-denied message.
Non-agent-directed notes: Regular comments that are neither slash commands nor replies to agent threads are silently ignored (debug-level log). No access check is performed for these.
Self-note filtering: Notes where author_id matches the bot’s own
user ID (resolved via get_bot_user_id()) are skipped immediately. This
prevents infinite loops where the agent’s own output triggers another
agent run. The author-ID check replaces the previous substring check
(marker_prefix_str in note_body), which was vulnerable to
denial-of-service: a user including the marker prefix in their reply
would cause the bot to silently ignore it.
Threading logic for replies: If the project requires internal notes
(internal_notes: true) but the original discussion was public, the reply
is posted as a new top-level internal note instead of replying in the
public thread. This prevents leaking internal analysis into public threads.
10.6. Note lifecycle
1. create_placeholder_note() -> "Running the <workflow> workflow..."
+ JSON session marker (id, wf, sha)
2. run_workflow() -> agent loop
3. update_note() -> replace placeholder with result
+ reply prompt + JSON session marker
Reply notes (from session resumption) omit the reply prompt since the
user is already engaged in the conversation.
On failure, the placeholder is updated to “Hummingbird analysis failed.” The JSON session marker is always present so the note can be identified as agent-generated (for rate limiting, SHA dedup, and self-note filtering). The marker embeds the workflow name and commit SHA, enabling per-workflow rate limiting and SHA-based deduplication.
10.7. Rate limiting and SHA deduplication
scan_agent_threads() performs a single pass over MR discussions, parsing
JSON session markers (<!-- hummingbird-session: {"id":...,"wf":...,"sha":...} -->)
to determine:
- Per-workflow thread count – how many threads the given workflow has
created on this MR. If the count meets or exceeds
max_runs_per_mr, the workflow is skipped. - SHA dedup – whether the current commit SHA has already been reviewed by this workflow. If so, the workflow is skipped (prevents re-reviewing the same code on metadata-only MR updates).
Slash commands and replies bypass both checks entirely.
The rate limit is per-workflow per-MR: different workflows maintain
independent thread counts on the same MR. JSON markers are backward
compatible – old plain-UUID markers (<!-- hummingbird-session: UUID -->)
are parsed as {"id": "UUID"} with no workflow or SHA information, so they
are not counted toward any specific workflow’s limit.
11. Session System
Sessions enable conversation continuity: a user can reply to an agent note and the agent picks up where it left off, with full context and sandbox files restored.
11.1. What gets saved
Three artifacts are saved after each run:
context.json– the fullcontentslist from the agent loop in the canonical provider-independent format. It is the minimum state needed to resume the conversation; format conversion is handled by the AI library.transcript.md– a human-readable markdown rendering of the run for debugging and auditing. Not used for resumption.sandbox.tar.gz– an archive of/tmp/data/(the sandbox working directory, which includes the_out/spill subdirectory). Contains PipelineRun JSONs, test logs, jq output, and any other files the model created during the run. Restored into the new sandbox on resumption so the model can reference its previous work.
11.2. Storage backends
S3 (production):
s3://{bucket}/sessions/{session_id}/context.json
s3://{bucket}/sessions/{session_id}/transcript.md
s3://{bucket}/sessions/{session_id}/sandbox.tar.gz
Local directory (development, --save-session):
{directory}/context.json
{directory}/transcript.md
{directory}/sandbox.tar.gz
Both backends have the same interface. S3 save is best-effort: wrapped in try/except so a transient S3 error does not prevent the MR note from being posted. The note always gets delivered first.
11.3. Sandbox archive transport
The sandbox archive requires special handling because the orchestrator cannot directly access the sandbox filesystem (no volume mounts):
Archive (inside sandbox):
sb.exec("tar czf /tmp/_archive.tar.gz -C /tmp data")
Stream out (sandbox -> host temp file):
for chunk in sb.read_file_iter("/tmp/_archive.tar.gz"):
tmp.write(chunk) # 64 KB chunks, no base64
Restore (host temp file -> new sandbox):
sb.stream_exec("tar xzf - -C /tmp", file_chunks())
Both directions use streaming binary I/O via Popen stdin/stdout pipes.
No base64 encoding is needed: read_file_iter() yields raw bytes from
the sandbox via a Popen stdout pipe, and stream_exec() feeds raw bytes
into a Popen stdin pipe. The archive is never extracted on the
orchestrator – it exists only as opaque bytes being transported between
sandboxes. This is a security invariant: the orchestrator never parses or
inspects the archive contents.
11.4. Resumption flow
flowchart TD
Reply["User replies to agent note"]
FindSession["find_session_for_reply()<br/>walks discussion thread"]
LoadS3["load_s3(session_id)"]
NotFound{"session found?"}
Expired["Post session-expired reply"]
NewSandbox["Start new sandbox"]
Restore["restore_sandbox(archive)"]
BuildContents["contents = old_contents + user_reply"]
RunLoop["run_agent_loop(<br/>initial_contents, CONTINUATION_PROMPT)"]
Reply --> FindSession --> LoadS3
LoadS3 --> NotFound
NotFound -->|"no"| Expired
NotFound -->|"yes"| NewSandbox
NewSandbox --> Restore --> BuildContents --> RunLoop
Key aspects:
- Fresh iteration budget. The resumed session starts iteration 0 with
the full
max_iterationsbudget, regardless of how many iterations the previous session used. - Context window is the real limit. A typical 8-iteration cold start
uses ~50-100K input tokens. The restored history is sent in full on every
API call. The
CONTEXT_LIMITcheck applies and will force wrap-up if needed. - Session ID reuse. The resumed session keeps the original session ID. S3 state is overwritten in place with the updated conversation history. Since GitLab discussions are linear (not branching), there is no need for a tree of sessions – the conversation is a single sequential thread.
- Graceful degradation. If the S3 session is not found (expired, deleted), the agent posts a reply explaining the session has expired and suggesting to start a new run. It does not fall back to a cold start.
11.5. Session markers
Every agent note contains a hidden HTML comment with a JSON payload:
<!-- hummingbird-session: {"id":"UUID","wf":"code-review","sha":"abc123"} -->
The JSON payload contains:
id– session UUID (always present)wf– workflow name (present for new-format markers)sha– commit SHA at the time of review (present for auto-triggered runs)
This marker serves four purposes:
- Resumption:
find_session_for_reply()searches discussion threads for this marker to find the session ID. Only bot-authored notes are considered; the first matching marker wins. - Per-workflow rate limiting:
scan_agent_threads()counts discussion threads for a specific workflow using thewffield. Only bot-authored notes are scanned. - SHA deduplication:
scan_agent_threads()checks whether the current SHA has already been reviewed by the workflow using theshafield. - Self-filtering:
handle_note()compares the webhook event’sauthor_idagainst the bot’s own user ID (viaget_bot_user_id()) to prevent infinite loops.
All marker-scanning functions resolve the bot user ID via gl.auth()
(GET /user) on the orchestrator token. This ensures markers in
non-bot notes (user replies, external contributors) are never parsed,
preventing session hijacking via injected markers.
Old plain-UUID markers (<!-- hummingbird-session: UUID -->) are parsed
as {"id": "UUID"} for backward compatibility. They are counted for
resumption but not for per-workflow rate limiting or SHA dedup (no
wf/sha information).
12. Workflow System
12.1. Separation of prompt and metadata
A workflow has two parts that live in different places:
- Prompt (
.mdfile): the LLM system prompt, verbatim. This is the investigation strategy, tool usage guidance, data patterns, and output format. Pure text, no code. - Metadata (YAML config): operational settings –
action,model,max_iterations,data_sources,projects. This controls what the orchestrator does with the workflow, not what the model does.
This separation means changing the analysis strategy (e.g., adding a new investigation step) requires editing a markdown file. Changing operational parameters (e.g., which projects use this workflow, which model to use) requires editing the YAML config. Neither requires a code change.
12.2. System prompt layering
The system prompt sent to the model is built from three layers:
BASE_SYSTEM_PROMPT # agent.py: sandbox rules, tool usage tips
+ tool_notes # from ToolRegistry: per-data-source domain knowledge
+ workflow_body # full content of e.g. workflows/analyze-failures.md
On session resumption, a fourth layer is appended:
+ CONTINUATION_PROMPT # agent.py: "do not re-run the full workflow"
The system prompt is rebuilt every iteration (to allow warning suffixes to be appended), but the base content is stable. Warning suffixes are appended at the end so they override earlier instructions.
12.3. Design choice: strategy, not procedure
The workflow .md file describes strategy and guidance, not a rigid script.
The model decides when and how to use each tool based on what it sees.
This matters because merge request failures are diverse. A fixed procedure would either miss edge cases (e.g., build failures before tests ran) or waste iterations on steps that don’t apply. By giving the model a strategy (“identify failing tests, fetch details, analyze root causes, group by similarity”), it can adapt to whatever it encounters.
The workflow does structure the investigation into phases (Data Collection, Analysis, Output) for clarity, but these are guidelines, not enforced checkpoints.
12.4. Workflow file anatomy (analyze-failures.md)
The primary workflow is structured as:
- Scope and approach – what this workflow analyzes and what it ignores
- Input – event JSON format (
project,iid) - Data Collection Phase:
- Data.1: Fetch MR details (direct call, small response)
- Data.2: Fetch commit statuses, identify failures
- Data.3: Batch fetch PipelineRuns + TaskRuns (fetch_batch_to_sandbox)
- Data.4: Process with jq, extract Testing Farm data in bulk
- Analysis Phase:
- Analysis.1a: Investigate each failed PipelineRun individually
- Analysis.1b: Summarize and group by root cause
- Output – markdown template with root causes, collapsible details, clickable links (PipelineRun, Testing Farm, test logs)
- Error Handling – partial report philosophy
Design considerations embedded in the workflow:
- Efficient bulk fetching: PipelineRuns and TaskRuns are fetched in one
fetch_batch_to_sandboxcall, not individually. - Testing Farm data extracted in Data.4, analyzed in Analysis.1: All TF results.xml are fetched in bulk before analysis starts, enabling cross-failure pattern detection.
- Log fetching is selective: Only 1-2 representative logs per failure pattern, not all logs. This keeps iteration count bounded.
- Reviewer-facing URLs: The output template instructs the model to
include clickable links using
konflux_uiandartifacts_basefrom response metadata.
12.5. Prompt file resolution
get_prompt(workflow_cfg, config_dir) resolves the prompt: field relative
to the config file’s directory:
prompt_path = config_dir / workflow_cfg.prompt
# e.g. /app/config.yaml with prompt: workflows/analyze-failures.md
# -> /app/workflows/analyze-failures.md
In the container image, workflows are baked in at /app/workflows/. A
ConfigMap can override them at deploy time by mounting at the same path.
13. Deployment and Container
13.1. Container build strategy
The Containerfile uses an all-RPM builder+installroot+scratch pattern (no pip, no venv):
- Builder stage: Uses a Fedora-based builder image with dnf. Installs
all dependencies as RPMs into a clean
--installroot. - Application code: Copies
hummingbird_agent/andworkflows/into the installroot at/app/. - Final stage:
FROM scratch, copies the entire installroot. No package manager, no shell beyond what RPMs provide.
RPM dependencies: python3-boto3, python3-google-auth+requests,
python3-gitlab, python3-kubernetes, python3-pyyaml, python3-requests,
python3-sentry-sdk, kubernetes1.35-client (for kubectl).
This approach was chosen over pip because:
- All deps come from Fedora’s package repository – no PyPI supply chain risk
- Smaller image (~22 MB saved vs. google-genai SDK alone)
- Reproducible builds from known RPM versions
- No compilation step (no gcc/python3-devel in the image)
13.2. Container runtime properties
CMD ["python3", "-m", "hummingbird_agent", "serve"]
WORKDIR /app
USER 65532
The default CMD runs serve mode for production. Local development uses
run mode via explicit command override. USER 65532 matches the standard
nonroot UID used by distroless images and the Podman sandbox.
13.3. K8s deployment manifests
Located in hummingbird-agent/kubernetes/:
deployment.yaml:
- Single replica with rolling update (
maxSurge: 1,maxUnavailable: 0) terminationGracePeriodSeconds: 900(15 minutes) to allow in-flight agent runs to complete on shutdown- ServiceAccount:
hummingbird-agent - Secrets mounted from K8s Secret
hummingbird-agent - Commented-out mounts for workflow ConfigMap and custom CA trust
rbac.yaml (applied in the sandbox namespace):
- ServiceAccount
hummingbird-agent - Role with
pods: create, get, list, delete, patchandpods/exec: create - RoleBinding linking the SA to the Role
patchis required for pool mode (relabeling pods during claim)
sandbox-pool.yaml (applied in the sandbox namespace):
- Deployment with
replicas: 3(tuned to balance latency vs cost) - Pods labelled
hummingbird/role: standbyfor pool discovery - Same security context and resource limits as direct-creation pods
revisionHistoryLimit: 2, rolling update withmaxSurge: 1,maxUnavailable: 0
networkpolicy.yaml:
podSelector: {}– applies to all pods in the namespaceegress: []– deny all outbound traffic
secret.yaml:
- Template with all required env vars (API keys, tokens, URLs)
- Values must be populated per deployment
13.4. Workflow mounting
Workflows are baked into the image at /app/workflows/. To update workflows
without rebuilding the image:
- Create a ConfigMap from the workflows directory
- Uncomment the volume mount in the deployment YAML
- The ConfigMap mount replaces the baked-in directory (all-or-nothing)
This is useful for rapid iteration in staging without waiting for a new image build.
13.5. Custom CA trust
For clusters with internal CA certificates (common in enterprise environments), the deployment supports OpenShift’s CA injection:
- Create a ConfigMap with the
config.openshift.io/inject-trusted-cabundlelabel - Mount it at
/etc/pki/custom - Set
REQUESTS_CA_BUNDLE=/etc/pki/custom/ca-bundle.crt
OpenShift automatically injects the cluster’s CA bundle into the ConfigMap.