Compare commits

...
Author SHA1 Message Date
hermes 9dc1c203cb fix(agents): declare workflow_call secrets + diagnose empty XAI_API_KEY
ci / lint (pull_request) Successful in 14s
Cross-owner reusable calls left XAI_API_KEY empty in the runner while
OLLAMA_CLOUD_API_KEY worked. Declare secrets on workflow_call, accept
alternate secret names, log key lengths (not values), and ship an
ai-agent.yml caller template with an explicit secrets map.
2026-07-30 16:47:13 +03:00
hermes 58db2996ad feat(agents): automatic Ollama Cloud → xAI fallback (#121)
ci / lint (push) Successful in 14s
Primary stays ollama-cloud; on quota/provider failure run-agent fails over once to agents.json fallback (xAI).
2026-07-30 15:28:44 +02:00
hermes 0ddde87b40 feat(agents): automatic model fallback when Ollama Cloud is exhausted
ci / lint (pull_request) Successful in 13s
Keep ollama-cloud/* as primary. Each agent may declare fallback in
agents.json (xai-oc/grok-*). On quota/auth/provider failure, run-agent
switches once to the fallback and continues retries.

- agents.json: fallback fields for pm/junior/senior/qa/intern
- route.sh: emit fallback to GITHUB_OUTPUT
- agent.yml: pass FALLBACK env into run-agent
- install-opencode.sh: register primary+fallback models in provider maps
- run-agent.sh: failover on quota/credit/429/capacity-class errors
2026-07-30 16:28:30 +03:00
hermes bbdb200f32 fix(agents): route pm/junior/senior/qa to xAI while Ollama Cloud is exhausted
ci / lint (pull_request) Successful in 13s
Ollama Cloud usage is at 100% so ollama-cloud/* agents fail. Move
pm, junior, senior, qa to xai-oc (grok-4.5 / grok-4.3). leave intern on
local ollama/ornith:35b; lead/ops already on xAI.
2026-07-30 16:25:46 +03:00
ffaerber d7d54546a3 Merge pull request 'Allow hermes user to trigger AI agent workflow' (#120) from ai/issue-218-allow-hermes into main
ci / lint (push) Successful in 13s
Reviewed-on: #120
2026-07-30 14:07:49 +02:00
hermes 38b36b2304 feat: allow hermes user to trigger AI agent workflow
ci / lint (pull_request) Successful in 14s
Add 'hermes' to the trusted issue creators and comment authors in the
workflow gate so the Hermes Agent can create issues and comment with
@mentions to trigger the AI agent pipeline.
2026-07-30 15:06:05 +03:00
ffaerber e040247097 Merge pull request 'fix(agents): rename xai provider key to xai-oc to avoid opencode catalog collision' (#119) from hotfix/xai-provider-name-collision into main
ci / lint (push) Successful in 14s
2026-07-30 13:08:37 +02:00
Felix Faerber c8efc17729 fix(agents): rename xai provider key to xai-oc to avoid opencode catalog collision (#118)
ci / lint (pull_request) Successful in 17s
opencode ships a built-in `xai` model-catalog entry (a real @ai-sdk/xai
integration with Responses-API support). Naming our custom OpenAI-compatible
shim `xai` too made opencode's --auto permission-check path assume the
catalog's provider object once a `permission` block is present (always, in
this pipeline) and call .responses(), which our shim doesn't implement —
crashing every @lead/@ops run with "Z.responses is not a function".

Reproduced locally with the production config shape on opencode 1.17.13;
renaming the provider key to xai-oc (model id xai-oc/grok-4.5) fixes it
reliably across repeated fresh-state runs.
2026-07-30 14:08:21 +03:00
ffaerber 60f9e95ecc Merge pull request 'hotfix: wire the xai provider into install-opencode.sh' (#115) from hotfix/xai-provider-wiring into main
ci / lint (push) Successful in 14s
2026-07-27 15:49:45 +02:00
Felix Faerber dd25c0ac2b hotfix: wire the xai provider into install-opencode.sh
ci / lint (pull_request) Successful in 21s
PR #114 pointed @lead/@ops at xai/grok-4.5 but never gave opencode a
provider config for it — xai is not a built-in like anthropic was, it
needs the same explicit OpenAI-compatible block as ollama/ollama-cloud
(confirmed by the prior, never-merged investigation in PR #110). Without
this, any @lead/@ops run fails immediately on an unresolvable provider.

Adds the xai provider block (baseURL https://api.x.ai/v1, models derived
from agents.json) and passes XAI_API_KEY into the Install-opencode step
(it was already added to the Run-agent step in #114).
2026-07-27 16:49:20 +03:00
ffaerber 8ae6abd9d0 Merge pull request 'chore: switch @lead/@ops from Anthropic to xAI grok-4.5' (#114) from chore/xai-grok-4.5 into main
ci / lint (push) Successful in 17s
2026-07-27 15:43:21 +02:00
Felix Faerber b30db8a4c9 chore: switch @lead/@ops from Anthropic to xAI grok-4.5
ci / lint (pull_request) Successful in 15s
Removes the last Anthropic-model usage from the agent registry.
XAI_API_KEY was already provisioned as an org secret; wire it into
the run-agent step and update every doc/comment that referenced
ANTHROPIC_API_KEY or claude-opus-4-8.
2026-07-27 16:43:05 +03:00
ffaerber 6955bc4f71 Merge pull request '@junior: publish.sh: PR-creation and push failures are silently swallowed' (#113) from ai/issue-112 into main
ci / lint (push) Successful in 32s
2026-07-27 15:33:43 +02:00
junior 0a06c813bc publish.sh: address review — fix PR-failure format, capture HTTP status, append activity to push failure 2026-07-27 13:32:47 +00:00
junior c4fb117b93 publish.sh: surface PR-open and git-push failures as issue comments
ci / lint (pull_request) Successful in 13s
2026-07-27 13:30:49 +00:00
ffaerber eeae1fdaaa fix: deterministic dedup of agent double-posts (self-post + framework reply) (#109)
ci / lint (push) Successful in 10s
2026-07-08 09:35:19 +02:00
ffaerber 7abbbb1b8d feat(ci): re-enable dependency caching (runner cache server fixed) (#108)
ci / lint (push) Skipped
2026-07-07 17:57:33 +02:00
ffaerber 95bc254640 revert(ci): remove cache steps until runner cache server works (#107)
ci / lint (push) Skipped
2026-07-07 17:52:25 +02:00
ffaerber 75a2493dee feat(ci): dependency caching for agent runs (opencode + Playwright) (#106)
ci / lint (push) Skipped
2026-07-07 17:16:24 +02:00
ffaerber 53ac2b7ba8 fix(agent): reactions show the agent, not "Ghost" (#105)
ci / lint (push) Skipped
2026-07-07 15:20:11 +02:00
ffaerber 4b73d0b8e9 fix(run-agent): stop agents self-posting their reply as a duplicate comment (#104)
ci / lint (push) Skipped
2026-07-07 15:09:45 +02:00
11 changed files with 227 additions and 54 deletions
+77 -20
View File
@@ -4,6 +4,35 @@ name: agent
# The gate + steps run in the caller's event context (github.event.* / github.repository are the caller's).
on:
workflow_call:
# Explicit secret contract so callers can map secrets by name (more reliable than
# secrets: inherit alone on some Gitea versions / cross-owner reusable workflows).
secrets:
GITEA_TOKEN:
required: true
OLLAMA_URL:
required: false
OLLAMA_CLOUD_API_KEY:
required: false
XAI_API_KEY:
required: false
TOKEN_PM:
required: false
TOKEN_SENIOR:
required: false
TOKEN_JUNIOR:
required: false
TOKEN_LEAD:
required: false
TOKEN_QA:
required: false
TOKEN_OPS:
required: false
TOKEN_INTERN:
required: false
# Pinned opencode version — used to install it and to key the CI cache below.
env:
OPENCODE_VERSION: "1.17.13"
jobs:
@@ -21,9 +50,10 @@ jobs:
# Trusted author only, and only when a known agent is mentioned. This gate is the main
# defense against malicious-issue prompt injection — do not loosen it.
if: >
(github.event.comment == null && github.event.issue.user.login == 'ffaerber') ||
(github.event.comment == null && (github.event.issue.user.login == 'ffaerber' || github.event.issue.user.login == 'hermes')) ||
(github.event.comment != null &&
(github.event.comment.user.login == 'ffaerber' ||
github.event.comment.user.login == 'hermes' ||
github.event.comment.user.login == 'pm' ||
github.event.comment.user.login == 'junior' ||
github.event.comment.user.login == 'senior' ||
@@ -44,17 +74,6 @@ jobs:
# job must never hold the single runner slot for hours.
timeout-minutes: 45
steps:
- name: Acknowledge with 👀
env:
GT: ${{ secrets.GITEA_TOKEN }}
CID: ${{ github.event.comment.id }}
NUM: ${{ github.event.issue.number }}
run: |
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" \
"$R" -d '{"content":"eyes"}' -w '\nreact -> HTTP %{http_code}\n' || true
- uses: actions/checkout@v4
with:
fetch-depth: 0
@@ -119,12 +138,46 @@ jobs:
TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }}
run: bash "$SCRIPTS/route.sh"
- name: Acknowledge with 👀 (as the routed agent)
if: steps.prep.outputs.mode != 'skip'
env:
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
CID: ${{ github.event.comment.id }}
NUM: ${{ github.event.issue.number }}
run: |
[ -n "$SELF_TOKEN" ] || { echo "no agent token — skipping 👀"; exit 0; }
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" \
"$R" -d '{"content":"eyes"}' -w '\nreact -> HTTP %{http_code}\n' || true
- name: Cache opencode CLI
if: steps.prep.outputs.mode != 'skip'
continue-on-error: true # a cache backend hiccup must never fail an agent run
uses: actions/cache@v4
with:
path: ~/.opencode
key: opencode-${{ runner.os }}-${{ env.OPENCODE_VERSION }}
- name: Cache Playwright browsers + npm (browser agents only)
if: steps.prep.outputs.mode != 'skip' && (steps.prep.outputs.name == 'senior' || steps.prep.outputs.name == 'lead' || steps.prep.outputs.name == 'qa')
continue-on-error: true
uses: actions/cache@v4
with:
path: |
~/.cache/ms-playwright
~/.npm
key: playwright-npm-${{ runner.os }}-v1
- name: Install opencode + provider config (+ Playwright MCP for browser agents)
if: steps.prep.outputs.mode != 'skip'
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
OLLAMA_URL: ${{ secrets.OLLAMA_URL }}
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
# Accept common alternate names — empty XAI_API_KEY has bitten us when the
# secret was stored under a slightly different key on the caller repo.
XAI_API_KEY: ${{ secrets.XAI_API_KEY || secrets.XAI_KEY || secrets.GROK_API_KEY || secrets.XAI_TOKEN }}
NAME: ${{ steps.prep.outputs.name }}
SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load
run: bash "$SCRIPTS/install-opencode.sh"
@@ -187,7 +240,7 @@ jobs:
id: run
env:
SCRIPTS: ${{ runner.temp }}/agents-scripts
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY || secrets.XAI_KEY || secrets.GROK_API_KEY || secrets.XAI_TOKEN }}
# SELF_TOKEN = the RUNNING agent's OWN token (TOKEN_PM for @pm, TOKEN_OPS for @ops, …).
# Only this agent's token is placed in its process env, so no agent can act as another.
# Powers the gitea-api / gitea-admin skills — each agent calls Gitea as itself. Every
@@ -196,6 +249,7 @@ jobs:
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
NAME: ${{ steps.prep.outputs.name }}
MODEL: ${{ steps.prep.outputs.model }}
FALLBACK: ${{ steps.prep.outputs.fallback }}
VISION: ${{ steps.prep.outputs.vision }}
MODE: ${{ steps.prep.outputs.mode }}
WORKMODE: ${{ steps.prep.outputs.workmode }} # build | discuss (devs consulted in-thread)
@@ -266,24 +320,27 @@ jobs:
run: bash "$SCRIPTS/rescue-pr.sh" || true
- name: Mark done with 🚀 (remove 👀)
if: steps.prep.outputs.mode != 'skip'
env:
GT: ${{ secrets.GITEA_TOKEN }}
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
CID: ${{ github.event.comment.id }}
NUM: ${{ github.event.issue.number }}
run: |
[ -n "$SELF_TOKEN" ] || exit 0
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"rocket"}' -w '\nreact -> HTTP %{http_code}\n' || true
curl -sS -X DELETE -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"rocket"}' -w '\nreact -> HTTP %{http_code}\n' || true
- name: Mark failed with 😕 (remove 👀)
if: failure()
if: failure() && steps.prep.outputs.mode != 'skip'
env:
GT: ${{ secrets.GITEA_TOKEN }}
SELF_TOKEN: ${{ steps.prep.outputs.name == 'pm' && secrets.TOKEN_PM || steps.prep.outputs.name == 'junior' && secrets.TOKEN_JUNIOR || steps.prep.outputs.name == 'senior' && secrets.TOKEN_SENIOR || steps.prep.outputs.name == 'lead' && secrets.TOKEN_LEAD || steps.prep.outputs.name == 'qa' && secrets.TOKEN_QA || steps.prep.outputs.name == 'ops' && secrets.TOKEN_OPS || steps.prep.outputs.name == 'intern' && secrets.TOKEN_INTERN || '' }}
CID: ${{ github.event.comment.id }}
NUM: ${{ github.event.issue.number }}
run: |
[ -n "$SELF_TOKEN" ] || exit 0
B="${GITHUB_SERVER_URL}/api/v1/repos/${GITHUB_REPOSITORY}/issues"
if [ -n "$CID" ]; then R="$B/comments/$CID/reactions"; else R="$B/$NUM/reactions"; fi
curl -sS -X DELETE -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
curl -sS -X POST -H "Authorization: token $GT" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true
curl -sS -X DELETE -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"eyes"}' || true
curl -sS -X POST -H "Authorization: token $SELF_TOKEN" -H "Content-Type: application/json" "$R" -d '{"content":"confused"}' -w '\nreact -> HTTP %{http_code}\n' || true
+14 -1
View File
@@ -12,4 +12,17 @@ on:
jobs:
agent:
uses: gitea/agents/.gitea/workflows/agent.yml@main
secrets: inherit
# Explicit secret map (plus inherit) so XAI_API_KEY / OLLAMA_* always reach the reusable
# workflow. secrets: inherit alone has left XAI_API_KEY empty on some Gitea cross-owner calls.
secrets:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
OLLAMA_URL: ${{ secrets.OLLAMA_URL }}
OLLAMA_CLOUD_API_KEY: ${{ secrets.OLLAMA_CLOUD_API_KEY }}
XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
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 }}
TOKEN_OPS: ${{ secrets.TOKEN_OPS }}
TOKEN_INTERN: ${{ secrets.TOKEN_INTERN }}
+12 -7
View File
@@ -1,31 +1,34 @@
{
"pm": {
"model": "ollama-cloud/minimax-m3:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": true,
"mode": "comment",
"skills": [
"gitea-api"
],
"desc": "Product manager & orchestrator — plans and picks the dev, hands each finished PR to @qa for review, and reports back to the issue creator (in autopilot it merges approved PRs itself). Works from the issue thread only — comments only, never edits files, never reads the PR diff."
"desc": "Product manager & orchestrator — plans and picks the dev, hands each finished PR to @qa for review, and reports back to the issue creator (in autopilot it merges approved PRs itself). Works from the issue thread only — comments only, never edits files, never reads the PR diff. Falls back to xAI grok-4.5 if Ollama Cloud is unavailable/quota-exhausted."
},
"junior": {
"model": "ollama-cloud/kimi-k2.7-code:cloud",
"fallback": "xai-oc/grok-4.3",
"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."
"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. Falls back to xAI grok-4.3 if Ollama Cloud fails."
},
"senior": {
"model": "ollama-cloud/glm-5.2:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": false,
"mode": "pr",
"skills": [
"gitea-api"
],
"desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."
"desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). Falls back to xAI grok-4.5 if Ollama Cloud fails."
},
"lead": {
"model": "anthropic/claude-opus-4-8",
"model": "xai-oc/grok-4.5",
"vision": true,
"mode": "pr",
"skills": [
@@ -35,15 +38,16 @@
},
"qa": {
"model": "ollama-cloud/minimax-m3:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": true,
"mode": "comment",
"skills": [
"gitea-api"
],
"desc": "QA / reviewer — reviews PRs: reads the diff, drives a headless browser (Playwright) to verify behavior, posts specific recommendations on the PR and the pass/fail verdict on the issue. Never edits code, never merges."
"desc": "QA / reviewer — reviews PRs: reads the diff, drives a headless browser (Playwright) to verify behavior, posts specific recommendations on the PR and the pass/fail verdict on the issue. Never edits code, never merges. Falls back to xAI grok-4.5 if Ollama Cloud fails."
},
"ops": {
"model": "anthropic/claude-opus-4-8",
"model": "xai-oc/grok-4.5",
"vision": false,
"mode": "comment",
"skills": [
@@ -53,9 +57,10 @@
},
"intern": {
"model": "ollama/ornith:35b",
"fallback": "xai-oc/grok-4.3",
"vision": false,
"mode": "pr",
"skills": [],
"desc": "Intern — very basic tasks only, routed to the local Ollama model (ornith:35b). Text-only, cannot read images. Escalates anything non-trivial to @junior, @senior or @lead."
"desc": "Intern — very basic tasks only, routed to the local Ollama model (ornith:35b). Text-only, cannot read images. Escalates anything non-trivial to @junior, @senior or @lead. Falls back to xAI grok-4.3 if local Ollama is down."
}
}
@@ -38,7 +38,7 @@ COST=${COST:-0}; INP=${INP:-0}; OUT=${OUT:-0}; CR=${CR:-0}; CW=${CW:-0}; RE=${RE
IN_TOTAL=$(( INP + CR + CW )) # total input context processed
# Cost label: ollama / ollama-cloud models are SUBSCRIPTION-billed (GPU-time against the plan, no
# $/token price exists), so a "$0.0000" there would be misleading — label it a subscription instead.
# Metered providers (anthropic/…) get the real dollar cost opencode computed.
# Metered providers (xai-oc/…) get the real dollar cost opencode computed.
case "${MODEL:-}" in
ollama*|*"/ollama"*) COSTF="subscription" ;;
*) COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}') ;;
+6
View File
@@ -26,3 +26,9 @@ jq -rs '
"### comment by \($who):\n\(.body | gsub("\\s*<!-- 🤖 agent reply — do not trigger -->"; ""))\n"' \
/tmp/thread_pages.json > /tmp/thread.md 2>/dev/null || : > /tmp/thread.md
echo "thread comments fetched: $(grep -c '^### comment by ' /tmp/thread.md 2>/dev/null || echo 0) (newest 100 kept)"
# Record the newest comment id on the thread BEFORE the agent runs. publish.sh compares against
# it to detect an agent that self-posted its reply mid-run (via the gitea-api skill, despite the
# prompt telling it not to) and skips the duplicate framework reply. Ids are monotonic — no dates.
jq -rs '[ (add // [])[].id ] | max // 0' /tmp/thread_pages.json > /tmp/thread_max_cid 2>/dev/null || echo 0 > /tmp/thread_max_cid
echo "pre-run newest comment id: $(cat /tmp/thread_max_cid)"
+40 -12
View File
@@ -1,15 +1,30 @@
#!/usr/bin/env bash
# Install opencode + provider config (+ Playwright MCP for browser agents).
#
# Required env (provided by the workflow step): OLLAMA_URL OLLAMA_CLOUD_API_KEY NAME SKILLS
# GITHUB_PATH HOME
# Required env (provided by the workflow step): OLLAMA_URL OLLAMA_CLOUD_API_KEY XAI_API_KEY
# NAME SKILLS GITHUB_PATH HOME
set -eu
# Non-secret diagnostics — prove which provider keys reached the runner (length only).
echo "provider key lengths: OLLAMA_URL=${#OLLAMA_URL} OLLAMA_CLOUD_API_KEY=${#OLLAMA_CLOUD_API_KEY} XAI_API_KEY=${#XAI_API_KEY}"
if [ -z "${XAI_API_KEY:-}" ]; then
echo "WARNING: XAI_API_KEY is empty in this job. xai-oc fallback will fail."
echo "Fix: set Actions secret XAI_API_KEY on the CALLER repo (e.g. ffaerber/homelab),"
echo "not only on gitea/agents. Name must be exact: XAI_API_KEY"
fi
# PIN the opencode version: an unpinned `latest` means a breaking release (CLI flags, or the
# --format json event schema that build-activity-log.sh parses) breaks every agent in every repo
# at once. Bump deliberately by changing this default (or set OPENCODE_VERSION in the step env).
OPENCODE_VERSION="${OPENCODE_VERSION:-1.17.13}"
curl -fsSL https://opencode.ai/install | bash -s -- --version "$OPENCODE_VERSION"
# Skip the download when a cache hit already restored the pinned binary (see the Cache
# opencode CLI step in agent.yml). The installer always re-fetches otherwise.
OC_BIN="$HOME/.opencode/bin/opencode"
if [ -x "$OC_BIN" ] && "$OC_BIN" --version 2>/dev/null | grep -qF "$OPENCODE_VERSION"; then
echo "opencode $OPENCODE_VERSION already present (cache hit) — skipping install"
else
curl -fsSL https://opencode.ai/install | bash -s -- --version "$OPENCODE_VERSION"
fi
echo "$HOME/.opencode/bin" >> "$GITHUB_PATH"
mkdir -p ~/.config/opencode
# Playwright browser MCP only for agents that need to drive a web app
@@ -31,19 +46,32 @@ esac
SKILLS="${SKILLS:-[]}"
PERM=$(jq -nc --argjson s "$SKILLS" '
{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).
# The provider `models:` maps are 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.
# `ollama-cloud/` prefix models go to the cloud provider; `ollama/` prefix models go to the local
# provider. Built-in providers (e.g. `anthropic/claude-opus-4-8` for @lead) are not derived here.
# Three OpenAI-compatible providers: local self-hosted ollama (ornith) + Ollama Cloud
# (gemma4/kimi-k2.7-code/glm-5.2/minimax-m3) + xAI (grok-4.5). The provider `models:` maps are
# 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. `ollama-cloud/` prefix models go to
# the cloud provider; `ollama/` prefix models go to the local provider; `xai-oc/` prefix models go
# to our xAI shim (OpenAI-compatible, https://api.x.ai/v1). No other built-in providers remain.
#
# The provider key is `xai-oc`, NOT `xai` — opencode ships a built-in `xai` entry in its own model
# catalog (a real @ai-sdk/xai integration that implements the Responses API). Naming our custom
# openai-compatible shim `xai` collides with that catalog entry: opencode's --auto permission-check
# path then assumes the catalog's provider (which has `.responses()`) and crashes with
# "Z.responses is not a function" on every run once a `permission` block is present (i.e. always in
# this pipeline). Confirmed by reproducing locally with the production config shape on opencode
# 1.17.13 — renaming the key to `xai-oc` avoids the collision entirely. See issue #118.
# 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")
LOCAL_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama/")) | sub("^ollama/";"")] | map({(.):{}}) | add // {"ornith:35b":{}}' "$AGENTS_JSON")
jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --argjson cloud "$CLOUD_MODELS" --argjson local "$LOCAL_MODELS" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" '{
# Include primary `.model` AND optional `.fallback` so failover models are always registered
# in opencode provider maps (issue: Ollama Cloud quota → xAI).
CLOUD_MODELS=$(jq -r '[.[] | (.model, .fallback) | select(type=="string" and startswith("ollama-cloud/")) | sub("^ollama-cloud/";"")] | unique | map({(.):{}}) | add // {}' "$AGENTS_JSON")
LOCAL_MODELS=$(jq -r '[.[] | (.model, .fallback) | select(type=="string" and startswith("ollama/")) | sub("^ollama/";"")] | unique | map({(.):{}}) | add // {"ornith:35b":{}}' "$AGENTS_JSON")
XAI_MODELS=$(jq -r '[.[] | (.model, .fallback) | select(type=="string" and startswith("xai-oc/")) | sub("^xai-oc/";"")] | unique | map({(.):{}}) | add // {}' "$AGENTS_JSON")
jq -n --argjson mcp "$MCP" --argjson perm "$PERM" --argjson cloud "$CLOUD_MODELS" --argjson local "$LOCAL_MODELS" --argjson xai "$XAI_MODELS" --arg url "$OLLAMA_URL" --arg ckey "$OLLAMA_CLOUD_API_KEY" --arg xkey "$XAI_API_KEY" '{
provider: {
ollama: {npm:"@ai-sdk/openai-compatible", options:{baseURL:($url+"/v1")}, models:$local},
"ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:$cloud}
"ollama-cloud": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://ollama.com/v1", apiKey:$ckey}, models:$cloud},
"xai-oc": {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://api.x.ai/v1", apiKey:$xkey}, models:$xai}
},
permission: $perm,
mcp: $mcp
+42 -4
View File
@@ -195,7 +195,31 @@ if [ "$MODE" != "pr" ]; then
done < /tmp/subtasks.txt
subtext=$(printf '\n\n---\nCreated sub-issues%s (mention an agent on each when ready):%b' "${ms:+ under milestone **$ms**}" "$links")
fi
# DEDUP GUARD (issue: @pm double-posts its report). Prompt-level "do not self-post" is ignored
# by some models, so enforce it here: if the agent ALREADY posted a comment on this thread
# during the run (any comment by $NAME newer than the pre-run newest id from fetch-thread.sh),
# its self-post IS the reply — skip the duplicate framework comment. Markers (CLOSE_ISSUE,
# DELEGATE, MERGE_PR, subtasks) were already processed above and are unaffected.
# FAIL OPEN: if the pre-run marker is missing (fetch-thread hiccup), pre_cid=0 would make the
# agent's comments from PREVIOUS runs count as self-posts and wrongly suppress the reply.
# Without the marker, skip the guard and post normally.
pre_cid=$(cat /tmp/thread_max_cid 2>/dev/null || echo "")
selfposts=0
if [ -n "$pre_cid" ]; then
: > /tmp/all_comments.json
for pg in $(seq 1 10); do
cpg=$(curl -sS "${hdr[@]}" "$API/issues/$NUM/comments?limit=50&page=$pg" 2>/dev/null) || cpg='[]'
cn=$(printf '%s' "$cpg" | jq 'if type=="array" then length else 0 end' 2>/dev/null || echo 0)
[ "${cn:-0}" -gt 0 ] && printf '%s\n' "$cpg" >> /tmp/all_comments.json
[ "${cn:-0}" -lt 50 ] && break
done
selfposts=$(jq -rs --arg n "$NAME" --argjson c "${pre_cid:-0}" '[ (add // [])[] | select(.user.login==$n) | select(.id > $c) ] | length' /tmp/all_comments.json 2>/dev/null || echo 0)
fi
if [ "${selfposts:-0}" -gt 0 ]; then
echo "agent @$NAME already posted ${selfposts} comment(s) on #$NUM during this run — skipping duplicate framework reply"
else
post "$(printf '%s%s%s' "$msg" "$subtext" "$activity")"
fi
# --- @pm autopilot merge: @pm is the ONLY agent that merges, and ONLY under the autopilot label ---
# (@qa never merges — it approves and hands back here.) Merge with the PAT (TTOK), not the built-in
@@ -310,7 +334,14 @@ if [ -n "$(git status --porcelain)" ]; then
git add -A
git commit -m "@$NAME: issue #$NUM"
fi
git push origin "HEAD:$BRANCH" || true
if git push origin "HEAD:$BRANCH"; then
:
else
status=$?
echo "git push failed for $BRANCH (exit $status)"
post "$(printf '⚠️ Push to branch `%s` failed (git exit %s). The PR will not open until the push succeeds. Please check the Actions log.%s' "$BRANCH" "$status" "$activity")"
exit 0
fi
git fetch -q origin 2>/dev/null || true
prbody=$(printf '%s\n\n---\nResolves #%s' "$prdesc" "$NUM")
@@ -336,14 +367,21 @@ url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
if [ -z "$url" ]; then
title="@$NAME: $TITLE"
resp=$(curl -sS -X POST "${hdr[@]}" "$API/pulls" \
resp_body=/tmp/pr_create_resp.json
http_status=$(curl -sS -o "$resp_body" -w '%{http_code}' -X POST "${hdr[@]}" "$API/pulls" \
-d "$(jq -nc --arg t "$title" --arg h "$br" --arg b "$prbody" \
'{title:$t, head:$h, base:"main", body:$b}')")
echo "PR create ($br): $resp"
resp=$(cat "$resp_body" 2>/dev/null || true)
echo "PR create ($br): HTTP $http_status$resp"
url=$(printf '%s' "$resp" | jq -r '.html_url // empty' 2>/dev/null)
prnum=$(printf '%s' "$resp" | jq -r '.number // empty' 2>/dev/null)
fi
[ -z "$url" ] && { echo "PR open/lookup failed for $br — posting reply on issue instead"; post "$(printf '%s%s' "$reply" "$activity")"; exit 0; }
if [ -z "$url" ]; then
err_msg=$(printf '%s' "$resp" | jq -r 'if type=="object" and .message then .message else "(no error message in response)" end' 2>/dev/null)
echo "PR open/lookup failed for $br — HTTP $http_status — response: $resp"
post "$(printf '⚠️ Failed to open PR for branch `%s`.\n\nHTTP status: %s\nGitea message: %s%s' "$br" "$http_status" "$err_msg" "$activity")"
exit 0
fi
# Posts to the PR thread when we have a PR number, else to the origin issue ($NUM).
prpost() {
+5 -3
View File
@@ -43,12 +43,14 @@ if [ -z "$name" ]; then
# Not an agent task (e.g. the gate's contains() matched "@internal"). Skip GRACEFULLY: emit
# mode=skip so every later step no-ops — a red run for a non-agent comment is just noise.
echo "no known agent mentioned (word-boundary) — skipping run"
{ echo "name=none"; echo "model=none"; echo "vision=false"; echo "mode=skip"; echo "skills=[]";
{ echo "name=none"; echo "model=none"; echo "fallback="; echo "vision=false"; echo "mode=skip"; echo "skills=[]";
echo "branch=main"; echo "new=false"; echo "autopilot=false"; echo "issnum=$NUM"; } >> "$GITHUB_OUTPUT"
exit 0
fi
fi
model=$(jq -r --arg a "$name" '.[$a].model' /tmp/agents.json)
# Optional provider failover when primary is quota/network-dead (e.g. Ollama Cloud → xAI).
fallback=$(jq -r --arg a "$name" '.[$a].fallback // empty' /tmp/agents.json)
vision=$(jq -r --arg a "$name" '.[$a].vision' /tmp/agents.json)
mode=$(jq -r --arg a "$name" '.[$a].mode' /tmp/agents.json)
# Compact JSON array of the skills this agent may load (scopes permission.skill in install-opencode.sh).
@@ -70,8 +72,8 @@ if [ "$mode" = "pr" ] && [ -z "$IS_PR" ]; then
workmode=discuss
fi
fi
echo "Routing to @$name (model=$model vision=$vision mode=$mode workmode=$workmode skills=$skills)"
{ echo "name=$name"; echo "model=$model"; echo "vision=$vision"; echo "mode=$mode"; echo "workmode=$workmode"; echo "skills=$skills"; } >> "$GITHUB_OUTPUT"
echo "Routing to @$name (model=$model fallback=${fallback:-none} vision=$vision mode=$mode workmode=$workmode skills=$skills)"
{ echo "name=$name"; echo "model=$model"; echo "fallback=$fallback"; echo "vision=$vision"; echo "mode=$mode"; echo "workmode=$workmode"; echo "skills=$skills"; } >> "$GITHUB_OUTPUT"
# Act as the agent's own Gitea user when its token is set; else the built-in bot.
case "$name" in
+25 -1
View File
@@ -3,7 +3,7 @@
# plain-text reply (/tmp/agent_out.md) plus the raw event stream (/tmp/events.jsonl).
#
# Required env (provided by the workflow step):
# ANTHROPIC_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
# XAI_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
# IBODY CMT
# FILES (the opencode -f image flags, from the imgs step output)
# AUTOPILOT is 'true' when the issue carries the `autopilot` label (label-gated autopilot mode).
@@ -149,6 +149,12 @@ ${LEARN}
shown by Gitea. Do NOT begin your reply with your own name, an '@${NAME}' header, or a '🤖/🔨 @you'
line; just write the content directly.
Do NOT use the gitea-api skill to post your reply, report, or any comment on THIS thread
yourself. The automation already posts your reply exactly once — self-posting it too is what
creates the duplicate comments you must avoid. On this thread, use gitea-api only to READ, or to
take an explicit action you were asked for (add/remove a label, close the issue, merge the PR).
Your report or answer IS your reply text — write it as your reply; do not post it via the API.
TEAM ROSTER (who does what — hand off if a task isn't yours):
${ROSTER}
@@ -180,6 +186,16 @@ rc=1
# instance-wide. 20 min is far above any legitimate attempt. timeout SIGTERMs, then SIGKILLs 30s
# later. rc=124 (timed out) is NOT retried — a hung backend stays hung; fail fast, free the runner.
AGENT_TIMEOUT="${AGENT_TIMEOUT:-1200}"
# Provider failover: when primary model dies on quota/auth/provider errors, switch once to
# FALLBACK (from agents.json) and continue the retry loop. Transient rate-limits still back off
# on the current model first.
FALLBACK_MODEL="${FALLBACK:-}"
fallback_used=0
is_failover_error() {
# Ollama Cloud exhausted / provider hard-fail — switch to fallback rather than thrash.
grep -qiE 'overloaded|429|529|rate.?limit|timeout|ETIMEDOUT|ECONNRESET|EAI_AGAIN|quota|credit|balance|usage.?limit|limit.?exceed|402|403|401|insufficient|out of credits|payment.?required|model_not_found|not found|Unavailable|capacity|ENOTFOUND|ECONNREFUSED' \
/tmp/events.jsonl /tmp/agent_err.log 2>/dev/null
}
for attempt in 1 2 3; do
echo "opencode attempt $attempt/3 for @$NAME ($MODEL, timeout ${AGENT_TIMEOUT}s)"
rc=0
@@ -190,6 +206,14 @@ for attempt in 1 2 3; do
echo "--- stderr (trace) ---"; cat /tmp/agent_err.log
[ $rc -eq 0 ] && break
if [ $rc -eq 124 ]; then echo "attempt timed out after ${AGENT_TIMEOUT}s — backend hung, not retrying"; break; fi
if [ $fallback_used -eq 0 ] && [ -n "$FALLBACK_MODEL" ] && [ "$FALLBACK_MODEL" != "$MODEL" ] && is_failover_error; then
echo "primary model failed — failing over to fallback: $FALLBACK_MODEL"
MODEL="$FALLBACK_MODEL"
fallback_used=1
# short pause then use next attempt slot on the fallback provider
sleep 2
continue
fi
if grep -qiE 'overloaded|429|529|rate.?limit|timeout|ETIMEDOUT|ECONNRESET|EAI_AGAIN' /tmp/events.jsonl /tmp/agent_err.log; then
echo "transient error — backing off $((attempt*20))s"; sleep $((attempt * 20)); continue
fi
@@ -107,7 +107,7 @@ curl -sS -X POST -H "Authorization: token $SELF_TOKEN" "$API/orgs/{org}/labels"
3. Commit the standard caller so it gets the agents — `PUT /repos/{owner}/{repo}/contents/.gitea/workflows/ai-agent.yml`
with base64 `content`, `message`, `branch:"main"` (copy the exact caller from the `agents` repo README).
4. Add the agent bot users as collaborators: `PUT /repos/{owner}/{repo}/collaborators/{username}` (`{"permission":"write"}`).
5. Ensure the repo can run agents — the org must hold the runtime secrets (ANTHROPIC_API_KEY, SELF_TOKEN,
5. Ensure the repo can run agents — the org must hold the runtime secrets (XAI_API_KEY, SELF_TOKEN,
TOKEN_* , OLLAMA_URL, OLLAMA_CLOUD_API_KEY); set any missing via the secrets calls above.
## Packages / container registry
+3 -3
View File
@@ -10,9 +10,9 @@ Shared **AI dev-team** workflow for Gitea Actions, reusable across repos. It giv
| `@pm` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | Product manager & orchestrator — plans, picks the dev, hands finished PRs to `@qa`, reports back to the issue creator (autopilot: merges approved PRs itself). Issue thread only; never edits files, never reads the PR diff. |
| `@junior` | `ollama-cloud/kimi-k2.7-code:cloud` | no | pr | — | 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` | `ollama-cloud/glm-5.2:cloud` | no | pr | `gitea-api` | Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only). |
| `@lead` | `anthropic/claude-opus-4-8` | yes | pr | `gitea-api` | Tech lead — the hardest problems, architecture, and final calls. |
| `@lead` | `xai-oc/grok-4.5` | yes | pr | `gitea-api` | Tech lead — the hardest problems, architecture, and final calls. |
| `@qa` | `ollama-cloud/minimax-m3:cloud` | yes | comment | `gitea-api` | QA / reviewer — reads the PR diff, drives a headless browser (Playwright) to verify behavior; recommendations on the PR, pass/fail verdict on the issue. Never edits code, never merges. |
| `@ops` | `anthropic/claude-opus-4-8` | no | comment | `gitea-admin` | Gitea operator — administers the instance itself (create orgs/users/repos, labels, secrets, scoped per-user tokens, bootstrap repos). Comments only; never edits code. Confirms before destructive actions. |
| `@ops` | `xai-oc/grok-4.5` | no | comment | `gitea-admin` | Gitea operator — administers the instance itself (create orgs/users/repos, labels, secrets, scoped per-user tokens, bootstrap repos). Comments only; never edits code. Confirms before destructive actions. |
| `@intern` | `ollama/ornith:35b` | no | pr | — | Intern — very basic tasks only, routed to the local Ollama model (`ornith:35b`). Text-only, cannot read images. Escalates anything non-trivial to `@junior`, `@senior` or `@lead`. |
The registry `.gitea/workflows/scripts/agents.json` is the source of truth for this mapping — if you
@@ -111,7 +111,7 @@ points `$SCRIPTS` at it. Keep the workflow and its scripts moving together on `m
| Secret | For |
|--------|-----|
| `ANTHROPIC_API_KEY` | `@lead` (and `@pm`/`@senior`/`@qa` if on Claude) |
| `XAI_API_KEY` | `@lead`, `@ops` (and any other agent switched to a `xai-oc/…` model) |
| `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, glm-5.2, minimax-m3) |
| `TOKEN_PM`,`TOKEN_SENIOR`,`TOKEN_JUNIOR`,`TOKEN_LEAD`,`TOKEN_QA` | **primary** — each agent's own Gitea-user PAT. The running agent gets *only its own* token (as `SELF_TOKEN`) so it posts, commits and comments as itself, and its `gitea-api` skill acts with its own scopes. Scopes: devs + `TOKEN_PM` carry `write:repository` (`@pm` is the only agent that merges, autopilot only); `TOKEN_QA` is `read:repository` + `write:issue` (reviews, never merges). |
| `TOKEN_OPS` | `@ops` only — the admin PAT behind the `gitea-admin` skill (create orgs/users/repos, manage labels & secrets, mint scoped tokens). Injected into the agent process only when the agent is `@ops`. |