This is the second article in a series on building an autonomous coding agent with open-weight models. The first article made the case and laid out the architecture.
Introduction
In this article, let us dive into the implementation details of the agent pipeline. We will look at what components are needed, how they work together, and what gaps they leave to be tackled later. A few components are left abstract on purpose, to avoid coupling the article to one particular cloud.
What We Are Building
Let’s start by looking at the high-level pieces we will be building and their responsibilities.
| Piece | File | Responsibility |
|---|---|---|
| Pipeline | Jenkinsfile |
Provisions the GPU node, starts the model server, invokes the orchestrator |
| Pod spec | jenkins-agent-pod.yaml |
Defines the three containers that share the node |
| Model server script | warm-model-server.sh |
Starts vLLM and waits until it answers |
| Orchestrator | src/main.ts + src/pipeline/ |
Clones, branches, runs the agent, pushes, opens the PR |
| Agent session | src/agent/pi-session.ts |
Configures the harness and sends it the task |
Repository layout used in this article. Note that this is the repository holding the agent tooling. It is the one Jenkins checks out and runs the pipeline from. The repository the agent actually works on is a different one, passed in as a parameter and cloned by the orchestrator later.
.
├── Jenkinsfile
├── config.env
└── scripts
├── platform/jenkins
│ ├── warm-model-server.sh
│ └── wait-for-model-server.sh
└── runtime
├── harness
│ ├── jenkins-agent-pod.yaml
│ ├── setup.sh
│ └── run-harness-on-repo.sh
└── agent
└── src
├── main.ts
├── agent/{pi-session,model-config}.ts
└── pipeline/stages/*.ts
A couple of things differ slightly from the diagrams in the previous article. There, the inference server and the coding agent were drawn as two pods. Here they are containers in a single pod, which is what lets them talk over localhost. And instead of pulling the model from cloud storage, vLLM pulls it straight from Hugging Face by model ID. Any registry or storage that vLLM can read from would work just as well.
We have chosen pi.dev as the agent harness for this implementation. It can easily be replaced with another harness like OpenCode or even Claude Code. What is important is that the harness lets us configure the LLM it interacts with, and all of these harnesses support that. Another reason for choosing pi.dev is its easy-to-use TypeScript SDK. Other harnesses provide an SDK too, but pi.dev felt the easiest to integrate with.
Configuring the Jenkinsfile
The pod: three containers on one GPU node
This is the infrastructure core. I briefly mentioned in the previous article that we are choosing Jenkins as the orchestration platform, but there is no real necessity for it. The orchestration can be done outside of Jenkins too; Jenkins just makes it easy for us. We are also assuming here that Jenkins is running on Kubernetes.
The agent block under pipeline in the Jenkinsfile configures where the pod should be placed. We specify the pod spec in a separate YAML file.
pipeline {
agent {
kubernetes {
yamlFile 'scripts/runtime/harness/jenkins-agent-pod.yaml'
defaultContainer 'jnlp'
}
}
// ...
}
The pod spec, trimmed to the parts that matter:
apiVersion: v1
kind: Pod
spec:
containers:
- name: jnlp # Jenkins connection
image: jenkins/inbound-agent:latest
- name: model-server # GPU-bound: vLLM
image: vllm/vllm-openai:latest
command: ["sh", "-c"]
args: ["sleep infinity"] # started later by the pipeline
env:
- name: HF_HOME # point the model cache at the mounted volume
value: /root/.cache/models
resources:
limits:
nvidia.com/gpu: "1"
ports:
- containerPort: 8000
volumeMounts:
- name: model-cache
mountPath: /root/.cache/models
- name: coding-agent # CPU-bound: orchestrator + harness
image: node:22-slim
command: ["sh", "-c", "cat"] # keeps the container alive until the pipeline needs it
tty: true
resources:
requests: { cpu: 2000m, memory: 4Gi }
limits: { cpu: 8000m, memory: 16Gi }
nodeSelector:
<gpu-node-group-label>: <gpu-node-group-name>
tolerations:
- key: <gpu-node-group-label>
operator: Equal
value: <gpu-node-group-name>
effect: NoSchedule
volumes:
- name: model-cache
persistentVolumeClaim:
claimName: <model-cache-pvc>
A few things to call out in the spec above:
- The
nodeSelectoris abstract. It should be replaced with the correct value to select the GPU node group needed to run the LLM. model-server, the container that hosts vLLM and runs the large language model, hassleep infinityas its args. This avoids loading the LLM at startup and lets the pipeline control the loading at a later step.model-serverhasnvidia.com/gpu: "1"under resources/limits, meaning the entire GPU is provided to vLLM to load the LLM.model-serverhas a volume mount to cache the model data, andHF_HOMEpoints to it. WithoutHF_HOME, vLLM would download the weights to its default cache location and the volume would never be used. The corresponding PVC can be backed by an EFS volume to cache the model permanently.coding-agentrunscatwith a TTY for the same reasonmodel-serverrunssleep infinity. It is a placeholder process that keeps the container alive, so the pipeline can run its scripts inside it later.- All the containers live in a single pod, so they can reach each other through
localhost.
Parameters, environment and options
Let us continue looking at what goes into the Jenkinsfile, in parts. Next up are the parameters and environment variables. We only need three parameters: the repo that needs to be checked out, the goal/prompt that needs to be executed, and the branch to be created. We could have the agent create the branch, but for more deterministic behaviour, and a better guardrail so the agent never modifies the default branch directly, it is better to keep that in our control.
The environment variables are just populated from the input parameters. We also add a couple of pipeline options. A GPU node is the most expensive thing in this setup, so we put a hard timeout on the build, in case the agent gets stuck in a loop, and we do not allow two builds to run side by side.
parameters {
string(name: 'GIT_REPO', defaultValue: '', description: 'Repository to work on')
text( name: 'GOAL', defaultValue: '', description: 'The task for the coding agent')
string(name: 'SOURCE_BRANCH', defaultValue: '', description: 'Branch the agent pushes to')
}
environment {
GIT_REPO = "${params.GIT_REPO}"
GOAL = "${params.GOAL}"
SOURCE_BRANCH = "${params.SOURCE_BRANCH}"
}
options {
timeout(time: 120, unit: 'MINUTES')
disableConcurrentBuilds()
}
Stages
With the basic blocks of the pipeline in place, let’s first look at what stages the pipeline will contain.
flowchart LR
A["Checkout"] --> B["Validate<br/>Parameters"]
B --> C["Warm Model<br/>Server"]
C --> D["Wait for<br/>Model Server"]
D --> E["Setup"]
E --> F["Run Agent"]
F --> G["Post:<br/>archive logs"]
classDef gpu fill:#fde68a,stroke:#b45309,color:#000
classDef cpu fill:#bfdbfe,stroke:#1d4ed8,color:#000
class C,D gpu
class E,F cpu
The stages highlighted in yellow are the ones responsible for bringing up the model in vLLM (remember, we skipped loading the model earlier by changing the container command to sleep infinity). A little later, we will see what goes into the Setup and Run Agent stages.
stages {
stage('Checkout') {
steps {
container('jnlp') {
checkout scm
}
}
}
stage('Validate Parameters') {
steps {
script {
if (!params.GOAL?.trim()) { error('GOAL must not be empty.') }
if (!params.SOURCE_BRANCH?.trim()) { error('SOURCE_BRANCH must not be empty.') }
}
}
}
stage('Warm Model Server') {
steps {
container('model-server') {
sh '''
apt-get update && apt-get install -y curl
bash scripts/platform/jenkins/warm-model-server.sh
'''
}
}
}
stage('Wait for Model Server') {
steps {
container('model-server') {
sh 'bash scripts/platform/jenkins/wait-for-model-server.sh'
}
}
}
stage('Setup') {
steps {
container('coding-agent') {
sh 'bash scripts/runtime/harness/setup.sh'
}
}
}
stage('Run Agent') {
steps {
container('coding-agent') {
withCredentials([
usernamePassword(credentialsId: 'GIT_BOT_CREDENTIALS',
usernameVariable: 'GIT_USER',
passwordVariable: 'GIT_TOKEN')
]) {
sh 'bash scripts/runtime/harness/run-harness-on-repo.sh "${GIT_REPO}"'
}
}
}
}
}
post {
always {
archiveArtifacts artifacts: 'logs/**/*', allowEmptyArchive: true
}
}
A few notes on the snippet above. The Checkout stage checks out the tooling repository, which is where all these scripts live. The model server container does not ship with curl, and the readiness check needs it, so we install it before warming. The GIT_BOT_CREDENTIALS credential needs to be created in Jenkins beforehand, as a username and token (or password) of an account that can push branches and open pull requests. And the post block archives the logs written by the orchestrator, which we will look at later. By default these end up under logs/ in the workspace.
Pay attention to the container block under each stage. container defines where the script should run. As expected, the stages related to bringing up the LLM go into the model-server container, whereas the actual harness execution goes into coding-agent.
Starting the Model Server
So far, the stages only call scripts, and those scripts abstract away what is actually happening. Let us look into each of those scripts, starting with the one that warms the model server.
mkdir -p /metrics
nohup vllm serve "${MODEL_SERVER_MODEL_ID}" \
--host 127.0.0.1 \
--port 8000 \
--served-model-name "${MODEL_NAME}" \
--max-model-len "${MODEL_SERVER_MAX_MODEL_LEN}" \
--gpu-memory-utilization "${MODEL_SERVER_GPU_MEMORY_UTILIZATION}" \
--enable-auto-tool-choice \
--tool-call-parser "${MODEL_SERVER_TOOL_CALL_PARSER}" \
--reasoning-parser "${MODEL_SERVER_REASONING_PARSER}" \
>> /metrics/model-server.log 2>&1 &
Most of these parameters come from config.env, which will be introduced slightly later. The rest of the command is fairly straightforward. We are bringing up a vLLM server on port 8000, serving the model specified by MODEL_SERVER_MODEL_ID under the name MODEL_NAME. It binds to 127.0.0.1, which is enough since the harness runs in the same pod and shares localhost. It also keeps the server unreachable from the rest of the cluster. The server log goes to /metrics/model-server.log, which is why we create that directory first.
The readiness script is a simple poll against the /models endpoint. Keep in mind that on a cold start vLLM first has to download the weights and then load them onto the GPU, which can take several minutes for a large model. So the loop is generous, at up to 7.5 minutes:
READY_URL="${MODEL_SERVER_BASE_URL}/models"
for i in $(seq 1 90); do
if curl -fsS "${READY_URL}" > /dev/null 2>&1; then
echo "Model server is ready"
exit 0
fi
sleep 5
done
echo "ERROR: model server did not become ready" >&2
exit 1
Shared settings in config.env
Now for config.env. All the shared settings live in this one file, which the scripts source:
# config.env
MODEL_NAME=${MODEL_NAME:-<model-name>}
MODEL_SERVER_MODEL_ID=${MODEL_SERVER_MODEL_ID:-<huggingface-model-id>}
MODEL_SERVER_BASE_URL=${MODEL_SERVER_BASE_URL:-http://localhost:8000/v1}
MODEL_SERVER_MAX_MODEL_LEN=${MODEL_SERVER_MAX_MODEL_LEN:-65536}
MODEL_SERVER_GPU_MEMORY_UTILIZATION=${MODEL_SERVER_GPU_MEMORY_UTILIZATION:-0.95}
# Tied to the model family: change these together with MODEL_NAME
MODEL_SERVER_TOOL_CALL_PARSER=${MODEL_SERVER_TOOL_CALL_PARSER:-qwen3_coder}
MODEL_SERVER_REASONING_PARSER=${MODEL_SERVER_REASONING_PARSER:-qwen3}
GIT_AUTHOR_NAME="Coding Agent"
GIT_AUTHOR_EMAIL=[email protected]
Setting Up the Coding Container
The Setup stage runs setup.sh inside the coding-agent container. The container starts from a plain node:22-slim image, so this script installs what the orchestrator needs and builds it. Note that it does not clone the target repository. That is the orchestrator’s job, and we will get to it a little later.
# scripts/runtime/harness/setup.sh
#!/usr/bin/env bash
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
source "${SCRIPT_DIR}/../../../config.env"
echo "=== Installing system dependencies ==="
apt-get update -qq
apt-get install -y --no-install-recommends git curl jq ca-certificates
echo "=== Enabling pnpm via corepack ==="
corepack enable pnpm
echo "=== Installing and building the orchestrator ==="
AGENT_RUNTIME_DIR="${SCRIPT_DIR}/../agent"
pnpm --dir "${AGENT_RUNTIME_DIR}" install --frozen-lockfile
pnpm --dir "${AGENT_RUNTIME_DIR}" run build
We need git because the orchestrator shells out to it, and pnpm run build compiles the TypeScript into scripts/runtime/agent/dist/main.js, which is the file the wrapper script runs later.
Configuring the Agent
Next, let’s see what goes into configuring the agent/harness. All of it lives in one small class, PiSdkAgentSession, in scripts/runtime/agent/src/agent/pi-session.ts, plus a helper in model-config.ts that builds the model provider definition. The orchestrator creates this class in its runAgent stage and calls it once with the prompt. Every snippet in this section is a piece of that class, in the order it executes.
scripts/runtime/agent/src/agent/
├── model-config.ts # builds the provider definition (models.json)
└── pi-session.ts # PiSdkAgentSession: configures the harness and runs the task
Pointing the harness at the model server
The harness has no idea that vLLM exists. All it needs is a provider definition that says, “there is an OpenAI-compatible server at this URL, serving this model”. The orchestrator reads the model settings from config.env (shown above) and model-config.ts turns them into that definition:
// scripts/runtime/agent/src/agent/model-config.ts
export const createModelsConfig = (input: {
baseUrl: string;
modelName: string;
contextWindow: number;
maxTokens: number;
}): string =>
JSON.stringify({
providers: {
"model-server": {
baseUrl: input.baseUrl,
api: "openai-completions",
apiKey: "not-needed",
models: [
{
id: input.modelName,
name: input.modelName,
contextWindow: input.contextWindow,
maxTokens: input.maxTokens
}
],
stream: true
}
}
}, null, 2);
Here is how the config.env values map onto it:
createModelsConfig input |
Comes from | Notes |
|---|---|---|
baseUrl |
MODEL_SERVER_BASE_URL |
localhost works because all containers share the pod |
modelName |
MODEL_NAME |
Must equal --served-model-name on the vLLM command |
contextWindow |
MODEL_SERVER_MAX_MODEL_LEN |
Must equal --max-model-len, otherwise the harness can send more than the server accepts |
maxTokens |
Orchestrator default of 16,384 | Capped at the context window |
apiKey is a dummy value. The model server has no authentication, and it only listens on localhost inside the pod, so there is nothing to protect.
At the start of each run, PiSdkAgentSession writes this definition to a throwaway directory and loads it, along with an empty auth file, into the harness runtime:
// scripts/runtime/agent/src/agent/pi-session.ts
const runtimeDir = await fs.mkdtemp(path.join(os.tmpdir(), "pi-sdk-runtime-"));
const modelsPath = path.join(runtimeDir, "models.json");
const authPath = path.join(runtimeDir, "auth.json");
await fs.writeFile(modelsPath, createModelsConfig({
baseUrl: input.modelConfig.baseUrl,
modelName: input.modelConfig.modelName,
contextWindow: input.modelConfig.contextWindow,
maxTokens: input.modelConfig.maxTokens
}), "utf8");
await fs.writeFile(authPath, JSON.stringify({ providers: {} }), "utf8");
const modelRuntime = await ModelRuntime.create({ modelsPath, authPath });
The directory is deleted when the run finishes, so nothing about the model config leaks between runs. And swapping the model later is a config.env change, not a code change.
Creating the session and choosing tools
With the model runtime ready, the next step is the session itself. This is the part where we decide what the agent is allowed to do:
// scripts/runtime/agent/src/agent/pi-session.ts (continued)
const sessionManager = SessionManager.create(input.cwd, path.join(getAgentDir(), "sessions"));
const loader = new DefaultResourceLoader({ cwd: input.cwd, agentDir: getAgentDir() });
await loader.reload();
const { session } = await createAgentSession({
cwd: input.cwd, // the cloned repository
modelRuntime,
resourceLoader: loader,
sessionManager,
tools: ["read", "bash", "edit", "write", "grep", "find", "ls"]
});
cwdis the cloned repository. The orchestrator clones it before this runs, so the agent is rooted in the right repo. It commits locally on whatever branch the clone starts on. The orchestrator moves those commits onto the real branch afterwards (more on that below).toolsis an explicit allowlist. The agent can read, search, edit and write files and run shell commands, which is all it needs to change code and run the tests. Keep in mind thatbashis a powerful tool, so the pod itself remains the real boundary.sessionManagerkeeps the conversation history of the run, so a session can be inspected later. We create a fresh one each time.resourceLoaderloads any extra resources the harness finds for the working directory. We are not adding any in this article.
session is what the next section drives: we subscribe to its events and send it the prompt.
Running the Agent with a Task
From Jenkins to the orchestrator
The Run Agent stage calls a thin wrapper, run-harness-on-repo.sh, which in turn starts the orchestrator:
#!/usr/bin/env bash
set -euo pipefail
# Export everything in config.env so the Node process inherits it
set -a; source "$(dirname "$0")/../../../config.env"; set +a
node scripts/runtime/agent/dist/main.js "${1}"
The set -a is what makes this work. It marks every variable assigned while it is active for export, so the settings from config.env become environment variables of the Node process. Together with the variables Jenkins already provides (GOAL, SOURCE_BRANCH, GIT_USER, GIT_TOKEN), everything the orchestrator needs arrives through the environment, apart from the repository, which is passed as an argument. A small config loader turns all of it into one typed object and fails fast if something is missing:
// scripts/runtime/agent/src/config.ts (trimmed)
export const loadRunConfig = (repoUrl: string | undefined, env: NodeJS.ProcessEnv): RunConfig => {
const contextWindow = Number(env.MODEL_SERVER_MAX_MODEL_LEN ?? 65_536);
return {
gitRepoUrl: requireNonEmpty("GIT_REPO", repoUrl),
goal: requireNonEmpty("GOAL", env.GOAL),
sourceBranch: requireNonEmpty("SOURCE_BRANCH", env.SOURCE_BRANCH),
gitUser: requireNonEmpty("GIT_USER", env.GIT_USER),
gitToken: requireNonEmpty("GIT_TOKEN", env.GIT_TOKEN),
gitAuthorName: requireNonEmpty("GIT_AUTHOR_NAME", env.GIT_AUTHOR_NAME),
gitAuthorEmail: requireNonEmpty("GIT_AUTHOR_EMAIL", env.GIT_AUTHOR_EMAIL),
modelName: requireNonEmpty("MODEL_NAME", env.MODEL_NAME),
modelBaseUrl: env.MODEL_SERVER_BASE_URL ?? "http://localhost:8000/v1",
modelContextWindow: contextWindow,
modelMaxTokens: Math.min(16_384, contextWindow)
};
};
This is where the names we have been seeing in the stage snippets, like ctx.config.goal and ctx.config.sourceBranch, come from.
The orchestrator
The orchestrator is a list of stages that run one after the other, sharing a context object (the config, the repo directory, a logger). If a stage throws, the run stops and the process exits non-zero, which Jenkins sees as a failure.
sequenceDiagram
participant J as Jenkinsfile
participant O as Orchestrator
participant G as Git Host
participant H as Coding Harness
participant V as vLLM
J->>O: node dist/main.js <repo>
O->>G: clone repository
O->>G: check if SOURCE_BRANCH exists
O->>H: session.prompt(goal)
loop until the task is done
H->>V: chat completion + tool schemas
V-->>H: tool calls / answer
H->>H: read, edit, run tests, commit
end
H-->>O: exit code
O->>O: move commits onto the branch
O->>G: push branch
O->>G: open pull request
O-->>J: exit code
// scripts/runtime/agent/src/pipeline/index.ts
return await runStages(ctx, [
cloneRepo,
resolveSourceBranch,
runAgent,
pushAgentBranch,
createPullRequest
]);
Each stage lives in its own file under scripts/runtime/agent/src/pipeline/stages/. runAgent is the part we covered in the previous section, and the others are plain git and git-host operations.
Cloning the repository
// scripts/runtime/agent/src/pipeline/stages/clone-repo.ts
export const cloneRepo: Stage<void> = async (ctx) => {
const { config, repoDir } = ctx;
const git = new GitClient();
await git.cloneOrUpdateRepository({
repoDir,
remoteUrl: config.gitRepoUrl,
user: config.gitUser,
token: config.gitToken
});
await git.cleanWorkingTree(repoDir);
await git.configureIdentity(repoDir, config.gitAuthorName, config.gitAuthorEmail);
ctx.eventLogger.logText(`Repository ready at ${repoDir}`);
};
cloneOrUpdateRepositorydoes a shallow clone (git clone --depth 1). If the directory is already a clone, from an earlier run on the same workspace, it fetches and checks out the default branch instead.cleanWorkingTreerunsgit clean -fdxso the agent never starts with leftovers.configureIdentitysetsuser.nameanduser.emailfrom theGIT_AUTHOR_NAMEandGIT_AUTHOR_EMAILinconfig.env. This is what the agent’s commits will be attributed to.- The token comes from the Jenkins credentials binding, and is handed to git through an askpass helper so it never appears in a URL or the logs.
Resolving the branch name
The SOURCE_BRANCH parameter might already exist on the remote, for example from an earlier run. This stage only picks the name to use. It does not create the branch.
// scripts/runtime/agent/src/pipeline/stages/resolve-source-branch.ts
export const resolveSourceBranch: Stage<void> = async (ctx) => {
const requested = ctx.config.sourceBranch;
const git = new GitClient();
const { exists } = await git.fetchBranch({ repoDir: ctx.repoDir, branchName: requested, ...credentials });
// Reuse the name if it is free, otherwise suffix it with a UTC timestamp
ctx.sourceBranch = exists
? `${requested}-${formatUtcTimestamp(new Date())}`
: requested;
};
The result is stored on the context as ctx.sourceBranch, and every later stage uses that name.
The prompt
The prompt essentially is the main goal we are giving to the agent. This can be something direct, like, identify gaps in and improve the unit test coverage of this repo. The prompt provided as input should however be wrapped with our own. Consider this to be our version of system prompt. The system prompt instructs the tool to not create a branch separately and not to push or create a pull request.
All of this happens in the runAgent stage, which builds the prompt and hands it to the agent session we configured in the previous section:
// scripts/runtime/agent/src/pipeline/stages/run-agent.ts
export const runAgent: Stage<void> = async (ctx) => {
const prompt = [
`Your Goal: ${ctx.config.goal}.`,
"Make the necessary changes, run lint and the unit tests you changed, and commit the changes.",
`Do not push to remote. The pipeline will push branch '${ctx.sourceBranch}' automatically.`,
"Do NOT create a pull request — it will be created automatically after the run."
].join(" ");
const result = await new PiSdkAgentSession().run({
cwd: ctx.repoDir,
prompt,
eventLogger: ctx.eventLogger,
modelConfig: {
baseUrl: ctx.config.modelBaseUrl,
modelName: ctx.config.modelName,
contextWindow: ctx.config.modelContextWindow,
maxTokens: ctx.config.modelMaxTokens
}
});
// A failed agent run should fail the build, and nothing should be pushed
if (result.exitCode !== 0) {
throw new Error(`Agent run failed with exit code ${result.exitCode}`);
}
};
The last few lines matter. If the agent run fails, the stage throws, the remaining stages never run, and Jenkins marks the build as failed. Without the check, the pipeline would carry on and push whatever half-finished commits the agent left behind.
Sending the task and reading the result
Inside the session class, the prompt is sent to the harness. This is the part that actually runs the agent, and it also decides whether the run counts as a success:
// scripts/runtime/agent/src/agent/pi-session.ts (continued)
let terminalFailureReason: string | undefined;
session.subscribe((event) => {
if (event.type === "tool_execution_start") {
input.eventLogger.logText(`Tool started: ${event.toolName}`);
}
if (event.type === "message_end" && event.message.role === "assistant") {
const reason = event.message.stopReason;
if (reason === "error" || reason === "aborted" || reason === "length") {
terminalFailureReason = reason;
}
}
});
try {
await session.prompt(input.prompt); // blocks until the agent is done
return { exitCode: terminalFailureReason ? 1 : 0 };
} catch (error) {
input.eventLogger.logText(`Prompt failed: ${String(error)}`);
return { exitCode: 1 };
} finally {
session.dispose();
}
There are three things happening here. The subscription streams every tool the agent uses into the log, which is what you will be reading when a run goes wrong. A stop reason of error, aborted or length is treated as a failure, and length usually means the model ran out of context window. And the outcome is mapped to an exit code, which runAgent above turns into a pass or fail for the pipeline. The logs written through eventLogger are what end up under logs/ and get archived by the post block in the Jenkinsfile.
Pushing the branch
The agent committed locally, on the branch the clone started on. Only now does the orchestrator create the branch we want, carrying the agent’s commits along, and push it:
// scripts/runtime/agent/src/pipeline/stages/push-agent-branch.ts
export const pushAgentBranch: Stage<void> = async (ctx) => {
const { repoDir } = ctx;
const branch = ctx.sourceBranch;
// -B creates the branch at the current commit, or resets it if it exists
const result = await runCommand("git", ["checkout", "-B", branch], { cwd: repoDir });
if (result.exitCode !== 0) {
throw new Error(`git checkout failed: ${result.stderr}`);
}
await new GitClient().pushBranch({ repoDir, branchName: branch, ...credentials });
};
This is the guardrail from earlier in practice. The prompt tells the agent not to push, and the orchestrator is the one that decides what gets pushed, which is only ever the branch we named. Since the agent has a shell, this is a convention enforced by the prompt, not a hard guarantee. A stricter setup would keep the git credentials out of the agent’s environment altogether.
Opening the pull request
// scripts/runtime/agent/src/pipeline/stages/create-pr.ts
export const createPullRequest: Stage<void> = async (ctx) => {
const git = new GitClient();
await git.fetchOrigin({ repoDir: ctx.repoDir, ...credentials });
const defaultBranch = await git.resolveDefaultBranch({ repoDir: ctx.repoDir, ...credentials });
// Commit messages on the branch that are not on the default branch
const log = await runCommand(
"git",
["log", `origin/${defaultBranch}..${ctx.sourceBranch}`, "--format=%B"],
{ cwd: ctx.repoDir }
);
const commitMessages = log.stdout.trim();
if (!commitMessages) {
ctx.eventLogger.logText("No new commits; skipping PR creation.");
return;
}
const [title, ...rest] = commitMessages.split("\n");
const pr = await gitHost.createPullRequest({
title,
description: rest.join("\n").trim(),
fromBranch: ctx.sourceBranch,
toBranch: defaultBranch
});
ctx.eventLogger.logText(`PR created: ${pr.url}`);
};
- If the agent made no commits, the stage skips the PR instead of failing.
- The PR title is the first line of the commit messages and the description is the rest, so the agent’s commit message becomes the PR text.
gitHost.createPullRequestis a thin client over the git host’s REST API. It depends on whichever host you use, so it is left out here.
Conclusion
This article covered the minimum needed to go from a Jenkins build parameter to a pull request: a pod with three containers, a pipeline that warms the model server, and a small program that configures the agent and hands it a task.
It is deliberately bare. It handles one repository per build, has no retries, and does not check whether an open pull request from an earlier run already exists, so re-running the same goal can produce duplicates. The timeout is the only protection against an agent that never finishes. All of these are things to tighten before pointing this at anything important.
The next article will build on this with shared agent skills, a pipeline that fixes failing pull requests, and ways to use idle CPU by processing several repositories in parallel.