Qwen Code¶
Qwen Code is an AI coding assistant that runs in the terminal. It reads and edits files, runs commands, and answers questions about a codebase.
On SAV infrastructure it runs in a Singularity container on a login node and connects to a model you serve yourself on a GPU node. Set the server up first following Serving LLMs with vLLM.
Quickstart¶
cd ~/llm
sbatch submit_llm.sh # start the model server
bash agent.sh <workspace> # pick a session, then work
agent.sh asks which session you want, finds the running server job, reads
which node and port it landed on, opens an SSH tunnel, waits for the model to
finish loading, and launches the agent. The tunnel closes when you exit.
Qwen Code session:
1) Continue last session
2) Start new session
3) Select previous session
4) List sessions
5) Exit
Select [1]:
Release the GPU when you finish:
scancel JOB_ID
Setup¶
You need the server setup done first:
~/llm/config.env, an API key in ~/.llm_api_key, and a server you can start.
Then add two files:
chmod +x ~/llm/agent.sh
Check that QWEN_SIF_X86 in config.env points at the either global image
or personal image installed in your project directory.
Then create the two account files the container needs. The base image has no entry for your UID, and SLURM commands inside the container will not authenticate until it can resolve your user and group:
getent passwd root "$USER" > ~/llm/.container_passwd
getent group root "$(id -gn)" > ~/llm/.container_group
Regenerate them if your account details ever change. They are bound over
/etc/passwd and /etc/group in the container — see
Bind mounts.
Usage¶
agent.sh [WORKSPACE] [MODE] [-j JOBID] [-- ARGS...]
WORKSPACE directory to work in (default: current directory)
MODE --continue, --new, --resume or --list
With no MODE, an interactive menu is shown.
-j JOBID vLLM job to connect to (default: the only running one)
-- ARGS everything after -- is passed to qwen verbatim
~/llm/agent.sh # menu, current directory
~/llm/agent.sh --continue # straight back into the last session
~/llm/agent.sh ~/my-project --new # new session, in that workspace
~/llm/agent.sh --list # list sessions; no GPU needed
~/llm/agent.sh -j 123456 --resume # pick which server to use
~/llm/agent.sh -- --help # arguments passed through to qwen
Session modes¶
| Mode | Effect |
|---|---|
--continue |
Resume the most recent session for this workspace |
--new |
Start a completely new session |
--resume |
Open Qwen Code's own session selector |
--list |
List saved sessions and exit |
| (none) | Show the interactive menu; Enter selects Continue |
Session state lives in your workspace directory, which agent.sh binds into the
container, so sessions survive across runs and across different server jobs —
if a server job hits its walltime, start a new one and --continue picks up
where you left off.
--list does not need a GPU
Listing sessions touches no model, so agent.sh skips job selection, the
tunnel and the readiness wait entirely. The list appears immediately, even
with no vLLM job running at all.
The launcher¶
In order, agent.sh:
- Parses the command line — workspace, session mode, job ID, and anything
after
--to pass through verbatim. - Resolves the session mode, showing the menu if none was given. This happens before anything connects, so the menu appears at once rather than after the model has loaded.
- Picks the server job — unless the mode needs no server. With no job ID
it looks for exactly one running job named
vllm; if there are several it prints them and stops rather than guessing, because connecting to the wrong model is confusing and silent. - Reads the metadata for that job to learn the node and port, and verifies the job is still running before trusting it, so a stale file produces a clear error instead of a hanging tunnel.
- Allocates a local port the same way the server does, so several agent sessions can run side by side on one login node.
- Opens the tunnel in the background and registers a trap to close it.
- Waits for readiness by polling
/v1/modelsuntil the model has finished loading — up to five minutes — while checking the tunnel is still alive. - Launches Qwen Code in the chosen workspace, with the cluster bind mounts that give it access to SLURM.
Metadata is parsed, never sourced
The field() helper extracts values with sed, and the node name and port
are validated before use. A file written by a job into a runtime directory
should not be able to execute code as you.
~/llm/agent.sh
#!/bin/bash
# Open a tunnel to a running vLLM job and start Qwen Code against it.
#
# ./agent.sh # menu, current directory
# ./agent.sh --continue # resume the most recent session
# ./agent.sh ~/proj --new # new session in ~/proj
# ./agent.sh --list # list sessions; no server needed
# ./agent.sh -j 123456 --resume # choose the server job
# ./agent.sh -- --help # pass arguments to qwen
set -euo pipefail
source "${LLM_HOME:-$HOME/llm}/config.env"
: "${KEY:?No API key. Create ~/.llm_api_key -- see config.env.}"
usage() {
cat <<EOF
Usage: ${0##*/} [WORKSPACE] [MODE] [-j JOBID] [-- ARGS...]
WORKSPACE directory to work in (default: current directory)
MODE --continue, --new, --resume or --list
With no MODE, an interactive menu is shown.
-j JOBID vLLM job to connect to (default: the only running one)
-- ARGS everything after -- is passed to qwen verbatim
EOF
}
# ---- Command line ------------------------------------------------------
JOB_ID=""
WORKSPACE=""
MODE=""
PASSTHRU=()
while [[ $# -gt 0 ]]; do
case "$1" in
-j) JOB_ID="${2:?-j needs a job ID}"; shift 2 ;;
-h|--help) usage; exit 0 ;;
--) shift; PASSTHRU=("$@"); break ;;
--*) MODE="$1"; shift ;;
*) if [[ -n "$WORKSPACE" ]]; then
echo "Unexpected argument: $1" >&2; usage >&2; exit 1
fi
WORKSPACE="$1"; shift ;;
esac
done
WS="${WORKSPACE:-$PWD}"
if [[ ! -d "$WS" ]]; then
echo "No such directory: $WS" >&2
exit 1
fi
WORKSPACE="$(cd "$WS" && pwd)"
# -----------------------------------------------------------------------
# Select Qwen session mode
#
# --continue Resume the most recent session for this workspace.
# --new Start a completely new session.
# --resume Open Qwen's session selector.
# --list List available sessions.
#
# With no option, present an interactive menu.
#
# Resolved before anything connects, so the menu appears immediately
# rather than after the model has finished loading.
# -----------------------------------------------------------------------
NEEDS_SERVER=1
case "$MODE" in
--continue)
QWEN_ARGS=(--continue)
;;
--new)
QWEN_ARGS=()
;;
--resume)
QWEN_ARGS=(--resume)
;;
--list)
# Listing sessions needs no model, so skip the tunnel.
QWEN_ARGS=(sessions list)
NEEDS_SERVER=0
;;
"")
if [[ ! -t 0 ]]; then
echo "Not a terminal. Pass --continue, --new," >&2
echo "--resume or --list explicitly." >&2
exit 1
fi
echo
echo "Qwen session:"
echo
echo " 1) Continue last session"
echo " 2) Start new session"
echo " 3) Select previous session"
echo " 4) List sessions"
echo " 5) Exit"
echo
read -r -p "Select [1]: " CHOICE || CHOICE=1
# Pressing Enter selects the default.
CHOICE=${CHOICE:-1}
case "$CHOICE" in
1)
QWEN_ARGS=(--continue)
;;
2)
QWEN_ARGS=()
;;
3)
QWEN_ARGS=(--resume)
;;
4)
QWEN_ARGS=(sessions list)
NEEDS_SERVER=0
;;
5)
exit 0
;;
*)
echo "Invalid selection." >&2
exit 1
;;
esac
;;
*)
echo "Unknown option: $MODE" >&2
echo
usage >&2
exit 1
;;
esac
# ---- Image -------------------------------------------------------------
# The Qwen Code image is built for x86_64 only, which is what the login
# and CPU nodes are. Fail here rather than inside singularity.
if [[ "$(uname -m)" != "x86_64" ]]; then
echo "This node is $(uname -m); the Qwen Code image is x86_64 only." >&2
echo "Run agent.sh from a login node." >&2
exit 1
fi
QWEN_SIF="${QWEN_SIF_X86:?QWEN_SIF_X86 is not set}"
if [[ "$NEEDS_SERVER" == "1" ]]; then
# ---- Pick the job --------------------------------------------------
# $USER is not exported in every environment, and set -u would abort.
ME="${USER:-$(id -un)}"
if [[ -z "$JOB_ID" ]]; then
mapfile -t jobs < <(squeue -u "$ME" -n vllm -h -t RUNNING -o '%i')
case ${#jobs[@]} in
0) echo "No running vLLM job. Start one: sbatch submit_llm.sh" >&2
exit 1 ;;
1) JOB_ID="${jobs[0]}" ;;
*) echo "Several vLLM jobs are running. Pass -j JOBID:" >&2
squeue -u "$ME" -n vllm -t RUNNING -o '%.10i %.9T %.14R %.11L' >&2
exit 1 ;;
esac
fi
# ---- Read its metadata ---------------------------------------------
META="$LLM_RUNTIME/$JOB_ID.job"
if [[ ! -r "$META" ]]; then
echo "No metadata for job $JOB_ID. It may still be starting up." >&2
echo "Watch it with: tail -f $LLM_LOGS/vllm-$JOB_ID.log" >&2
exit 1
fi
# Parsed, never sourced: this file should not be able to run code.
field() { sed -n "s/^$1=//p" "$META" | head -n1; }
NODE=$(field NODE)
SERVER_PORT=$(field SERVER_PORT)
SERVED_MODEL=$(field SERVED_MODEL)
if ! squeue -h -j "$JOB_ID" -t RUNNING -o '%i' | grep -qx "$JOB_ID"; then
echo "Job $JOB_ID is no longer running; metadata is stale." >&2
exit 1
fi
if [[ ! "$NODE" =~ ^[A-Za-z0-9._-]+$ || ! "$SERVER_PORT" =~ ^[0-9]+$ ]]; then
echo "Malformed metadata in $META" >&2
exit 1
fi
# ---- Tunnel --------------------------------------------------------
LOCAL_PORT=$(python3 -c 'import socket; s=socket.socket(); s.bind(("127.0.0.1", 0)); print(s.getsockname()[1]); s.close()')
ssh -o ExitOnForwardFailure=yes -o BatchMode=yes -N \
-L "127.0.0.1:$LOCAL_PORT:127.0.0.1:$SERVER_PORT" "$NODE" &
SSH_PID=$!
cleanup() {
kill "$SSH_PID" 2>/dev/null || true
wait "$SSH_PID" 2>/dev/null || true
}
trap cleanup EXIT INT TERM
# ---- Wait for the model to finish loading --------------------------
BASE="http://127.0.0.1:$LOCAL_PORT"
ready=0
for _ in $(seq 150); do
if ! kill -0 "$SSH_PID" 2>/dev/null; then
echo "SSH tunnel to $NODE died." >&2
exit 1
fi
if curl -fsS -m 2 -H "Authorization: Bearer $KEY" \
"$BASE/v1/models" >/dev/null 2>&1; then
ready=1; break
fi
sleep 2
done
if [[ $ready -ne 1 ]]; then
echo "Server did not answer within 5 minutes." >&2
echo "Check the log: tail -f $LLM_LOGS/vllm-$JOB_ID.log" >&2
exit 1
fi
echo "Connected to '$SERVED_MODEL' on $NODE:$SERVER_PORT"
export OPENAI_BASE_URL="$BASE/v1"
export OPENAI_API_KEY="$KEY"
export OPENAI_MODEL="$SERVED_MODEL"
fi
# ---- Launch ------------------------------------------------------------
LAUNCH=()
if [[ ${#QWEN_ARGS[@]} -gt 0 ]]; then LAUNCH+=("${QWEN_ARGS[@]}"); fi
if [[ ${#PASSTHRU[@]} -gt 0 ]]; then LAUNCH+=("${PASSTHRU[@]}"); fi
# The last four binds and the PATH are what let the agent use SLURM from
# inside the container -- see "Bind mounts". Without them sbatch, srun
# and squeue fail to authenticate.
#
# No 'exec': the EXIT trap must still run to close the tunnel.
# ${LAUNCH[@]+...} guards against older bash treating an empty array as
# unset under set -u.
ml singularity
singularity exec \
--bind "$WORKSPACE:$WORKSPACE" \
--bind "$HOME:$HOME" \
--bind "$LLM_PROJECT:$LLM_PROJECT" \
--bind /run/munge:/run/munge \
--bind /var/spool/slurmd/conf-cache:/etc/slurm \
--bind "$LLM_HOME/.container_passwd:/etc/passwd" \
--bind "$LLM_HOME/.container_group:/etc/group" \
--pwd "$WORKSPACE" \
--env "PATH=/usr/local/bin:/opt/agent_env/bin:/opt/slurm/bin:/opt/slurm/sbin:/usr/bin:/bin" \
"$QWEN_SIF" qwen ${LAUNCH[@]+"${LAUNCH[@]}"}
Devana slurm bind
On devana slurm needs to be bind followingly -- bind /etc/slurm:/etc/slurm &
--env "PATH=/usr/local/bin:/opt/agent_env/bin:$HOME/.local/bin:/usr/bin:/bin"
Running agents responsibly¶
A coding agent executes commands. Two cluster-specific consequences:
It runs on the login node, but it has SLURM Because agent.sh binds the
munge socket and the SLURM configuration into the container, the agent can run
sbatch, srun, squeue and scancel itself. Tell it to: ask it to submit a
job rather than to run the build, and heavy work lands on compute nodes where
it belongs instead of on a shared, unqueued login node.
> compile this and run the test suite -- submit it with sbatch to the
> cpu partition under account number <project_ID>, then check the job's output
Anything the agent runs directly still executes on the login node, so for a long interactive session you can also put the whole thing in a job:
srun --account=<project_ID> --partition=cpu --time=1:00:00 --pty bash
~/llm/agent.sh
The agent can submit jobs against your allocation
It spends your account's compute budget without a second confirmation
step. Be specific about partitions and walltimes in your prompts, and
check squeue -u "$USER" if you are not sure what it started.
It edits files for real
Pass a workspace or run from a project directory, not $HOME, and keep
that project in version control so you can see and undo what it did. Be
deliberate about any mode that auto-approves actions — the model is working
from your prompt, but it is still a model.
Bind mounts¶
Singularity does not expose every host path. agent.sh binds seven, and the
last four are what let the agent submit and monitor its own SLURM jobs:
| Bind | Why |
|---|---|
$WORKSPACE |
The directory the agent is working in |
$HOME |
Agent configuration and saved session state |
$LLM_PROJECT |
Shared containers and the model cache |
/run/munge |
Munge authentication socket. Without it every SLURM command inside the container fails with a credential error |
/var/spool/slurmd/conf-cache → /etc/slurm |
Perun cluster configuration, so SLURM clients know which controller to talk to |
/etc/slurm → /etc/slurm |
Devana cluster configuration, so SLURM clients know which controller to talk to |
.container_passwd → /etc/passwd |
The base image has no entry for your UID; munge and SLURM need your account to resolve to a name |
.container_group → /etc/group |
The same for your primary group |
--env PATH then puts /opt/slurm/bin and /opt/slurm/sbin on the path so
sbatch, srun, squeue and scancel are actually found, alongside
/opt/agent_env/bin where Qwen Code itself is installed.
Create the passwd and group files first
These are two small files you generate once; the binds fail if they do not exist. See Setup.
To run the image by hand with the same access:
ml singularity
singularity exec \
--bind "$PWD:$PWD" \
--bind "$HOME:$HOME" \
--bind /project/p2075-26-2:/project/p2075-26-2 \
--bind /run/munge:/run/munge \
--bind /var/spool/slurmd/conf-cache:/etc/slurm \
--bind "$HOME/llm/.container_passwd:/etc/passwd" \
--bind "$HOME/llm/.container_group:/etc/group" \
--pwd "$PWD" \
--env "PATH=/usr/local/bin:/opt/agent_env/bin:/opt/slurm/bin:/opt/slurm/sbin:/usr/bin:/bin" \
qwen-code.sif qwen
Devana slurm bind
On devana slurm needs to be bind followingly -- bind /etc/slurm:/etc/slurm &
--env "PATH=/usr/local/bin:/opt/agent_env/bin:$HOME/.local/bin:/usr/bin:/bin"
Never bake API keys, SSH keys or tokens into an image — anyone who can read
the .sif can read them back out.
Building an agent image¶
The stock images cover Qwen Code. Build your own for a different version, extra tools, or a different agent entirely. The image holds the agent and its runtime only; model weights stay in the server's cache.
qwen-code.def
Bootstrap: docker
From: ubuntu:24.04
%labels
Description Qwen Code client
%environment
export LANG=C.UTF-8
export LC_ALL=C.UTF-8
%post
set -e
apt-get update
DEBIAN_FRONTEND=noninteractive apt-get install -y \
ca-certificates curl git nodejs npm
npm install --global @qwen-code/qwen-code@0.2.1
apt-get clean
rm -rf /var/lib/apt/lists/*
%runscript
exec qwen "$@"
Pin the version rather than using latest, so the image can be rebuilt
identically later. If the distribution's Node.js is too old for the release you
want, install a specific Node.js version in %post instead.
You can see recipe for building the qwen-code.sif at /apps/images/vllm/x86_64
on Perun cluster and /storage-apps on Devana cluster.
Build, test, install¶
singularity build qwen-code.sif qwen-code.def
singularity exec qwen-code.sif qwen --version
Building needs either elevated privileges and network access for the
base image and packages. This means you need to build on your own
machine of the same architecture and copy the .sif across.
Perun's login/CPU nodes are x86_64; GPU nodes are aarch64 (all Devana nodes are x86_64).
Since the agent runs on a login node, an x86_64 image is normally all you need — agent.sh
picks the matching one from uname -m.
Never overwrite an image in use
A running job may have it open, and you cannot tell whose session you break. Put a version and date in the filename, then update the profile.
Using a different coding agent¶
Nothing here is specific to Qwen Code except the image, the executable name, the session flags and the environment variables. To run a different agent:
- Build a SIF for it, following that agent's own install instructions. An agent's image contains only that agent — one image cannot start another.
- Copy
agent.shto, say,~/llm/aider.shand change four things: the image variables, theqwencommand at the bottom, the session-modecaseblock, and theOPENAI_*exports if the agent reads different variables. - Add its image paths to
config.envnext to the Qwen Code ones.
Two things to check before committing to an agent:
- Which variables it reads. Not every agent uses
OPENAI_BASE_URL. Some wantANTHROPIC_*, some want a config file written to$HOME. - Which API it needs. vLLM serves the OpenAI API, so an Anthropic-only client needs a translating proxy, not just different variable names.
Tool calling, streaming and structured output vary between agents and between models, so test any new combination end to end before advertising it.
When a second agent actually arrives
If a third one follows, that is the point to factor the shared parts of
agent.sh into one script with per-agent config files. Doing it with a
single agent buys nothing but indirection.
Troubleshooting¶
Server-side problems — the job will not start, the model will not load, out of
GPU memory — are on the vLLM page. Start by
checking the server directly; if it
answers curl, the problem is here.
`Unknown option: --foo`
The session mode is not one agent.sh understands. The modes are
--continue, --new, --resume and --list. Anything Qwen Code itself
should receive goes after -- instead:
~/llm/agent.sh -- --some-qwen-flag
The menu does not appear
agent.sh only prompts on a terminal. Run from a script or a pipe and it asks
for an explicit mode instead, so an automated run cannot hang waiting for
input that will never arrive.
`agent.sh` says there is no metadata
The server job has not got far enough to publish its port, or it died during startup:
squeue -u "$USER"
tail -50 ~/llm/jobs/vllm-JOBID.log
A job in PD is queued, not broken.
The server never becomes ready
agent.sh waits five minutes. A cold cache on a large model can exceed that;
pre-download the weights, then
retry.
The agent connects but cannot edit files
Tool calling is not working. The agent will describe edits in prose instead of
making them — a clear symptom. Confirm the model supports tool calling, then
check that TOOL_PARSER in config.env matches what the model emits; the
server log shows the raw tool call when parsing fails.
SLURM commands fail inside the container
Check which part is missing, from inside the same container:
singularity exec \
--bind /run/munge:/run/munge \
--bind /var/spool/slurmd/conf-cache:/etc/slurm \
--bind "$HOME/llm/.container_passwd:/etc/passwd" \
--bind "$HOME/llm/.container_group:/etc/group" \
--env "PATH=/opt/slurm/bin:/usr/bin:/bin" \
"$QWEN_SIF_X86" squeue -u "$USER"
Devana slurm bind
On devana slurm needs to be bind followingly -- bind /etc/slurm:/etc/slurm &
--env "PATH=/usr/local/bin:/opt/agent_env/bin:$HOME/.local/bin:/usr/bin:/bin"
| Symptom | Cause |
|---|---|
squeue: command not found |
PATH is missing /opt/slurm/bin |
Invalid user for ... or whoami fails |
The passwd and group files are missing or stale — regenerate them, see Setup |
| Credential or munge socket errors | /run/munge is not bound, or munge is not running on this node |
Unable to contact slurm controller |
/var/spool/slurmd/conf-cache or /etc/slurm (depending on cluster) is not bound over /etc/slurm |
All five paths must exist on the host; Singularity fails the whole launch if a bind source is missing, so a cryptic startup error usually means one of them.
A session will not resume
Sessions are stored per workspace, so --continue from a different directory
finds nothing. Use --list to see what exists, or --resume to pick from the
selector. Check also that your home directory is bound — session state lives
there, not in the image.
The agent stops mid-session
Usually the server job hit its walltime:
sacct -j JOBID --format=JobID,State,Elapsed,Timelimit
Submit a new server job and reconnect with --continue; saved sessions
survive, so you resume where you left off.
`This node is aarch64; the Qwen Code image is x86_64 only`
You are on a GPU node. The agent is meant to run on a login node and reach the model through the tunnel; no aarch64 client image exists. Log in to a login node and run it there.
If you need the agent inside a job for some other reason, request a CPU partition rather than a GPU one — those nodes are x86_64:
srun --account=p2075-26-2 --partition=cpu --time=1:00:00 --pty bash
Qwen Code is missing from the image
singularity exec qwen-code.sif which qwen
If this prints nothing, the install step failed silently during the build, or
it is installed outside the PATH that agent.sh sets.
Reference¶
Commands¶
| Task | Command |
|---|---|
| Menu | ~/llm/agent.sh |
| Continue last session | ~/llm/agent.sh --continue |
| New session in a workspace | ~/llm/agent.sh ~/proj --new |
| List sessions (no GPU) | ~/llm/agent.sh --list |
| Choose the server job | ~/llm/agent.sh -j JOBID |
| Pass arguments through | ~/llm/agent.sh -- --help |
| Start a server | sbatch ~/llm/submit_llm.sh |
| Stop a server | scancel JOBID |
Configuration¶
Set in config.env:
| Variable | Purpose |
|---|---|
QWEN_SIF_X86 |
Qwen Code image (x86_64; login and CPU nodes) |
LLM_PROJECT |
Project directory, bound into the container |
KEY |
API key, read from ~/.llm_api_key |