Autonomous coding agent with open-weight models - Setup

Second article in the series: the Jenkinsfile, the agent configuration, and the code that runs a coding agent against a task on a GPU node.

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 nodeSelector is 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, has sleep infinity as its args. This avoids loading the LLM at startup and lets the pipeline control the loading at a later step.
  • model-server has nvidia.com/gpu: "1" under resources/limits, meaning the entire GPU is provided to vLLM to load the LLM.
  • model-server has a volume mount to cache the model data, and HF_HOME points to it. Without HF_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-agent runs cat with a TTY for the same reason model-server runs sleep 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=coding-agent@example.com

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"]
});
  • cwd is 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).
  • tools is 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 that bash is a powerful tool, so the pod itself remains the real boundary.
  • sessionManager keeps the conversation history of the run, so a session can be inspected later. We create a fresh one each time.
  • resourceLoader loads 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}`);
};
  • cloneOrUpdateRepository does 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.
  • cleanWorkingTree runs git clean -fdx so the agent never starts with leftovers.
  • configureIdentity sets user.name and user.email from the GIT_AUTHOR_NAME and GIT_AUTHOR_EMAIL in config.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.createPullRequest is 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.