Agents in CI
The team upgrades Spring Boot across BoxOffice on a Monday morning. Fourteen services, fourteen red builds: a renamed configuration property here, a deprecated method there, a test that relied on an old default. Each fix is small and boring; together they take two engineers two days. An agent β a model that plans, uses tools, observes the result and tries again β can do most of this work while the engineers review. It can also, if badly designed, push to main, leak the deploy key, loop for an hour on a flaky test, or "fix" a red build by deleting the assertion. This page is about the design that gets the first outcome and makes the second impossible.
What agents do in a pipeline
The agent loop β the model chooses a tool, the harness runs it, the result goes back into the context, repeat β becomes, in CI, a loop over the repository and the build:
| Agentic workflow | Trigger | Verifiable by | Typical blast radius | Sensible autonomy |
|---|---|---|---|---|
| Fix the failing build | CI run failed | The build and tests pass; diff is small | One branch | Supervised: draft PR |
| Dependency upgrade | Renovate/Dependabot PR is red | Build, tests, canary | Many services | Supervised; auto-merge only patch versions with green CI |
Migration (JUnit 4 β 5, javax β jakarta, Java 17 β 21) | Planned campaign | Build, tests, plus a codemod for the mechanical part | Whole codebase, in batches | Supervised, one PR per service |
| Issue to PR | Issue labelled or @mention | Tests written with the issue's acceptance criteria | One feature | Supervised; human review as usual |
| Docs and config sync | API or schema changed | Lint, link check, OpenAPI diff | Docs | Can be autonomous for docs-only changes |
Every row ends in the same place: a proposal (a branch, a draft PR) that deterministic checks and a human approve. That is level 3 of the autonomy spectrum. Level 4 β applying changes without per-change approval β is reserved for changes that are both low-risk and fully verifiable, and rarely needs a model at all.
Anatomy of a fix-the-build agent
sequenceDiagram
autonumber
participant CI as CI run
participant Orch as Workflow (deterministic)
participant Agent as Agent loop (sandbox)
participant Model as Model
participant Repo as Git host
CI-->>Orch: run failed (event)
Orch->>Orch: dedupe by commit SHA, check branch is not ai-fix/*
Orch->>Agent: failed log + checkout (no write token)
loop at most N turns, M dollars
Agent->>Model: context + tool results
Model-->>Agent: tool call (read / edit / run build)
Agent->>Agent: execute allow-listed tool, log it
end
Agent-->>Orch: working-tree diff + transcript
Orch->>Orch: verify: paths allowed, no test removed, build green
Orch->>Repo: separate job with write token: branch + draft PR
Repo-->>Orch: human reviews, CI runs, merge or close Read it as two programs. The agent is non-deterministic and untrusted: it may be wrong, confused or manipulated by text in the log. The orchestrator around it β triggers, dedupe, verification, the PR job β is ordinary deterministic code, reviewed like any other. All the safety lives in the orchestrator.
Running agents headless
Agents in CI run headless: no terminal, no human to answer "may I run this command?". Every decision a person would make interactively must be made in advance, as configuration.
claude -p "<task>" runs Claude Code non-interactively. Relevant flags from its CLI reference:
gh run view "$RUN_ID" --log-failed | tail -n 300 | \
claude --bare -p "The CI build failed; the log is on stdin. Fix the root cause with the smallest change. Never delete or weaken a test." \
--allowedTools "Read,Edit,Write,Bash(mvn -B *)" \
--permission-mode dontAsk \
--max-turns 25 --max-budget-usd 1.50 \
--output-format stream-json --verbose > transcript.jsonl
--bare skips auto-discovery of hooks, plugins, MCP servers and CLAUDE.md (the same behaviour on every machine, and nothing loaded from the repository you did not choose); --allowedTools uses permission-rule syntax (Bash(mvn -B *) allows only commands starting with mvn -B); dontAsk denies everything else; --max-turns and --max-budget-usd bound the loop; stream-json produces a transcript for the audit log, whose last line reports the cost.
anthropics/claude-code-action@v1 runs the same engine inside GitHub Actions. Without a prompt it answers @claude mentions in PRs and issues (only from users with write access, by default); with a prompt it runs in automation mode on any event. CLI flags go in claude_args. The interactive setup from the documentation:
name: Claude Code
on:
issue_comment:
types: [created]
pull_request_review_comment:
types: [created]
jobs:
claude:
if: contains(github.event.comment.body, '@claude')
runs-on: ubuntu-latest
permissions:
contents: write
pull-requests: write
issues: write
id-token: write
actions: read
steps:
- uses: actions/checkout@v6
with:
fetch-depth: 1
- uses: anthropics/claude-code-action@v1
with:
anthropic_api_key: ${{ secrets.ANTHROPIC_API_KEY }}
Note the permissions: this job can write code, PRs and issues β appropriate for an on-demand assistant that humans invoke, which pushes commits a human then reviews. For an unattended agent reacting to failures, prefer a job without write access that produces a patch, and a separate job that opens the PR (next section). Check the action's security documentation and current inputs before use.
An agent is a loop you can write yourself over any API with tool calling β including OpenAI-compatible endpoints via LiteLLM or a local model with Ollama. The core, without error handling:
for step in range(MAX_STEPS):
reply = client.chat.completions.create(model=MODEL, messages=messages, tools=TOOLS)
msg = reply.choices[0].message
messages.append(msg.model_dump(exclude_none=True))
if not msg.tool_calls:
break
for call in msg.tool_calls:
result = run_allow_listed(call.function.name, json.loads(call.function.arguments))
audit(step, call, result)
messages.append({"role": "tool", "tool_call_id": call.id, "content": result})
Writing it yourself makes the tool surface explicit: here the agent has read_file, write_file (only under src/), run_build and finish β and no shell at all. Hands-on 13 has the complete version with budgets and an audit log.
Sandboxing
The agent executes the build, and the build executes the repository's code: tests, Maven plugins, scripts. Assume that code β or text the agent reads β may be hostile, and contain it in layers:
| Layer | Control | Protects against |
|---|---|---|
| Ephemeral environment | Fresh hosted runner or container per run, destroyed after | Persistence between runs; poisoned caches (be careful with shared build caches) |
| Tool allow-list | Read/edit files, run the build; no general shell, no curl, no git push | The agent doing anything you did not foresee |
| Filesystem scope | Writes only under services/*/src/; .github/, .git/ unreachable | Editing CI to remove checks; tampering with history |
| Environment scrubbing | Build and tests run without *KEY*, *TOKEN*, *SECRET* variables | A malicious test reading the environment |
| Network egress | Only the package registry and the model endpoint (proxy or firewall on self-hosted runners) | Exfiltration to arbitrary hosts |
| Resource limits | Job timeout, CPU/memory limits | Runaway loops and builds |
No single layer is perfect. A tool allow-list of Bash(mvn *) still runs whatever the project's Maven plugins do; environment scrubbing does not protect the model API key held by the agent process itself. That is why the key has a spend limit, the job has no write token, and the output is verified outside the agent.
Permissions: the token is the blast radius
Whatever the model decides, it can never do more than its credentials allow. Design the credentials first:
- Least-privilege
GITHUB_TOKEN: declarepermissions:in every workflow; the job that runs the agent getscontents: read(andactions: readto fetch logs). - Split jobs by privilege: the agent job produces a patch artifact; a small job with
contents: writeandpull-requests: writeapplies it and opens a draft PR. The job with write access runs no model and no untrusted code. - No secrets in the agent's context: never paste secrets into prompts or tool results; keep deploy keys, cloud credentials and registry tokens out of the job entirely.
- Read-only by default: an agent that only diagnoses (explains the failure as a comment) needs no write access at all β start there.
- Branch protection and rulesets:
mainrequires PRs, CI and a human approval; bots cannot bypass; changes to.github/workflows/require a code owner. - Human approval is structural, not a prompt instruction: the agent cannot merge because its token cannot.
In the BoxOffice code: two jobs, two privileges (lab 13)
Hands-on 13 β The fix agent splits the workflow exactly this way. The fix job runs the model and the untrusted build with contents: read and actions: read (to fetch the failed log), and it runs only for failed CI runs of this repository's own branches, never for its own ai-fix/ branches. The open-pr job has contents: write and pull-requests: write, runs no model and no build, and only applies the patch as a draft PR β after an independent step re-ran mvn verify and rejected deleted @Tests and files outside services/<name>/src/.
jobs:
fix:
# Only failed runs, only branches of this repository, never our own fix branches.
if: >-
github.event.workflow_run.conclusion == 'failure' &&
github.event.workflow_run.head_repository.full_name == github.repository &&
!startsWith(github.event.workflow_run.head_branch, 'ai-fix/') &&
!startsWith(github.event.workflow_run.head_branch, 'ai-tests/')
runs-on: ubuntu-latest
timeout-minutes: ${{ (vars.LLM_PROVIDER || 'ollama') == 'ollama' && 45 || 30 }}
permissions:
contents: read
actions: read # read the logs of the failed run
outputs:
fixed: ${{ steps.verify.outputs.fixed }}
open-pr: # write access, but no model and no untrusted build here
needs: fix
if: needs.fix.outputs.fixed == 'true'
runs-on: ubuntu-latest
timeout-minutes: 5
permissions:
contents: write
pull-requests: write
Budgets: turns, tokens, dollars, minutes
Each agent turn re-sends the whole context β instructions, tool definitions, the log, every file read, every tool result so far. Context grows with every step, so total input tokens grow roughly quadratically with the number of steps (AI systems β the numbers). Prompt caching reads the repeated prefix at a fraction of the price, which changes the slope dramatically. Illustrative assumptions: a 20,000-token starting context (instructions, tool definitions, log), 2,500 tokens added per step (tool result plus the model's output), 400 output tokens per step, list prices of a large model at the time of writing (US$ 4 input, US$ 20 output, US$ 0.20 cached read per million tokens):
Without caching, 40 turns cost more than eight times what 10 turns cost (about US$ 11 vs US$ 1.3); with caching, the curve is nearly linear and about eight times cheaper at 40 turns. Either way, the budget must be enforced by the harness, not requested in the prompt:
| Budget | Typical value for fix-the-build | Enforced by |
|---|---|---|
| Turns | 20β30 | --max-turns, or the loop bound |
| Dollars | US$ 1β2 per run | --max-budget-usd, or a token counter in the loop; workspace spend limit at the provider |
| Wall time | 20β30 min | timeout-minutes on the job |
| Runs per commit | 1 | Dedupe on the commit SHA |
| Runs per day per repository | e.g. 20 | Quota in the LLM gateway |
The ratio that matters is dollars per merged fix, not per run: an agent that costs US$ 0.80 per run but produces a mergeable fix one time in four costs US$ 3.20 per useful fix β compare that with the engineer-time it saves.
In the BoxOffice code: the budgets and the write area of the fix agent (lab 13)
The agent of hands-on 13 stops at 20 steps, 400,000 tokens or US$ 1.50, whichever comes first; it may read files of the repository but write only under src/, never .git/ or .github/, and every tool call goes to an audit log. A run with the local qwen2.5-coder-16k shows why the budget is not optional: the model never opened CatalogService.java, rewrote the test six times, and stopped after its 20 steps, 8Β½ minutes and 165,075 input tokens with "fixed": false. Nothing dangerous happened β every write stayed under services/catalog/src/, and the independent mvn verify failed, so no PR was opened. The guards make a bad agent harmless, not good.
ROOT = Path.cwd().resolve()
MAX_STEPS = int(os.environ.get("AGENT_MAX_STEPS", "20"))
MAX_TOKENS = int(os.environ.get("AGENT_MAX_TOKENS", "400000")) # input + output, whole run
MAX_USD = float(os.environ.get("AGENT_MAX_USD", "1.50"))
AUDIT = Path(os.environ.get("AGENT_AUDIT", "agent-audit.jsonl")) # keep it outside the repo
def safe(path: str, write: bool = False) -> Path:
p = (ROOT / path).resolve()
if not p.is_relative_to(ROOT) or {".git", ".github"} & set(p.relative_to(ROOT).parts):
raise PermissionError(f"{path}: outside the allowed area")
if write and "src" not in p.relative_to(ROOT).parts:
raise PermissionError(f"{path}: only files under services/<name>/src/ may be written")
return p
if done:
break
if usage.input_tokens + usage.output_tokens > MAX_TOKENS or usage.usd > MAX_USD:
result = {"fixed": False, "explanation": f"stopped by budget after {step} steps"}
break
Idempotency, retries and loops
Agents are triggered by events, and events repeat: a CI run is re-run, two pushes arrive in a minute, the agent's own branch fails CI. The messaging patterns of the course apply directly (Messaging β delivery semantics):
- Idempotency key: the failing commit SHA. One proposal per SHA (branch
ai-fix/<sha8>,concurrencygroup on the SHA). Re-runs find the branch and stop. - No self-triggering: never run the agent on its own branches (
ai-fix/*), and remember that pushes with theGITHUB_TOKENdo not trigger new workflow runs on GitHub β a built-in loop breaker. - Retries are for transport, not for the task: SDKs retry
429/5xxwith backoff. Re-running a failed agent is a new attempt with a new cost β make it a human decision, or at most one automatic retry with a different strategy. - Fail open for advisory agents: if the agent fails, the original red build is still there; nothing is worse than before.
Audit: who changed what, and why
An agent's change must be explainable months later. Keep:
| Record | Where |
|---|---|
| The full transcript (prompts, tool calls, results) | CI artifact with a retention period |
| Model id, prompt version, harness version | In the transcript and in the PR body |
| Cost and turns | Job summary; aggregated in the LLM gateway |
| That the commit is AI-generated | Commit trailer (AI-assisted: ai-fix (claude-opus-5-5)), PR label ai-generated |
| Who approved it | The PR review β the human of record |
This is the same discipline as for any production change, and it feeds the supply-chain provenance of the software: you can answer "which code in inventory was written by an agent, approved by whom?".
Dependency and migration agents
Dependency upgrades are the most common agentic workflow in practice. Bots such as Renovate or Dependabot open the upgrade PR deterministically; the agent's job starts when that PR is red: read the changelog and the failure, adapt the code, keep the change minimal. Group related updates (all Spring artifacts together), let patch-level updates with green CI auto-merge without AI, and send major versions to an agent that proposes and a human that approves.
Migrations β JUnit 4 β 5, javax.* β jakarta.*, Java 17 β 21 β mix a large mechanical part with a long tail of special cases. Use deterministic codemods for the mechanical part (OpenRewrite recipes for Java are the standard tool) and the agent for the leftovers the recipe cannot handle. Split the campaign into one PR per service, small enough to review, behind the normal CI and canary. Large-scale changes at Google followed the same principle long before LLMs: automate the change, shard it into independently reviewable pieces, and let the owners review each piece1.
BoxOffice: the Spring Boot upgrade, again
Renovate opens one grouped PR per service. Six are green and merge after review. For the eight red ones, the fix agent runs once per failing commit, with mvn as its only shell command, a US$ 1.50 cap and 25 turns. It proposes six fixes as draft PRs (renamed properties, a changed default in a test slice); for the other two it stops and explains (a removed API that needs a design decision). Two engineers review eight PRs in a morning instead of fixing fourteen services in two days β and the two hard cases get their full attention.
In the interview
For "design an agent that fixes broken builds", draw the orchestrator around the agent, not just the loop: trigger and dedupe, sandbox and tool allow-list, budgets enforced by the harness, verification outside the agent, a separate privileged step that opens a draft PR, and the audit trail. Quantify cost per run (quadratic in turns, mitigated by caching) and per merged fix. Say which failures it should not touch (flaky tests, infrastructure, security findings) and how the system detects them.
Best practices
- Use the failing commit SHA as the idempotency key β one proposal per SHA: lab 13's
ai-fix.yamluses aconcurrencygroup on the SHA and the branchai-fix/<sha8>. - Keep the transcript and the audit log as CI artifacts β the run can be explained later; lab 13 uploads
agent-transcript.jsonlandagent-audit.jsonl. - Scrub secrets from the build the agent runs β lab 13 runs
mvn verifywithout the*KEY*,*TOKEN*and*SECRET*variables. - Track dollars per merged fix, not per run β one useful fix in four runs at US$ 0.80 is US$ 3.20 per fix; compare it with the engineer time it saves.
- Open drafts, never ready-for-review PRs β a draft cannot be merged by accident and says that a person must take ownership; lab 13 opens
gh pr create --draft.
Common pitfalls
- One job with the agent and a write token: every prompt injection becomes a push.
- Budgets in the prompt ("please stop after 10 steps") instead of in the harness.
- Trusting the agent's "fixed it": verify independently; check that no test was removed or weakened.
- Agents triggering agents: a fix branch fails CI, which triggers the fix agent, whichβ¦
- Running agents on fork code with secrets (
pull_request_target,workflow_run): see Security & governance.
How it connects
flowchart LR
TRIG["red build event<br/>trigger + dedupe"] --> HARN["harness = orchestrator"]
HARN --> SB["sandbox, tool allow-list,<br/>no write token"]
HARN --> BUD["budgets: turns, tokens,<br/>dollars, minutes"]
HARN --> LOOP["agent loop<br/>β AI systems agents"]
LOOP --> VER["verification<br/>outside the agent"]
VER --> PRIV["separate privileged step<br/>opens a draft PR"]
PRIV --> HUM["human review"]
HARN --> IDEM["idempotency, retries, loops<br/>β Messaging semantics"]
HARN --> AUD["audit trail:<br/>who changed what, and why"] Palette components: orchestrator (the harness that enforces budgets and permissions) Β· toolreg (the allow-list of tools the agent may call) Β· llmgw (quotas and cost per run) Β· safety (no secrets or write tokens in the agent's reach) Β· logs (the audit trail) Β· metrics (merged fixes per proposal, cost per merged fix). Scenarios: ai-assistant with the spike profile (hundreds of agents at once compete with user traffic at the gateway). Continues in: AI systems β agents, Messaging β semantics (at-least-once runs, idempotent effects), Security & governance, Cloud-native β delivery, Hands-on 13 β fix agent.
-
WINTERS, T.; MANSHRECK, T.; WRIGHT, H. Software Engineering at Google, ch. 22 "Large-Scale Changes". O'Reilly, 2020.Β β©