Skip to content

framework-agent actions-1-c325747b-turn-1 #9

framework-agent actions-1-c325747b-turn-1

framework-agent actions-1-c325747b-turn-1 #9

# The workflow ActionsDriver dispatches (#610): one run is one `prompt()` turn.
#
# The driver cannot learn a run id from the dispatch API (it answers 204 with no body), so
# the correlation id it generates is echoed into `run-name` and into the artifact name. That
# is how the driver finds its own run, and it is why both must keep interpolating it.
name: framework-agent
run-name: framework-agent ${{ inputs.correlation_id }}
on:
workflow_dispatch:
inputs:
prompt:
description: What the agent should do. Passed to the action verbatim, never through a shell.
required: true
correlation_id:
description: How the driver finds this run. Must appear in run-name and in the artifact name.
required: true
model:
description: Model id, e.g. claude-opus-4-8. Empty means the action's default.
required: false
resume_session_id:
description: A prior agent session id to continue instead of starting fresh.
required: false
branch:
description: Branch to push the run's work to, so the driver can read it back and continue on it (#1085).
required: false
# The workflow pushes the run branch and the agent may open a PR. `id-token: write` is not
# optional: the action exchanges an OIDC token to authenticate the subscription OAuth token,
# and without it every run fails with "Could not fetch an OIDC token".
permissions:
contents: write
pull-requests: write
id-token: write
jobs:
agent:
runs-on: ubuntu-latest
# Well under the 6h job cap. A turn that runs this long has gone wrong.
timeout-minutes: 60
steps:
- uses: actions/checkout@v4
with:
# Full history: the agent reads the log to understand what it is changing.
fetch-depth: 0
# Built from environment variables rather than by interpolating ${{ }} into the shell,
# so a crafted input cannot become a command. The driver validates them too.
- name: Compose agent arguments
id: args
env:
MODEL: ${{ inputs.model }}
RESUME: ${{ inputs.resume_session_id }}
run: |
set -euo pipefail
# Agent mode grants no permissions by default, so an unattended run cannot edit
# or run anything without this. The runner is disposable, which is what makes it safe.
args="--dangerously-skip-permissions"
# Written as `if` rather than `[ .. ] && ..`, which returns non-zero when the input
# is empty and would fail the step under `set -e`.
if [ -n "$MODEL" ]; then args="$args --model $MODEL"; fi
if [ -n "$RESUME" ]; then args="$args --resume $RESUME"; fi
echo "value=$args" >> "$GITHUB_OUTPUT"
- name: Run the agent
id: claude
uses: anthropics/claude-code-action@v1
with:
# A `claude setup-token` OAuth token: the run spends the subscription, not an API key (#495).
claude_code_oauth_token: ${{ secrets.CLAUDE_CODE_OAUTH_TOKEN }}
prompt: ${{ inputs.prompt }}
claude_args: ${{ steps.args.outputs.value }}
# The runner and its checkout vanish when the job ends, so the only way the driver can
# read the agent's work (diffs, file contents) or run the next turn on top of it is a
# branch on origin. The action does not create one for a workflow_dispatch run, so we
# push it here, to the name the driver chose. This mirrors the local flow, where the
# framework (not the agent) pushes the session branch (#799); the agent just commits.
- name: Push the run branch
if: always()
id: runbranch
env:
RUN_BRANCH: ${{ inputs.branch }}
DISPATCH_REF: ${{ github.ref_name }}
# Authenticate the push explicitly. The agent step runs its own git setup and leaves
# the checkout's persisted credentials unusable, so a plain `git push origin` fails
# with "Authentication failed"; push through a tokenized URL instead.
GH_PUSH_TOKEN: ${{ github.token }}
run: |
set -euo pipefail
if [ -z "$RUN_BRANCH" ]; then echo "no run branch requested"; exit 0; fi
git config user.name "framework-agent"
git config user.email "framework-agent@users.noreply.github.com"
# Commit anything the agent left uncommitted so it is not lost with the runner.
if [ -n "$(git status --porcelain)" ]; then
git add -A
git commit -m "framework agent run ($RUN_BRANCH)" --quiet
fi
# Don't create an empty branch for a no-op turn: only push when the run advanced
# past the ref it checked out. If that ref can't be resolved, err toward pushing.
base="$(git rev-parse --verify --quiet "origin/$DISPATCH_REF" || true)"
if [ -n "$base" ] && [ "$(git rev-parse HEAD)" = "$base" ]; then
echo "no new commits; nothing to push"
exit 0
fi
git push "https://x-access-token:${GH_PUSH_TOKEN}@github.com/${GITHUB_REPOSITORY}.git" "HEAD:refs/heads/$RUN_BRANCH"
echo "branch=$RUN_BRANCH" >> "$GITHUB_OUTPUT"
# The only REST-readable channel out of a run. `always()` so a failed turn still
# returns its transcript, which is exactly when we most want to read it.
- name: Collect the transcript
if: always()
env:
EXECUTION_FILE: ${{ steps.claude.outputs.execution_file }}
# The branch we actually pushed above, not the action's branch_name, which stays
# empty for a workflow_dispatch agent run (#1085).
BRANCH: ${{ steps.runbranch.outputs.branch }}
SESSION_ID: ${{ steps.claude.outputs.session_id }}
run: |
set -euo pipefail
# Not dot-prefixed: upload-artifact@v4 defaults include-hidden-files to false and
# drops every file under a hidden path segment, so a `.framework-run/` dir uploads
# nothing and the driver finds no artifact to read.
mkdir -p framework-run
# An empty array still parses, so a crashed action yields an empty turn, not a driver error.
if [ -n "$EXECUTION_FILE" ] && [ -f "$EXECUTION_FILE" ]; then
cp "$EXECUTION_FILE" framework-run/execution.json
else
echo '[]' > framework-run/execution.json
fi
jq -n --arg branch "$BRANCH" --arg session_id "$SESSION_ID" \
'{branch: $branch, session_id: $session_id}' > framework-run/meta.json
- uses: actions/upload-artifact@v4
if: always()
with:
name: framework-run-${{ inputs.correlation_id }}
path: framework-run
retention-days: 7