Compare commits
16
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
1fd32f0ff6 | ||
|
|
e9fad6bfad | ||
|
|
729a14f8bd | ||
|
|
d3c8f116f1 | ||
|
|
658c10fc35 | ||
|
|
649cd4ea9b | ||
|
|
509b10ac57 | ||
|
|
4ed96dcdf2 | ||
|
|
767a3341fd | ||
|
|
9a3ca95f9e | ||
|
|
f0f1152545 | ||
|
|
13148ea68c | ||
|
|
d0a4de9682 | ||
|
|
423a060816 | ||
|
|
cafd36eae1 | ||
|
|
b3deee7412 |
+58
-10
@@ -42,6 +42,15 @@ jobs:
|
|||||||
# .gitea/workflows/scripts/) are NOT on disk yet. Check this repo out into a separate subdir
|
# .gitea/workflows/scripts/) are NOT on disk yet. Check this repo out into a separate subdir
|
||||||
# and run the scripts from $SCRIPTS. Pinned to @main to match the caller's
|
# and run the scripts from $SCRIPTS. Pinned to @main to match the caller's
|
||||||
# `uses: …/agent.yml@main`, so the scripts and the workflow always move together.
|
# `uses: …/agent.yml@main`, so the scripts and the workflow always move together.
|
||||||
|
#
|
||||||
|
# actions/checkout requires `path` to be inside the workspace, so this necessarily lands the
|
||||||
|
# clone at `.agents-workflow/` INSIDE the tree the dev agent later edits. That checkout dir is
|
||||||
|
# untracked and looks like a stray artifact: an agent that commits it as a gitlink or
|
||||||
|
# `rm -rf`s it as "leftover" would destroy the very scripts the post-agent steps run, breaking
|
||||||
|
# the run with exit 127 and stranding pushed work with no PR (issue #33). To make the run
|
||||||
|
# immune, the next step copies the scripts to a stable location OUTSIDE the workspace
|
||||||
|
# (${{ runner.temp }}) and every later step runs from $SCRIPTS there — so nothing the agent
|
||||||
|
# does to the working tree can break the run's own execution environment.
|
||||||
- name: Fetch shared agent scripts (this repo)
|
- name: Fetch shared agent scripts (this repo)
|
||||||
uses: actions/checkout@v4
|
uses: actions/checkout@v4
|
||||||
with:
|
with:
|
||||||
@@ -50,13 +59,31 @@ jobs:
|
|||||||
path: .agents-workflow
|
path: .agents-workflow
|
||||||
token: ${{ secrets.GITEA_TOKEN }}
|
token: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|
||||||
|
# Copy the step scripts out of the workspace so the agent cannot break them (issue #33).
|
||||||
|
# $SCRIPTS points here for every subsequent step, NOT into the in-tree .agents-workflow/.
|
||||||
|
- name: Stage shared scripts outside the workspace
|
||||||
|
env:
|
||||||
|
SRC: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
|
||||||
|
DST: ${{ runner.temp }}/agents-scripts
|
||||||
|
run: |
|
||||||
|
set -eu
|
||||||
|
rm -rf "$DST"
|
||||||
|
mkdir -p "$DST"
|
||||||
|
cp -a "$SRC"/. "$DST"/
|
||||||
|
chmod -R a+rx "$DST" || true
|
||||||
|
echo "staged $(ls -1 "$DST" | wc -l) scripts at $DST"
|
||||||
|
|
||||||
- name: Route agent + prepare branch
|
- name: Route agent + prepare branch
|
||||||
id: prep
|
id: prep
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
BODY: ${{ github.event.comment.body }} # event text via env, never inline in shell
|
BODY: ${{ github.event.comment.body }} # event text via env, never inline in shell
|
||||||
IBODY: ${{ github.event.issue.body }}
|
IBODY: ${{ github.event.issue.body }}
|
||||||
EVENT: ${{ github.event_name }}
|
# Comment-vs-issue discriminator. Do NOT use github.event_name here: this is a REUSABLE
|
||||||
|
# (workflow_call) workflow, so on Gitea event_name is 'workflow_call', not the original
|
||||||
|
# 'issues'/'issue_comment'. The comment id, however, is reliably present in the forwarded
|
||||||
|
# payload — empty on an issue-opened event, set on a comment event.
|
||||||
|
CID: ${{ github.event.comment.id }}
|
||||||
IS_PR: ${{ github.event.issue.pull_request }}
|
IS_PR: ${{ github.event.issue.pull_request }}
|
||||||
NUM: ${{ github.event.issue.number }}
|
NUM: ${{ github.event.issue.number }}
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
GT: ${{ secrets.GITEA_TOKEN }}
|
||||||
@@ -69,7 +96,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Install opencode + provider config (+ Playwright MCP for browser agents)
|
- name: Install opencode + provider config (+ Playwright MCP for browser agents)
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
OLLAMA_URL: ${{ secrets.OLLAMA_URL }}
|
OLLAMA_URL: ${{ secrets.OLLAMA_URL }}
|
||||||
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
|
||||||
NAME: ${{ steps.prep.outputs.name }}
|
NAME: ${{ steps.prep.outputs.name }}
|
||||||
@@ -86,7 +113,7 @@ jobs:
|
|||||||
# All three secrets are passed via env and never inlined into shell — this shared workflow
|
# All three secrets are passed via env and never inlined into shell — this shared workflow
|
||||||
# runs in repos that don't have them and must not fail there.
|
# runs in repos that don't have them and must not fail there.
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
SWARM_HOST: ${{ secrets.SWARM_HOST }}
|
SWARM_HOST: ${{ secrets.SWARM_HOST }}
|
||||||
SWARM_USER: ${{ secrets.SWARM_USER }}
|
SWARM_USER: ${{ secrets.SWARM_USER }}
|
||||||
SSH_PRIV_KEY: ${{ secrets.SSH_PRIV_KEY }}
|
SSH_PRIV_KEY: ${{ secrets.SSH_PRIV_KEY }}
|
||||||
@@ -100,14 +127,14 @@ jobs:
|
|||||||
# Only emitted when AGENT_TOKEN is actually present, so repos without it don't get a
|
# Only emitted when AGENT_TOKEN is actually present, so repos without it don't get a
|
||||||
# broken skill. The token is passed via env and never inlined into shell.
|
# broken skill. The token is passed via env and never inlined into shell.
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
|
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
|
||||||
run: bash "$SCRIPTS/skill-gitea-api.sh"
|
run: bash "$SCRIPTS/skill-gitea-api.sh"
|
||||||
|
|
||||||
- name: Inspect / fetch image attachments (download only for vision agents)
|
- name: Inspect / fetch image attachments (download only for vision agents)
|
||||||
id: imgs
|
id: imgs
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
GT: ${{ secrets.GITEA_TOKEN }}
|
||||||
NUM: ${{ github.event.issue.number }}
|
NUM: ${{ github.event.issue.number }}
|
||||||
VISION: ${{ steps.prep.outputs.vision }}
|
VISION: ${{ steps.prep.outputs.vision }}
|
||||||
@@ -115,7 +142,7 @@ jobs:
|
|||||||
|
|
||||||
- name: Fetch the full issue thread (shared memory)
|
- name: Fetch the full issue thread (shared memory)
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
GT: ${{ secrets.GITEA_TOKEN }}
|
||||||
NUM: ${{ github.event.issue.number }}
|
NUM: ${{ github.event.issue.number }}
|
||||||
run: bash "$SCRIPTS/fetch-thread.sh"
|
run: bash "$SCRIPTS/fetch-thread.sh"
|
||||||
@@ -123,7 +150,7 @@ jobs:
|
|||||||
- name: Run agent
|
- name: Run agent
|
||||||
id: run
|
id: run
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
|
||||||
# AGENT_TOKEN powers the `gitea-api` skill (cross-repo issue/PR/Actions read+write).
|
# AGENT_TOKEN powers the `gitea-api` skill (cross-repo issue/PR/Actions read+write).
|
||||||
# It is already a required secret for the delegation step below; exposing it here too
|
# It is already a required secret for the delegation step below; exposing it here too
|
||||||
@@ -145,13 +172,13 @@ jobs:
|
|||||||
- name: Build activity log (tool calls + reasoning) from the event stream
|
- name: Build activity log (tool calls + reasoning) from the event stream
|
||||||
id: log
|
id: log
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
MODE: ${{ steps.prep.outputs.mode }}
|
MODE: ${{ steps.prep.outputs.mode }}
|
||||||
run: bash "$SCRIPTS/build-activity-log.sh"
|
run: bash "$SCRIPTS/build-activity-log.sh"
|
||||||
|
|
||||||
- name: Publish — PR (dev agents) or comment (pm), always reply in the issue
|
- name: Publish — PR (dev agents) or comment (pm), always reply in the issue
|
||||||
env:
|
env:
|
||||||
SCRIPTS: ${{ github.workspace }}/.agents-workflow/.gitea/workflows/scripts
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
GT: ${{ secrets.GITEA_TOKEN }}
|
||||||
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
|
AGENT_TOKEN: ${{ secrets.AGENT_TOKEN }}
|
||||||
TOKEN_PM: ${{ secrets.TOKEN_PM }}
|
TOKEN_PM: ${{ secrets.TOKEN_PM }}
|
||||||
@@ -167,6 +194,27 @@ jobs:
|
|||||||
NEW: ${{ steps.prep.outputs.new }}
|
NEW: ${{ steps.prep.outputs.new }}
|
||||||
run: bash "$SCRIPTS/publish.sh"
|
run: bash "$SCRIPTS/publish.sh"
|
||||||
|
|
||||||
|
# Failure-safe: if any step above failed AFTER a dev agent already pushed commits, the normal
|
||||||
|
# Publish step never ran, so the work would be stranded on the branch with no PR (issue #33).
|
||||||
|
# This best-effort step opens a PR for the pushed branch so nothing is silently lost. Runs from
|
||||||
|
# $SCRIPTS (outside the workspace) so it works even if the tree was mangled by the agent.
|
||||||
|
- name: Rescue — open a PR for pushed work if the run failed
|
||||||
|
if: failure()
|
||||||
|
env:
|
||||||
|
SCRIPTS: ${{ runner.temp }}/agents-scripts
|
||||||
|
GT: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
TOKEN_PM: ${{ secrets.TOKEN_PM }}
|
||||||
|
TOKEN_SENIOR: ${{ secrets.TOKEN_SENIOR }}
|
||||||
|
TOKEN_JUNIOR: ${{ secrets.TOKEN_JUNIOR }}
|
||||||
|
TOKEN_LEAD: ${{ secrets.TOKEN_LEAD }}
|
||||||
|
TOKEN_QA: ${{ secrets.TOKEN_QA }}
|
||||||
|
NAME: ${{ steps.prep.outputs.name }}
|
||||||
|
MODE: ${{ steps.prep.outputs.mode }}
|
||||||
|
NUM: ${{ github.event.issue.number }}
|
||||||
|
TITLE: ${{ github.event.issue.title }}
|
||||||
|
BRANCH: ${{ steps.prep.outputs.branch }}
|
||||||
|
run: bash "$SCRIPTS/rescue-pr.sh" || true
|
||||||
|
|
||||||
- name: Mark done with 🚀 (remove 👀)
|
- name: Mark done with 🚀 (remove 👀)
|
||||||
env:
|
env:
|
||||||
GT: ${{ secrets.GITEA_TOKEN }}
|
GT: ${{ secrets.GITEA_TOKEN }}
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
name: ai-agent
|
name: ai-agent
|
||||||
# Thin caller so the agents work on THIS repo too (their own workflow). Same shared logic.
|
run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment
|
||||||
# New issues opened by ffaerber auto-start @pm; @mention an agent in a comment to route on replies.
|
# Standard caller for the shared AI-agent workflow (ffaerber/agents). Copy this file VERBATIM into
|
||||||
|
# any repo that should get the agents — it is identical in every repo. All logic + scripts live in
|
||||||
|
# agents/.gitea/workflows/; scripts are fetched from @main at run time. The `jobs.agent` wrapper is
|
||||||
|
# required: a reusable (workflow_call) workflow can only be invoked from a caller job, not top-level.
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
types: [created]
|
types: [created]
|
||||||
|
|||||||
@@ -0,0 +1,7 @@
|
|||||||
|
{
|
||||||
|
"pm": {"model":"ollama-cloud/gemma4:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"Product manager — research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files."},
|
||||||
|
"junior": {"model":"ollama-cloud/kimi-k2.7-code:cloud","vision":false,"mode":"pr", "skills":[],"desc":"Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to @senior or @lead."},
|
||||||
|
"senior": {"model":"ollama-cloud/glm-5.2:cloud","vision":false,"mode":"pr", "skills":["gitea-api","node1-ssh"],"desc":"Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."},
|
||||||
|
"lead": {"model":"anthropic/claude-opus-4-8","vision":true, "mode":"pr", "skills":["gitea-api","node1-ssh"],"desc":"Tech lead — the hardest problems, architecture, and final calls."},
|
||||||
|
"qa": {"model":"ollama-cloud/minimax-m3:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"QA — verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs."}
|
||||||
|
}
|
||||||
@@ -28,10 +28,16 @@ SKILLS="${SKILLS:-[]}"
|
|||||||
PERM=$(jq -nc --argjson s "$SKILLS" '
|
PERM=$(jq -nc --argjson s "$SKILLS" '
|
||||||
{skill: ( {"*":"deny"} + (reduce $s[] as $k ({}; . + {($k):"allow"})) )}')
|
{skill: ( {"*":"deny"} + (reduce $s[] as $k ({}; . + {($k):"allow"})) )}')
|
||||||
# Two ollama providers: local self-hosted (ornith) + Ollama Cloud (gemma4/kimi-k2.7-code/glm-5.2/minimax-m3).
|
# Two ollama providers: local self-hosted (ornith) + Ollama Cloud (gemma4/kimi-k2.7-code/glm-5.2/minimax-m3).
|
||||||
jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" '{
|
# The ollama-cloud `models:` map is DERIVED from agents.json (the single source of truth, shared with
|
||||||
|
# route.sh) so every model an agent is routed to is always declared in the provider config. Only the
|
||||||
|
# `ollama-cloud/` provider prefix models participate — e.g. `anthropic/claude-opus-4-8` (@lead) is a
|
||||||
|
# built-in provider and `ornith:35b` is local-only, neither belongs here. See issue #31.
|
||||||
|
AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json"
|
||||||
|
CLOUD_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama-cloud/")) | sub("^ollama-cloud/";"")] | map({(.):{}}) | add // {}' "$AGENTS_JSON")
|
||||||
|
jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --argjson cloud "$CLOUD_MODELS" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" '{
|
||||||
provider: {
|
provider: {
|
||||||
ollama: {npm:"@ai-sdk/openai-compatible", options:{baseURL:($url+"/v1")}, models:{"ornith:35b":{}}},
|
ollama: {npm:"@ai-sdk/openai-compatible", options:{baseURL:($url+"/v1")}, models:{"ornith:35b":{}}},
|
||||||
"ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:{"glm-5.2:cloud":{},"gemma4:cloud":{},"kimi-k2.7-code:cloud":{},"minimax-m3:cloud":{}}}
|
"ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:$cloud}
|
||||||
},
|
},
|
||||||
permission: $perm,
|
permission: $perm,
|
||||||
mcp: $mcp
|
mcp: $mcp
|
||||||
|
|||||||
@@ -39,7 +39,6 @@ if [ "$MODE" != "pr" ]; then
|
|||||||
# Visible comment: the reply text, or a sensible line if the agent only emitted a marker.
|
# Visible comment: the reply text, or a sensible line if the agent only emitted a marker.
|
||||||
msg="$reply"
|
msg="$reply"
|
||||||
case "$msg" in ""|"_(Made changes"*) msg=$([ -n "$target" ] && echo "Handing off to @$target." || echo "_(no further comment)_") ;; esac
|
case "$msg" in ""|"_(Made changes"*) msg=$([ -n "$target" ] && echo "Handing off to @$target." || echo "_(no further comment)_") ;; esac
|
||||||
post "$(printf '🤖 **@%s**\n\n%s' "$NAME" "$msg")"
|
|
||||||
# Close the issue if the agent flagged it (maintainer said it's not needed / duplicate).
|
# Close the issue if the agent flagged it (maintainer said it's not needed / duplicate).
|
||||||
if grep -qiE '^[[:space:]]*CLOSE_ISSUE[[:space:]]*$' /tmp/agent_out.md; then
|
if grep -qiE '^[[:space:]]*CLOSE_ISSUE[[:space:]]*$' /tmp/agent_out.md; then
|
||||||
echo "closing issue #$NUM"
|
echo "closing issue #$NUM"
|
||||||
@@ -48,6 +47,9 @@ if [ "$MODE" != "pr" ]; then
|
|||||||
fi
|
fi
|
||||||
# BREAKDOWN: from a BEGIN_SUBTASKS block, create a milestone + one sub-issue per line
|
# BREAKDOWN: from a BEGIN_SUBTASKS block, create a milestone + one sub-issue per line
|
||||||
# (linked to this issue). Sub-issues are NOT auto-started — maintainer mentions agents later.
|
# (linked to this issue). Sub-issues are NOT auto-started — maintainer mentions agents later.
|
||||||
|
# Process subtasks first so we can append the created-issues list to the SAME comment as
|
||||||
|
# the reply (issue #38 — one comment per run).
|
||||||
|
subtext=""
|
||||||
if grep -qiE '^[[:space:]]*BEGIN_SUBTASKS' /tmp/agent_out.md; then
|
if grep -qiE '^[[:space:]]*BEGIN_SUBTASKS' /tmp/agent_out.md; then
|
||||||
block=$(awk '/^[[:space:]]*BEGIN_SUBTASKS/{f=1;next} /^[[:space:]]*END_SUBTASKS/{f=0} f' /tmp/agent_out.md)
|
block=$(awk '/^[[:space:]]*BEGIN_SUBTASKS/{f=1;next} /^[[:space:]]*END_SUBTASKS/{f=0} f' /tmp/agent_out.md)
|
||||||
ms=$(printf '%s\n' "$block" | sed -nE 's/^[[:space:]]*milestone:[[:space:]]*//Ip' | head -1)
|
ms=$(printf '%s\n' "$block" | sed -nE 's/^[[:space:]]*milestone:[[:space:]]*//Ip' | head -1)
|
||||||
@@ -75,8 +77,9 @@ if [ "$MODE" != "pr" ]; then
|
|||||||
echo "created sub-issue #${n:-?}: $title"
|
echo "created sub-issue #${n:-?}: $title"
|
||||||
[ -n "$n" ] && links="$links\n- #$n — $title"
|
[ -n "$n" ] && links="$links\n- #$n — $title"
|
||||||
done < /tmp/subtasks.txt
|
done < /tmp/subtasks.txt
|
||||||
post "$(printf '🤖 **@%s** — created sub-issues%s (mention an agent on each when ready):%b' "$NAME" "${ms:+ under milestone **$ms**}" "$links")"
|
subtext=$(printf '\n\n---\n🤖 **@%s** — created sub-issues%s (mention an agent on each when ready):%b' "$NAME" "${ms:+ under milestone **$ms**}" "$links")
|
||||||
fi
|
fi
|
||||||
|
post "$(printf '🤖 **@%s**\n\n%s%s' "$NAME" "$msg" "$subtext")"
|
||||||
# Auto-delegate: if the plan names a teammate, trigger them via AGENT_TOKEN (a PAT, so it
|
# Auto-delegate: if the plan names a teammate, trigger them via AGENT_TOKEN (a PAT, so it
|
||||||
# fires a new workflow run — the built-in token cannot). Never targets @pm or self, so the
|
# fires a new workflow run — the built-in token cannot). Never targets @pm or self, so the
|
||||||
# chain always terminates at a dev. The '🤖' guard on the trigger stops status-comment loops.
|
# chain always terminates at a dev. The '🤖' guard on the trigger stops status-comment loops.
|
||||||
@@ -98,6 +101,18 @@ if [ "$MODE" != "pr" ]; then
|
|||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
|
# Scrub the runtime scripts checkout (.agents-workflow) from the tree so it never lands in a
|
||||||
|
# commit/PR and never confuses the git ops below (issue #33). The scripts we run live outside the
|
||||||
|
# workspace ($SCRIPTS -> runner.temp), so removing this in-tree copy is always safe. Handle every
|
||||||
|
# way an agent might have left it: untracked dir, tracked files, or a committed gitlink/submodule.
|
||||||
|
if git ls-files --error-unmatch .agents-workflow >/dev/null 2>&1 || \
|
||||||
|
[ -n "$(git ls-files .agents-workflow 2>/dev/null)" ]; then
|
||||||
|
git rm -r --cached --quiet --ignore-unmatch .agents-workflow 2>/dev/null || true
|
||||||
|
fi
|
||||||
|
git config -f .gitmodules --remove-section submodule..agents-workflow 2>/dev/null || true
|
||||||
|
[ -s .gitmodules ] || rm -f .gitmodules 2>/dev/null || true
|
||||||
|
rm -rf .agents-workflow 2>/dev/null || true
|
||||||
|
|
||||||
# The agent may have committed on the starting branch AND/OR created extra
|
# The agent may have committed on the starting branch AND/OR created extra
|
||||||
# ai/issue-N-<slug> branches. Commit any leftover on the current branch, push it, then
|
# ai/issue-N-<slug> branches. Commit any leftover on the current branch, push it, then
|
||||||
# open a PR for EVERY ai/issue-N* branch that has commits beyond main.
|
# open a PR for EVERY ai/issue-N* branch that has commits beyond main.
|
||||||
@@ -111,6 +126,16 @@ git fetch -q origin 2>/dev/null || true
|
|||||||
prbody=$(printf '%s\n\n---\nResolves #%s · 🤖 @%s' "$prdesc" "$NUM" "$NAME")
|
prbody=$(printf '%s\n\n---\nResolves #%s · 🤖 @%s' "$prdesc" "$NUM" "$NAME")
|
||||||
owner=${GITHUB_REPOSITORY%%/*}
|
owner=${GITHUB_REPOSITORY%%/*}
|
||||||
|
|
||||||
|
# Post the agent's activity trail (tool calls + reasoning) inline in the same comment so
|
||||||
|
# each run produces exactly ONE comment (issue #38). Computed once here so every dev-agent
|
||||||
|
# exit path (no-changes, PR-open-failed, normal) appends it to the single reply comment.
|
||||||
|
activity=""
|
||||||
|
if [ -s /tmp/activity_log.md ]; then
|
||||||
|
entries=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0)
|
||||||
|
log=$(cat /tmp/activity_log.md)
|
||||||
|
activity=$(printf '\n\n---\n🤖 **@%s** — activity log (%s entries):\n<details>\n<summary>tool calls & reasoning</summary>\n\n%s\n\n</details>' "$NAME" "$entries" "$log")
|
||||||
|
fi
|
||||||
|
|
||||||
# One PR per run: publish ONLY this run's own branch ($BRANCH), never sibling
|
# One PR per run: publish ONLY this run's own branch ($BRANCH), never sibling
|
||||||
# ai/issue-N-* branches. This removes the multi-PR ambiguity that left the
|
# ai/issue-N-* branches. This removes the multi-PR ambiguity that left the
|
||||||
# activity log stranded on the triggering issue instead of the PR thread.
|
# activity log stranded on the triggering issue instead of the PR thread.
|
||||||
@@ -118,7 +143,7 @@ br="$BRANCH"
|
|||||||
ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0)
|
ahead=$(git rev-list --count "origin/main..origin/$br" 2>/dev/null || echo 0)
|
||||||
if [ "${ahead:-0}" -eq 0 ]; then
|
if [ "${ahead:-0}" -eq 0 ]; then
|
||||||
# No changes on this branch — a plan / questions / analysis only.
|
# No changes on this branch — a plan / questions / analysis only.
|
||||||
post "$(printf '🤖 **@%s**\n\n%s' "$NAME" "$reply")"
|
post "$(printf '🤖 **@%s**\n\n%s%s' "$NAME" "$reply" "$activity")"
|
||||||
exit 0
|
exit 0
|
||||||
fi
|
fi
|
||||||
|
|
||||||
@@ -136,7 +161,7 @@ if [ -z "$url" ]; then
|
|||||||
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
|
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
|
||||||
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
|
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
|
||||||
fi
|
fi
|
||||||
[ -z "$url" ] && { echo "PR open/lookup failed for $br — posting reply on issue instead"; post "$(printf '🤖 **@%s**\n\n%s' "$NAME" "$reply")"; exit 0; }
|
[ -z "$url" ] && { echo "PR open/lookup failed for $br — posting reply on issue instead"; post "$(printf '🤖 **@%s**\n\n%s%s' "$NAME" "$reply" "$activity")"; exit 0; }
|
||||||
|
|
||||||
# Posts to the PR thread when we have a PR number, else to the origin issue ($NUM).
|
# Posts to the PR thread when we have a PR number, else to the origin issue ($NUM).
|
||||||
prpost() {
|
prpost() {
|
||||||
@@ -148,17 +173,8 @@ prpost() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if [ "$NEW" = "true" ]; then
|
if [ "$NEW" = "true" ]; then
|
||||||
prpost "$prnum" "$(printf '🤖 **@%s** — ✅ PR ready for review — @ffaerber please review & merge:\n- %s' "$NAME" "$url")"
|
prpost "$prnum" "$(printf '🤖 **@%s** — ✅ PR ready for review — @ffaerber please review & merge:\n- %s%s' "$NAME" "$url" "$activity")"
|
||||||
else
|
else
|
||||||
# Resume (comment is on a PR thread): include the write-up here too.
|
# Resume (comment is on a PR thread): include the write-up here too.
|
||||||
prpost "$prnum" "$(printf '🤖 **@%s** — updated branch/PR:\n- %s\n\n%s' "$NAME" "$url" "$prdesc")"
|
prpost "$prnum" "$(printf '🤖 **@%s** — updated branch/PR:\n- %s\n\n%s%s' "$NAME" "$url" "$prdesc" "$activity")"
|
||||||
fi
|
|
||||||
|
|
||||||
# Post the agent's activity trail (tool calls + reasoning) as a separate comment so
|
|
||||||
# it is visible on the PR thread. Additive — kept here even when nothing changed, so a
|
|
||||||
# follow-up run (re-trigger) can see what this run did via the fetched issue thread.
|
|
||||||
if [ -s /tmp/activity_log.md ]; then
|
|
||||||
entries=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0)
|
|
||||||
log=$(cat /tmp/activity_log.md)
|
|
||||||
prpost "$prnum" "$(printf '🤖 **@%s** — activity log (%s entries):\n<details>\n<summary>tool calls & reasoning</summary>\n\n%s\n\n</details>' "$NAME" "$entries" "$log")"
|
|
||||||
fi
|
fi
|
||||||
|
|||||||
Executable
+56
@@ -0,0 +1,56 @@
|
|||||||
|
#!/usr/bin/env bash
|
||||||
|
# Failure-safe rescue: when a run FAILED after a dev agent already pushed commits, the normal
|
||||||
|
# Publish step never ran and the work would be stranded on the branch with no PR (issue #33).
|
||||||
|
# This opens a PR for the pushed branch so nothing is silently lost. It is strictly best-effort:
|
||||||
|
# every failure here is swallowed (the caller also appends `|| true`) so it can never itself break
|
||||||
|
# the run. Comment-only roles (pm/qa) push nothing, so they are skipped.
|
||||||
|
#
|
||||||
|
# Required env (provided by the workflow step):
|
||||||
|
# GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA
|
||||||
|
# NAME MODE NUM TITLE BRANCH GITHUB_SERVER_URL GITHUB_REPOSITORY
|
||||||
|
set +e
|
||||||
|
|
||||||
|
# Only dev agents (mode=pr) ever push a branch to rescue.
|
||||||
|
[ "${MODE:-}" = "pr" ] || { echo "rescue: comment-mode agent, nothing to rescue"; exit 0; }
|
||||||
|
[ -n "${BRANCH:-}" ] || { echo "rescue: no branch known, skipping"; exit 0; }
|
||||||
|
|
||||||
|
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
|
||||||
|
case "$NAME" in
|
||||||
|
pm) TOK="$TOKEN_PM";; senior) TOK="$TOKEN_SENIOR";; junior) TOK="$TOKEN_JUNIOR";;
|
||||||
|
lead) TOK="$TOKEN_LEAD";; qa) TOK="$TOKEN_QA";; *) TOK="";;
|
||||||
|
esac
|
||||||
|
[ -z "$TOK" ] && TOK="$GT"
|
||||||
|
API="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}"
|
||||||
|
hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
|
||||||
|
|
||||||
|
git fetch -q origin 2>/dev/null || true
|
||||||
|
|
||||||
|
# Nothing to rescue unless the branch exists on the remote with commits beyond main.
|
||||||
|
ahead=$(git rev-list --count "origin/main..origin/$BRANCH" 2>/dev/null || echo 0)
|
||||||
|
if [ "${ahead:-0}" -eq 0 ]; then
|
||||||
|
echo "rescue: no pushed commits on origin/$BRANCH beyond main — nothing to rescue"
|
||||||
|
exit 0
|
||||||
|
fi
|
||||||
|
echo "rescue: origin/$BRANCH is $ahead commit(s) ahead of main — ensuring a PR exists"
|
||||||
|
|
||||||
|
# Idempotent: Gitea ignores ?head=, so match the head branch client-side.
|
||||||
|
resp=$(curl -sS "${hdr[@]}" "$API/pulls?state=open&limit=50" \
|
||||||
|
| jq -r --arg br "$BRANCH" 'if type=="array" then (map(select(.head.ref==$br)) | .[0] // empty) else empty end' 2>/dev/null)
|
||||||
|
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
|
||||||
|
if [ -z "$url" ]; then
|
||||||
|
body=$(printf 'The run failed before it could publish, but pushed work exists on this branch — opening a PR so it is not lost.\n\n---\nResolves #%s · 🤖 @%s (auto-rescued after a failed run)' "$NUM" "$NAME")
|
||||||
|
resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \
|
||||||
|
-d "$(jq -nc --arg t "@$NAME: $TITLE" --arg h "$BRANCH" --arg b "$body" \
|
||||||
|
'{title:$t, head:$h, base:"main", body:$b}')")
|
||||||
|
echo "rescue PR create ($BRANCH): $resp"
|
||||||
|
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
|
||||||
|
fi
|
||||||
|
|
||||||
|
if [ -n "$url" ]; then
|
||||||
|
curl -sS -X POST "${hdr[@]}" "$API/issues/$NUM/comments" \
|
||||||
|
-d "$(jq -nc --arg b "$(printf '🤖 **@%s** — ⚠️ the run failed, but your pushed work was not lost — a PR was opened for branch \`%s\`:\n- %s' "$NAME" "$BRANCH" "$url")" '{body:$b}')" \
|
||||||
|
-w '\nrescue comment -> HTTP %{http_code}\n' || true
|
||||||
|
else
|
||||||
|
echo "rescue: could not open/find a PR for $BRANCH"
|
||||||
|
fi
|
||||||
|
exit 0
|
||||||
@@ -4,35 +4,34 @@
|
|||||||
# /tmp/agents.json, picks which agent to run, emits step outputs (name/model/vision/mode/branch/new)
|
# /tmp/agents.json, picks which agent to run, emits step outputs (name/model/vision/mode/branch/new)
|
||||||
# to $GITHUB_OUTPUT, configures git identity, and prepares/publishes the working branch.
|
# to $GITHUB_OUTPUT, configures git identity, and prepares/publishes the working branch.
|
||||||
#
|
#
|
||||||
# Required env (all provided by the workflow step): BODY IBODY EVENT IS_PR NUM GT
|
# Required env (all provided by the workflow step): BODY IBODY CID IS_PR NUM GT
|
||||||
# TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA
|
# TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA
|
||||||
# GITHUB_SERVER_URL GITHUB_REPOSITORY GITHUB_OUTPUT
|
# GITHUB_SERVER_URL GITHUB_REPOSITORY GITHUB_OUTPUT
|
||||||
set -eu
|
set -eu
|
||||||
|
|
||||||
# --- agent registry: model + capabilities + mode + role + skills ---
|
# --- agent registry: model + capabilities + mode + role + skills ---
|
||||||
|
# The registry is the SINGLE SOURCE OF TRUTH, kept in agents.json next to this
|
||||||
|
# script. install-opencode.sh derives its ollama-cloud provider `models:` map
|
||||||
|
# from the same file, so an agent's model can never be missing from the provider
|
||||||
|
# config — drift is impossible by construction. See issue #31.
|
||||||
# `skills` is the allow-list of opencode Skills each agent may load. It scopes the
|
# `skills` is the allow-list of opencode Skills each agent may load. It scopes the
|
||||||
# `permission.skill` block written into opencode.json (see install-opencode.sh) so an agent only
|
# `permission.skill` block written into opencode.json (see install-opencode.sh) so an agent only
|
||||||
# ever sees (and can load) the skills relevant to its role. Skills NOT listed here are hidden from
|
# ever sees (and can load) the skills relevant to its role. Skills NOT listed here are hidden from
|
||||||
# that agent entirely — not even the one-line summary appears in its <available_skills>, so the
|
# that agent entirely — not even the one-line summary appears in its <available_skills>, so the
|
||||||
# full API/how-to detail never reaches an agent that shouldn't act on it. A teammate can still learn
|
# full API/how-to detail never reaches an agent that shouldn't act on it. A teammate can still learn
|
||||||
# *that* another agent has a capability from the roster and ask them to use it.
|
# *that* another agent has a capability from the roster and ask them to use it.
|
||||||
cat > /tmp/agents.json <<'JSON'
|
AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json"
|
||||||
{
|
cp "$AGENTS_JSON" /tmp/agents.json
|
||||||
"pm": {"model":"ollama-cloud/gemma4:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"Product manager — research, plan, ask clarifying questions, and decide which dev should do the work. Comments only; never edits files."},
|
|
||||||
"junior": {"model":"ollama-cloud/kimi-k2.7-code:cloud","vision":false,"mode":"pr", "skills":[],"desc":"Junior dev — small, low-risk changes (mostly YAML/compose/config). Text-only, cannot read images. Defers complex or image tasks to @senior or @lead."},
|
|
||||||
"senior": {"model":"ollama-cloud/glm-5.2:cloud","vision":false,"mode":"pr", "skills":["gitea-api","node1-ssh"],"desc":"Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."},
|
|
||||||
"lead": {"model":"anthropic/claude-opus-4-8","vision":true, "mode":"pr", "skills":["gitea-api","node1-ssh"],"desc":"Tech lead — the hardest problems, architecture, and final calls."},
|
|
||||||
"qa": {"model":"ollama-cloud/minimax-m3:cloud","vision":true, "mode":"comment","skills":["gitea-api"],"desc":"QA — verifies things work. Drives a headless browser (Playwright) to open a URL/web app, click through it, screenshot, and report bugs or confirm behavior. Comments findings; opens no PRs."}
|
|
||||||
}
|
|
||||||
JSON
|
|
||||||
# On a new issue, @pm auto-assesses. On a comment, route by the @mention.
|
# On a new issue, @pm auto-assesses. On a comment, route by the @mention.
|
||||||
scan="$BODY"; [ "$EVENT" = "issues" ] && scan="$IBODY"
|
# A comment event has a comment id (CID); an issue-opened event does not. (event_name is unreliable
|
||||||
|
# here — see agent.yml: this reusable workflow sees it as 'workflow_call'.)
|
||||||
|
if [ -n "$CID" ]; then scan="$BODY"; else scan="$IBODY"; fi
|
||||||
name=""
|
name=""
|
||||||
for a in pm junior senior lead qa; do
|
for a in pm junior senior lead qa; do
|
||||||
case "$scan" in *"@$a"*) name=$a; break;; esac
|
case "$scan" in *"@$a"*) name=$a; break;; esac
|
||||||
done
|
done
|
||||||
if [ -z "$name" ]; then
|
if [ -z "$name" ]; then
|
||||||
if [ "$EVENT" = "issues" ]; then name=pm; else echo "no known agent mentioned"; exit 1; fi
|
if [ -z "$CID" ]; then name=pm; else echo "no known agent mentioned"; exit 1; fi
|
||||||
fi
|
fi
|
||||||
model=$(jq -r --arg a "$name" '.[$a].model' /tmp/agents.json)
|
model=$(jq -r --arg a "$name" '.[$a].model' /tmp/agents.json)
|
||||||
vision=$(jq -r --arg a "$name" '.[$a].vision' /tmp/agents.json)
|
vision=$(jq -r --arg a "$name" '.[$a].vision' /tmp/agents.json)
|
||||||
|
|||||||
@@ -10,7 +10,10 @@ set -u
|
|||||||
[ -z "$CMT" ] && CMT="(a new issue was just opened — assess it)"
|
[ -z "$CMT" ] && CMT="(a new issue was just opened — assess it)"
|
||||||
THREAD=$(cat /tmp/thread.md 2>/dev/null); [ -z "$THREAD" ] && THREAD="(no prior comments)"
|
THREAD=$(cat /tmp/thread.md 2>/dev/null); [ -z "$THREAD" ] && THREAD="(no prior comments)"
|
||||||
DESC=$(jq -r --arg a "$NAME" '.[$a].desc' /tmp/agents.json)
|
DESC=$(jq -r --arg a "$NAME" '.[$a].desc' /tmp/agents.json)
|
||||||
ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision))") | join("\n")' /tmp/agents.json)
|
# Include each teammate's skills so an agent (esp. @pm) can route by capability — e.g. only
|
||||||
|
# @senior/@lead hold node1-ssh, so a node1 task must not go to @junior. Skill *names* only; the
|
||||||
|
# scoped how-to detail stays hidden per the permission.skill allow-list.
|
||||||
|
ROSTER=$(jq -r 'to_entries | map("- @\(.key): \(.value.desc) (vision: \(.value.vision); skills: \(.value.skills | if length>0 then join(", ") else "none" end))") | join("\n")' /tmp/agents.json)
|
||||||
if [ "$VISION" = "true" ]; then CAP="You CAN read images attached to the issue."; else CAP="You CANNOT read images — you are a text-only model."; fi
|
if [ "$VISION" = "true" ]; then CAP="You CAN read images attached to the issue."; else CAP="You CANNOT read images — you are a text-only model."; fi
|
||||||
NOTE=""
|
NOTE=""
|
||||||
if [ "$VISION" != "true" ] && [ "${HAS_IMAGES:-0}" -gt 0 ]; then
|
if [ "$VISION" != "true" ] && [ "${HAS_IMAGES:-0}" -gt 0 ]; then
|
||||||
|
|||||||
@@ -0,0 +1,2 @@
|
|||||||
|
.env
|
||||||
|
.agents-workflow/
|
||||||
@@ -29,10 +29,18 @@ detail out of agents that shouldn't act on it while still letting them know the
|
|||||||
|
|
||||||
## Use it in a repo
|
## Use it in a repo
|
||||||
|
|
||||||
Add `.gitea/workflows/ai-agent.yml` to the consuming repo:
|
**The standard caller is one file, identical in every repo.** Copy this repo's own
|
||||||
|
[`.gitea/workflows/ai-agent.yml`](.gitea/workflows/ai-agent.yml) verbatim into the consuming repo —
|
||||||
|
it is the source of truth, and `agents` itself uses the same file:
|
||||||
|
|
||||||
```yaml
|
```yaml
|
||||||
name: ai-agent
|
name: ai-agent
|
||||||
|
run-name: "ai-agent · #${{ github.event.issue.number }}" # quotes required: bare # starts a YAML comment
|
||||||
|
# Standard caller for the shared AI-agent workflow (ffaerber/agents). Copy this file VERBATIM into
|
||||||
|
# any repo that should get the agents — it is identical in every repo. All logic + scripts live in
|
||||||
|
# agents/.gitea/workflows/; scripts are fetched from @main at run time. The `jobs.agent` wrapper is
|
||||||
|
# required: a reusable (workflow_call) workflow can only be invoked from a caller job, not top-level.
|
||||||
|
# `run-name` titles each run by the triggering issue (e.g. "ai-agent · #42") in the Actions list.
|
||||||
on:
|
on:
|
||||||
issue_comment:
|
issue_comment:
|
||||||
types: [created]
|
types: [created]
|
||||||
@@ -44,7 +52,9 @@ jobs:
|
|||||||
secrets: inherit
|
secrets: inherit
|
||||||
```
|
```
|
||||||
|
|
||||||
That's the whole per-repo footprint. All the logic (agent registry, routing, delegation,
|
That's the whole per-repo footprint, and it's the minimum a caller can be: the `on:` triggers must
|
||||||
|
live in each repo (a reusable workflow can't declare its callers' triggers) and the `jobs.agent`
|
||||||
|
wrapper is mandatory for `workflow_call`. Everything else (agent registry, routing, delegation,
|
||||||
reactions, PR/issue plumbing) lives here in `agent.yml`.
|
reactions, PR/issue plumbing) lives here in `agent.yml`.
|
||||||
|
|
||||||
## Repo layout
|
## Repo layout
|
||||||
|
|||||||
Reference in New Issue
Block a user