Compare commits

..
Author SHA1 Message Date
junior c6cafaf81c Switch @lead model to ollama-cloud/kimi-k3:cloud
ci / lint (pull_request) Successful in 13s
2026-07-29 10:48:29 +00:00
10 changed files with 41 additions and 163 deletions

No files matched your search

+4 -47
View File
@@ -1,46 +1,9 @@
name: agent name: agent
# Reusable AI-agent workflow, shared across repos. A caller repo triggers on issue_comment/issues # Reusable AI-agent workflow, shared across repos. A caller repo triggers on issue_comment/issues
# and invokes this via: uses: gitea/agents/.gitea/workflows/agent.yml@main (explicit secret map, # and invokes this via: uses: gitea/agents/.gitea/workflows/agent.yml@main (secrets: inherit).
# see .gitea/workflows/ai-agent.yml for the stub every caller copies).
# The gate + steps run in the caller's event context (github.event.* / github.repository are the caller's). # The gate + steps run in the caller's event context (github.event.* / github.repository are the caller's).
on: on:
workflow_call: 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
# Secrets for caller-provided skills (.gitea/agent-skills/<name>/setup.sh). Their names are
# known only to the caller repo, but SECRETS_JSON below is toJSON(secrets) and can therefore
# only ever contain what this contract declares — so every such secret has to be listed here,
# optional, or the skill silently no-ops and the agent loses the capability without an error.
# These three are homelab's node1-ssh skill; add further caller-skill secrets alongside them.
SWARM_HOST:
required: false
SWARM_USER:
required: false
SSH_PRIV_KEY_B64:
required: false
# Pinned opencode version — used to install it and to key the CI cache below. # Pinned opencode version — used to install it and to key the CI cache below.
env: env:
@@ -62,10 +25,9 @@ jobs:
# Trusted author only, and only when a known agent is mentioned. This gate is the main # 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. # defense against malicious-issue prompt injection — do not loosen it.
if: > if: >
(github.event.comment == null && (github.event.issue.user.login == 'ffaerber' || github.event.issue.user.login == 'hermes')) || (github.event.comment == null && github.event.issue.user.login == 'ffaerber') ||
(github.event.comment != null && (github.event.comment != null &&
(github.event.comment.user.login == 'ffaerber' || (github.event.comment.user.login == 'ffaerber' ||
github.event.comment.user.login == 'hermes' ||
github.event.comment.user.login == 'pm' || github.event.comment.user.login == 'pm' ||
github.event.comment.user.login == 'junior' || github.event.comment.user.login == 'junior' ||
github.event.comment.user.login == 'senior' || github.event.comment.user.login == 'senior' ||
@@ -187,9 +149,7 @@ jobs:
SCRIPTS: ${{ runner.temp }}/agents-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 }}
# Accept common alternate names — empty XAI_API_KEY has bitten us when the XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
# 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 }} NAME: ${{ steps.prep.outputs.name }}
SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load SKILLS: ${{ steps.prep.outputs.skills }} # JSON array of skills this agent may load
run: bash "$SCRIPTS/install-opencode.sh" run: bash "$SCRIPTS/install-opencode.sh"
@@ -252,7 +212,7 @@ jobs:
id: run id: run
env: env:
SCRIPTS: ${{ runner.temp }}/agents-scripts SCRIPTS: ${{ runner.temp }}/agents-scripts
XAI_API_KEY: ${{ secrets.XAI_API_KEY || secrets.XAI_KEY || secrets.GROK_API_KEY || secrets.XAI_TOKEN }} XAI_API_KEY: ${{ secrets.XAI_API_KEY }}
# SELF_TOKEN = the RUNNING agent's OWN token (TOKEN_PM for @pm, TOKEN_OPS for @ops, …). # 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. # 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 # Powers the gitea-api / gitea-admin skills — each agent calls Gitea as itself. Every
@@ -261,14 +221,12 @@ 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 || '' }} 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 }} NAME: ${{ steps.prep.outputs.name }}
MODEL: ${{ steps.prep.outputs.model }} MODEL: ${{ steps.prep.outputs.model }}
FALLBACK: ${{ steps.prep.outputs.fallback }}
VISION: ${{ steps.prep.outputs.vision }} VISION: ${{ steps.prep.outputs.vision }}
MODE: ${{ steps.prep.outputs.mode }} MODE: ${{ steps.prep.outputs.mode }}
WORKMODE: ${{ steps.prep.outputs.workmode }} # build | discuss (devs consulted in-thread) WORKMODE: ${{ steps.prep.outputs.workmode }} # build | discuss (devs consulted in-thread)
HAS_IMAGES: ${{ steps.imgs.outputs.has_images }} HAS_IMAGES: ${{ steps.imgs.outputs.has_images }}
BRANCH: ${{ steps.prep.outputs.branch }} BRANCH: ${{ steps.prep.outputs.branch }}
AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the issue carries the `autopilot` label AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the issue carries the `autopilot` label
AUTOMERGE: ${{ steps.prep.outputs.automerge }} # 'false' when `human-merge` is also set
NUM: ${{ github.event.issue.number }} NUM: ${{ github.event.issue.number }}
TITLE: ${{ github.event.issue.title }} TITLE: ${{ github.event.issue.title }}
IBODY: ${{ github.event.issue.body }} IBODY: ${{ github.event.issue.body }}
@@ -306,7 +264,6 @@ jobs:
NEW: ${{ steps.prep.outputs.new }} NEW: ${{ steps.prep.outputs.new }}
IS_PR: ${{ github.event.issue.pull_request }} # set when this run is on a PR thread IS_PR: ${{ github.event.issue.pull_request }} # set when this run is on a PR thread
AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the origin issue carries `autopilot` AUTOPILOT: ${{ steps.prep.outputs.autopilot }} # 'true' when the origin issue carries `autopilot`
AUTOMERGE: ${{ steps.prep.outputs.automerge }} # 'false' when `human-merge` is also set
ISSNUM: ${{ steps.prep.outputs.issnum }} # origin issue number (resolved from branch on PR threads) ISSNUM: ${{ steps.prep.outputs.issnum }} # origin issue number (resolved from branch on PR threads)
run: bash "$SCRIPTS/publish.sh" run: bash "$SCRIPTS/publish.sh"
+1 -19
View File
@@ -12,22 +12,4 @@ on:
jobs: jobs:
agent: agent:
uses: gitea/agents/.gitea/workflows/agent.yml@main uses: gitea/agents/.gitea/workflows/agent.yml@main
# Explicit secret map (plus inherit) so XAI_API_KEY / OLLAMA_* always reach the reusable secrets: inherit
# 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 }}
# Secrets for this repo's own caller-provided skills (.gitea/agent-skills/). Unset in a repo
# that ships no such skill, which is harmless: the skill's setup.sh no-ops on an empty value.
SWARM_HOST: ${{ secrets.SWARM_HOST }}
SWARM_USER: ${{ secrets.SWARM_USER }}
SSH_PRIV_KEY_B64: ${{ secrets.SSH_PRIV_KEY_B64 }}
+7 -12
View File
@@ -1,34 +1,31 @@
{ {
"pm": { "pm": {
"model": "ollama-cloud/minimax-m3:cloud", "model": "ollama-cloud/minimax-m3:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": true, "vision": true,
"mode": "comment", "mode": "comment",
"skills": [ "skills": [
"gitea-api" "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. Falls back to xAI grok-4.5 if Ollama Cloud is unavailable/quota-exhausted." "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."
}, },
"junior": { "junior": {
"model": "ollama-cloud/kimi-k2.7-code:cloud", "model": "ollama-cloud/kimi-k2.7-code:cloud",
"fallback": "xai-oc/grok-4.3",
"vision": false, "vision": false,
"mode": "pr", "mode": "pr",
"skills": [], "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. Falls back to xAI grok-4.3 if Ollama Cloud fails." "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": { "senior": {
"model": "ollama-cloud/glm-5.2:cloud", "model": "ollama-cloud/glm-5.2:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": false, "vision": false,
"mode": "pr", "mode": "pr",
"skills": [ "skills": [
"gitea-api" "gitea-api"
], ],
"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." "desc": "Senior dev — complex, multi-file implementation (GLM-5.2 via Ollama Cloud, text-only)."
}, },
"lead": { "lead": {
"model": "xai-oc/grok-4.5", "model": "ollama-cloud/kimi-k3:cloud",
"vision": true, "vision": true,
"mode": "pr", "mode": "pr",
"skills": [ "skills": [
@@ -38,16 +35,15 @@
}, },
"qa": { "qa": {
"model": "ollama-cloud/minimax-m3:cloud", "model": "ollama-cloud/minimax-m3:cloud",
"fallback": "xai-oc/grok-4.5",
"vision": true, "vision": true,
"mode": "comment", "mode": "comment",
"skills": [ "skills": [
"gitea-api" "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. Falls back to xAI grok-4.5 if Ollama Cloud fails." "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."
}, },
"ops": { "ops": {
"model": "xai-oc/grok-4.5", "model": "xai/grok-4.5",
"vision": false, "vision": false,
"mode": "comment", "mode": "comment",
"skills": [ "skills": [
@@ -57,10 +53,9 @@
}, },
"intern": { "intern": {
"model": "ollama/ornith:35b", "model": "ollama/ornith:35b",
"fallback": "xai-oc/grok-4.3",
"vision": false, "vision": false,
"mode": "pr", "mode": "pr",
"skills": [], "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. Falls back to xAI grok-4.3 if local Ollama is down." "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."
} }
} }
@@ -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 IN_TOTAL=$(( INP + CR + CW )) # total input context processed
# Cost label: ollama / ollama-cloud models are SUBSCRIPTION-billed (GPU-time against the plan, no # 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. # $/token price exists), so a "$0.0000" there would be misleading — label it a subscription instead.
# Metered providers (xai-oc/…) get the real dollar cost opencode computed. # Metered providers (xai/…) get the real dollar cost opencode computed.
case "${MODEL:-}" in case "${MODEL:-}" in
ollama*|*"/ollama"*) COSTF="subscription" ;; ollama*|*"/ollama"*) COSTF="subscription" ;;
*) COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}') ;; *) COSTF=$(awk -v c="$COST" 'BEGIN{printf "$%.4f", c+0}') ;;
+7 -25
View File
@@ -5,14 +5,6 @@
# NAME SKILLS GITHUB_PATH HOME # NAME SKILLS GITHUB_PATH HOME
set -eu 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 # 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 # --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). # at once. Bump deliberately by changing this default (or set OPENCODE_VERSION in the step env).
@@ -47,31 +39,21 @@ 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"})) )}')
# Three OpenAI-compatible providers: local self-hosted ollama (ornith) + Ollama Cloud # 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 # (gemma4/kimi-k2.7-code/kimi-k3/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 # 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 # 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 # the cloud provider; `ollama/` prefix models go to the local provider; `xai/` prefix models go to
# to our xAI shim (OpenAI-compatible, https://api.x.ai/v1). No other built-in providers remain. # the xAI provider (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. # See issue #31.
AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json" AGENTS_JSON="${SCRIPTS:-$(dirname -- "$0")}/agents.json"
# Include primary `.model` AND optional `.fallback` so failover models are always registered CLOUD_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama-cloud/")) | sub("^ollama-cloud/";"")] | map({(.):{}}) | add // {}' "$AGENTS_JSON")
# in opencode provider maps (issue: Ollama Cloud quota → xAI). LOCAL_MODELS=$(jq -r '[.[] | .model | select(startswith("ollama/")) | sub("^ollama/";"")] | map({(.):{}}) | add // {"ornith:35b":{}}' "$AGENTS_JSON")
CLOUD_MODELS=$(jq -r '[.[] | (.model, .fallback) | select(type=="string" and startswith("ollama-cloud/")) | sub("^ollama-cloud/";"")] | unique | map({(.):{}}) | add // {}' "$AGENTS_JSON") XAI_MODELS=$(jq -r '[.[] | .model | select(startswith("xai/")) | sub("^xai/";"")] | 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" '{ 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: { provider: {
ollama: {npm:"@ai-sdk/openai-compatible", options:{baseURL:($url+"/v1")}, models:$local}, 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} xai: {npm:"@ai-sdk/openai-compatible", options:{baseURL:"https://api.x.ai/v1", apiKey:$xkey}, models:$xai}
}, },
permission: $perm, permission: $perm,
mcp: $mcp mcp: $mcp
+2 -3
View File
@@ -4,7 +4,7 @@
# Required env (provided by the workflow step): # Required env (provided by the workflow step):
# GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA # GT TOKEN_PM TOKEN_SENIOR TOKEN_JUNIOR TOKEN_LEAD TOKEN_QA
# NAME MODE NUM TITLE BRANCH NEW GITHUB_SERVER_URL GITHUB_REPOSITORY # NAME MODE NUM TITLE BRANCH NEW GITHUB_SERVER_URL GITHUB_REPOSITORY
# IS_PR AUTOPILOT AUTOMERGE ISSNUM (autopilot: @qa label-gated merge/halt + auto-trigger @qa on a fresh PR) # IS_PR AUTOPILOT ISSNUM (autopilot: @qa label-gated merge/halt + auto-trigger @qa on a fresh PR)
set +e # publish is best-effort: a grep-no-match / curl non-zero must NOT kill the step set +e # publish is best-effort: a grep-no-match / curl non-zero must NOT kill the step
# Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot. # Post/PR as the agent's OWN Gitea user when its token is configured; else the built-in bot.
case "$NAME" in case "$NAME" in
@@ -222,10 +222,9 @@ if [ "$MODE" != "pr" ]; then
fi fi
# --- @pm autopilot merge: @pm is the ONLY agent that merges, and ONLY under the autopilot label --- # --- @pm autopilot merge: @pm is the ONLY agent that merges, and ONLY under the autopilot label ---
# (and never when `human-merge` is also set — AUTOMERGE is false then).
# (@qa never merges — it approves and hands back here.) Merge with the PAT (TTOK), not the built-in # (@qa never merges — it approves and hands back here.) Merge with the PAT (TTOK), not the built-in
# token, so the push to main fires the deploy. TOKEN_PM must carry write:repository. # token, so the push to main fires the deploy. TOKEN_PM must carry write:repository.
if [ "$NAME" = "pm" ] && [ "$AUTOMERGE" = "true" ] && grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then if [ "$NAME" = "pm" ] && [ "$AUTOPILOT" = "true" ] && grep -qiE '^[[:space:]]*MERGE_PR[[:space:]]*$' /tmp/agent_out.md; then
PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w) PRS=$(resolve_prs); PRN=${PRS##* }; CNT=$(echo "$PRS" | wc -w)
if [ -z "$PRN" ]; then if [ -z "$PRN" ]; then
echo "MERGE_PR but no open PR found for issue #$ISSN" echo "MERGE_PR but no open PR found for issue #$ISSN"
+9 -13
View File
@@ -43,14 +43,12 @@ if [ -z "$name" ]; then
# Not an agent task (e.g. the gate's contains() matched "@internal"). Skip GRACEFULLY: emit # 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. # 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 "no known agent mentioned (word-boundary) — skipping run"
{ echo "name=none"; echo "model=none"; echo "fallback="; echo "vision=false"; echo "mode=skip"; echo "skills=[]"; { echo "name=none"; echo "model=none"; echo "vision=false"; echo "mode=skip"; echo "skills=[]";
echo "branch=main"; echo "new=false"; echo "autopilot=false"; echo "automerge=false"; echo "issnum=$NUM"; } >> "$GITHUB_OUTPUT" echo "branch=main"; echo "new=false"; echo "autopilot=false"; echo "issnum=$NUM"; } >> "$GITHUB_OUTPUT"
exit 0 exit 0
fi 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)
# 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) vision=$(jq -r --arg a "$name" '.[$a].vision' /tmp/agents.json)
mode=$(jq -r --arg a "$name" '.[$a].mode' /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). # Compact JSON array of the skills this agent may load (scopes permission.skill in install-opencode.sh).
@@ -72,8 +70,8 @@ if [ "$mode" = "pr" ] && [ -z "$IS_PR" ]; then
workmode=discuss workmode=discuss
fi fi
fi fi
echo "Routing to @$name (model=$model fallback=${fallback:-none} vision=$vision mode=$mode workmode=$workmode skills=$skills)" echo "Routing to @$name (model=$model 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" { echo "name=$name"; echo "model=$model"; 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. # Act as the agent's own Gitea user when its token is set; else the built-in bot.
case "$name" in case "$name" in
@@ -121,12 +119,10 @@ fi
issnum="$NUM" issnum="$NUM"
case "$IS_PR" in ?*) issnum=$(printf '%s' "$branch_ref" | sed -nE 's,^ai/issue-([0-9]+).*,\1,p');; esac case "$IS_PR" in ?*) issnum=$(printf '%s' "$branch_ref" | sed -nE 's,^ai/issue-([0-9]+).*,\1,p');; esac
[ -z "$issnum" ] && issnum="$NUM" [ -z "$issnum" ] && issnum="$NUM"
# `human-merge` alongside it keeps the hands-off build but leaves the merge to a human. autopilot=false
labels=$(curl -sS -H "Authorization: token $GT" "$API/issues/$issnum/labels" 2>/dev/null) if curl -sS -H "Authorization: token $GT" "$API/issues/$issnum/labels" 2>/dev/null \
autopilot=false; automerge=false | jq -e 'any(.[]?; .name=="autopilot")' >/dev/null 2>&1; then
if printf '%s' "$labels" | jq -e 'any(.[]?; .name=="autopilot")' >/dev/null 2>&1; then
autopilot=true autopilot=true
printf '%s' "$labels" | jq -e 'any(.[]?; .name=="human-merge")' >/dev/null 2>&1 || automerge=true
fi fi
echo "autopilot (autopilot label on #$issnum)=$autopilot automerge=$automerge" echo "autopilot (autopilot label on #$issnum)=$autopilot"
{ echo "autopilot=$autopilot"; echo "automerge=$automerge"; echo "issnum=$issnum"; } >> "$GITHUB_OUTPUT" { echo "autopilot=$autopilot"; echo "issnum=$issnum"; } >> "$GITHUB_OUTPUT"
+4 -29
View File
@@ -3,7 +3,7 @@
# plain-text reply (/tmp/agent_out.md) plus the raw event stream (/tmp/events.jsonl). # plain-text reply (/tmp/agent_out.md) plus the raw event stream (/tmp/events.jsonl).
# #
# Required env (provided by the workflow step): # Required env (provided by the workflow step):
# XAI_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT AUTOMERGE NUM TITLE # XAI_API_KEY SELF_TOKEN NAME MODEL VISION MODE HAS_IMAGES BRANCH AUTOPILOT NUM TITLE
# IBODY CMT # IBODY CMT
# FILES (the opencode -f image flags, from the imgs step output) # 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). # AUTOPILOT is 'true' when the issue carries the `autopilot` label (label-gated autopilot mode).
@@ -74,21 +74,14 @@ if [ "$MODE" = "comment" ]; then
auto-start any dev — the maintainer @mentions an agent on each sub-issue when ready." auto-start any dev — the maintainer @mentions an agent on each sub-issue when ready."
if [ "$AUTOPILOT" = "true" ]; then if [ "$AUTOPILOT" = "true" ]; then
ACTION="$ACTION ACTION="$ACTION
AUTOPILOT MODE IS ACTIVE (this issue carries the 'autopilot' label) — it changes only the AUTOPILOT MODE IS ACTIVE (this issue carries the 'autopilot' label) — it changes exactly TWO
phases below; everything else above is unchanged: things for you; everything else above is unchanged:
- PHASE 1: do NOT ask '@ffaerber ready to build?'. Present your SHORT plan AND end with a - PHASE 1: do NOT ask '@ffaerber ready to build?'. Present your SHORT plan AND end with a
'DELEGATE: @<dev>' line in the SAME reply. Only skip delegating (and ask @ffaerber) if the task 'DELEGATE: @<dev>' line in the SAME reply. Only skip delegating (and ask @ffaerber) if the task
is genuinely ambiguous or unsafe." is genuinely ambiguous or unsafe.
if [ "$AUTOMERGE" = "true" ]; then
ACTION="$ACTION
- PHASE 3: do NOT ask the human to merge. When @qa has approved, end your reply with EXACTLY - PHASE 3: do NOT ask the human to merge. When @qa has approved, end your reply with EXACTLY
'MERGE_PR' — the automation merges the PR and closes this issue. You are the ONLY agent that 'MERGE_PR' — the automation merges the PR and closes this issue. You are the ONLY agent that
merges, and only here." merges, and only here."
else
ACTION="$ACTION
- PHASE 3 is UNCHANGED: this issue also carries 'human-merge', so when @qa has approved, tell
@ffaerber the PR is ready to merge. Do NOT emit 'MERGE_PR'."
fi
fi fi
fi fi
if [ "$NAME" = "qa" ]; then if [ "$NAME" = "qa" ]; then
@@ -193,16 +186,6 @@ rc=1
# instance-wide. 20 min is far above any legitimate attempt. timeout SIGTERMs, then SIGKILLs 30s # 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. # later. rc=124 (timed out) is NOT retried — a hung backend stays hung; fail fast, free the runner.
AGENT_TIMEOUT="${AGENT_TIMEOUT:-1200}" 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 for attempt in 1 2 3; do
echo "opencode attempt $attempt/3 for @$NAME ($MODEL, timeout ${AGENT_TIMEOUT}s)" echo "opencode attempt $attempt/3 for @$NAME ($MODEL, timeout ${AGENT_TIMEOUT}s)"
rc=0 rc=0
@@ -213,14 +196,6 @@ for attempt in 1 2 3; do
echo "--- stderr (trace) ---"; cat /tmp/agent_err.log echo "--- stderr (trace) ---"; cat /tmp/agent_err.log
[ $rc -eq 0 ] && break [ $rc -eq 0 ] && break
if [ $rc -eq 124 ]; then echo "attempt timed out after ${AGENT_TIMEOUT}s — backend hung, not retrying"; break; fi 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 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 echo "transient error — backing off $((attempt*20))s"; sleep $((attempt * 20)); continue
fi fi
-2
View File
@@ -29,8 +29,6 @@ An issue labeled **`autopilot`** runs without the usual human checkpoints:
- **Kill switch:** remove the `autopilot` label at any time. The label is re-read fresh at the - **Kill switch:** remove the `autopilot` label at any time. The label is re-read fresh at the
start of every run, so the next agent turn reverts to normal human-approval behavior. `@qa` also start of every run, so the next agent turn reverts to normal human-approval behavior. `@qa` also
removes the label itself whenever it halts on a bug or a failed merge. removes the label itself whenever it halts on a bug or a failed merge.
- **`human-merge` label** (together with `autopilot`): everything above except the merge — after
`@qa` approves, `@pm` tells the creator the PR is ready and a human merges.
No label (the default) = today's behavior, unchanged. No label (the default) = today's behavior, unchanged.
## Branches & pull requests ## Branches & pull requests
+6 -12
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. | | `@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`. | | `@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). | | `@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` | `xai-oc/grok-4.5` | yes | pr | `gitea-api` | Tech lead — the hardest problems, architecture, and final calls. | | `@lead` | `ollama-cloud/kimi-k3:cloud` | 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. | | `@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` | `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. | | `@ops` | `xai/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`. | | `@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 The registry `.gitea/workflows/scripts/agents.json` is the source of truth for this mapping — if you
@@ -31,8 +31,7 @@ deploy-host SSH skill, live in the consuming repo under `.gitea/agent-skills/`
4. `@qa` reviews **on the PR** — either recommendations + `BOUNCE: @dev` (dev fixes → `@qa` 4. `@qa` reviews **on the PR** — either recommendations + `BOUNCE: @dev` (dev fixes → `@qa`
re-verifies, direct loop, max 3 rounds) or `APPROVE`. re-verifies, direct loop, max 3 rounds) or `APPROVE`.
5. On approval `@qa` posts the verdict **on the issue** → `@pm` tells the creator *"ready to merge"* 5. On approval `@qa` posts the verdict **on the issue** → `@pm` tells the creator *"ready to merge"*
and a **human merges** — or, with the `autopilot` label, `@pm` merges and closes the issue itself and a **human merges** — or, with the `autopilot` label, `@pm` merges and closes the issue itself.
(unless the issue also carries `human-merge`, which keeps the merge with a human).
`@pm` is the only agent that ever merges, and only under the `autopilot` label (its kill switch: `@pm` is the only agent that ever merges, and only under the `autopilot` label (its kill switch:
remove the label mid-flight and the next step reverts to human control). remove the label mid-flight and the next step reverts to human control).
@@ -88,12 +87,7 @@ on:
jobs: jobs:
agent: agent:
uses: gitea/agents/.gitea/workflows/agent.yml@main uses: gitea/agents/.gitea/workflows/agent.yml@main
# Explicit map, not `secrets: inherit`: inherit alone has left XAI_API_KEY empty on some Gitea secrets: inherit
# cross-owner calls. Copy the full list from .gitea/workflows/ai-agent.yml, which is the stub
# this file abbreviates -- a secret missing from the map does not reach the workflow at all.
secrets:
GITEA_TOKEN: ${{ secrets.GITEA_TOKEN }}
# ... and the OLLAMA_*, XAI_API_KEY, TOKEN_* and caller-skill entries, verbatim.
``` ```
That's the whole per-repo footprint, and it's the minimum a caller can be: the `on:` triggers must That's the whole per-repo footprint, and it's the minimum a caller can be: the `on:` triggers must
@@ -117,8 +111,8 @@ points `$SCRIPTS` at it. Keep the workflow and its scripts moving together on `m
| Secret | For | | Secret | For |
|--------|-----| |--------|-----|
| `XAI_API_KEY` | `@lead`, `@ops` (and any other agent switched to a `xai-oc/…` model) | | `XAI_API_KEY` | `@ops` (and any other agent switched to a `xai/…` model) |
| `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, glm-5.2, minimax-m3) | | `OLLAMA_URL`, `OLLAMA_CLOUD_API_KEY` | local ornith / Ollama Cloud (gemma4, kimi-k2.7-code, kimi-k3, 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_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`. | | `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`. |