Compare commits
15
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
0a89309ff1 | ||
|
|
cb11a6b2d1 | ||
|
|
81882ee3ec | ||
|
|
1987ba792d | ||
|
|
a1ff1b9881 | ||
|
|
d803ee7cf5 | ||
|
|
1d248bc675 | ||
|
|
1fd32f0ff6 | ||
|
|
e9fad6bfad | ||
|
|
729a14f8bd | ||
|
|
d3c8f116f1 | ||
|
|
658c10fc35 | ||
|
|
649cd4ea9b | ||
|
|
4ed96dcdf2 | ||
|
|
767a3341fd |
@@ -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]
|
||||||
|
|||||||
@@ -1,7 +1,9 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
# Build activity log (tool calls + reasoning) from the event stream into /tmp/activity_log.md.
|
# Build the activity log — the list of TOOL CALLS the agent made — into /tmp/activity_log.md.
|
||||||
# Only dev agents (mode=pr) get an activity-log comment — comment-only roles (pm/qa)
|
# Only dev agents (mode=pr) get an activity-log comment — comment-only roles (pm/qa) do no tool calls.
|
||||||
# do no tool calls, so a trail would be empty/noise.
|
# NOTE: we deliberately DO NOT include the agent's prose text parts. That final "here's what I did"
|
||||||
|
# text is just a restatement of the PR description (already published as the PR body), not a tool
|
||||||
|
# call — so it was noise in a section titled "tool calls". The log is the record of ACTIONS taken.
|
||||||
#
|
#
|
||||||
# Required env (provided by the workflow step): MODE
|
# Required env (provided by the workflow step): MODE
|
||||||
set -u
|
set -u
|
||||||
@@ -11,16 +13,12 @@ if [ "$MODE" != "pr" ]; then
|
|||||||
fi
|
fi
|
||||||
jq -r '
|
jq -r '
|
||||||
def trunc(n): if length > n then (.[0:n] + "…") else . end;
|
def trunc(n): if length > n then (.[0:n] + "…") else . end;
|
||||||
select(.type=="tool_use" or .type=="text") |
|
select(.type=="tool_use") |
|
||||||
if .type=="text" then
|
|
||||||
"💬 " + ((.part.text // "") | trunc(4000))
|
|
||||||
else
|
|
||||||
(.part.tool // "?") as $t |
|
(.part.tool // "?") as $t |
|
||||||
((.part.state.title // (.part.state.input | tojson | trunc(160)) // "")) as $title |
|
((.part.state.title // (.part.state.input | tojson | trunc(160)) // "")) as $title |
|
||||||
"🔧 **" + $t + "**: `" + ($title | trunc(240)) + "`"
|
"🔧 **" + $t + "**: `" + ($title | trunc(240)) + "`"
|
||||||
end
|
|
||||||
' /tmp/events.jsonl > /tmp/activity_log.md 2>/dev/null || true
|
' /tmp/events.jsonl > /tmp/activity_log.md 2>/dev/null || true
|
||||||
n=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0)
|
n=$(wc -l < /tmp/activity_log.md 2>/dev/null || echo 0)
|
||||||
echo "activity log: $n entries"
|
echo "activity log: $n tool calls"
|
||||||
[ "$n" -eq 0 ] && : > /tmp/activity_log.md
|
[ "$n" -eq 0 ] && : > /tmp/activity_log.md
|
||||||
head -3 /tmp/activity_log.md
|
head -3 /tmp/activity_log.md
|
||||||
|
|||||||
@@ -18,14 +18,25 @@ hdr=(-H "Authorization: token $TOK" -H "Content-Type: application/json")
|
|||||||
post() { curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \
|
post() { curl -sS -w 'comment -> HTTP %{http_code}\n' -X POST "${hdr[@]}" \
|
||||||
"$API/issues/$NUM/comments" -d "$(jq -nc --arg b "$1" '{body:$b}')"; }
|
"$API/issues/$NUM/comments" -d "$(jq -nc --arg b "$1" '{body:$b}')"; }
|
||||||
|
|
||||||
# drop machine-readable markers (DELEGATE / CLOSE_ISSUE / the BEGIN_SUBTASKS..END_SUBTASKS block)
|
# drop machine-readable markers: DELEGATE / CLOSE_ISSUE, and the BEGIN_SUBTASKS..END_SUBTASKS and
|
||||||
|
# BEGIN_PR_DESCRIPTION..END_PR_DESCRIPTION blocks (the PR description is published separately).
|
||||||
reply=$(awk '
|
reply=$(awk '
|
||||||
/^[[:space:]]*BEGIN_SUBTASKS/{s=1}
|
/^[[:space:]]*BEGIN_SUBTASKS/{s=1}
|
||||||
|
/^[[:space:]]*BEGIN_PR_DESCRIPTION/{p=1}
|
||||||
/^[[:space:]]*DELEGATE:[[:space:]]*@/{next}
|
/^[[:space:]]*DELEGATE:[[:space:]]*@/{next}
|
||||||
/^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next}
|
/^[[:space:]]*CLOSE_ISSUE[[:space:]]*$/{next}
|
||||||
s{ if(/^[[:space:]]*END_SUBTASKS/){s=0}; next }
|
s{ if(/^[[:space:]]*END_SUBTASKS/){s=0}; next }
|
||||||
|
p{ if(/^[[:space:]]*END_PR_DESCRIPTION/){p=0}; next }
|
||||||
{print}
|
{print}
|
||||||
' /tmp/agent_out.md 2>/dev/null)
|
' /tmp/agent_out.md 2>/dev/null)
|
||||||
|
# Strip a leading self-header the model sometimes emits ("🤖 **@pm**" on its own line) so we don't
|
||||||
|
# double it when we prepend our own. Removes a leading run of such header lines and blank lines.
|
||||||
|
reply=$(printf '%s' "$reply" | awk '
|
||||||
|
BEGIN{s=1}
|
||||||
|
s && /^[^A-Za-z0-9]*\*\*@[A-Za-z]+\*\*[[:space:]]*$/ {next}
|
||||||
|
s && /^[[:space:]]*$/ {next}
|
||||||
|
{s=0; print}
|
||||||
|
')
|
||||||
[ -z "$reply" ] && reply="_(Made changes without a text summary — see the diff below.)_"
|
[ -z "$reply" ] && reply="_(Made changes without a text summary — see the diff below.)_"
|
||||||
# Prefer the agent's clean delimited PR description; fall back to the whole reply.
|
# Prefer the agent's clean delimited PR description; fall back to the whole reply.
|
||||||
prdesc=$(awk '/BEGIN_PR_DESCRIPTION/{f=1;next} /END_PR_DESCRIPTION/{f=0} f' /tmp/agent_out.md)
|
prdesc=$(awk '/BEGIN_PR_DESCRIPTION/{f=1;next} /END_PR_DESCRIPTION/{f=0} f' /tmp/agent_out.md)
|
||||||
@@ -39,7 +50,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 +58,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 +88,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.
|
||||||
@@ -123,6 +137,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<details>\n<summary>🔧 activity — %s tool calls</summary>\n\n%s\n\n</details>' "$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.
|
||||||
@@ -130,7 +154,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
|
||||||
|
|
||||||
@@ -148,7 +172,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() {
|
||||||
@@ -160,17 +184,9 @@ 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: just link the PR — its body and the diff already carry the description, so we don't
|
||||||
prpost "$prnum" "$(printf '🤖 **@%s** — updated branch/PR:\n- %s\n\n%s' "$NAME" "$url" "$prdesc")"
|
# repeat the full write-up in the comment (the reasoning trail below shows what this run did).
|
||||||
fi
|
prpost "$prnum" "$(printf '🤖 **@%s** — pushed an update to the PR:\n- %s%s' "$NAME" "$url" "$activity")"
|
||||||
|
|
||||||
# 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
|
||||||
|
|||||||
@@ -55,7 +55,13 @@ if [ -n "$IS_PR" ]; then # comment on a PR -> resume its bra
|
|||||||
ref=$(curl -s -H "Authorization: token $GT" "$API/pulls/$NUM" | jq -r .head.ref)
|
ref=$(curl -s -H "Authorization: token $GT" "$API/pulls/$NUM" | jq -r .head.ref)
|
||||||
git fetch origin "$ref" && git checkout "$ref"
|
git fetch origin "$ref" && git checkout "$ref"
|
||||||
{ echo "branch=$ref"; echo "new=false"; } >> "$GITHUB_OUTPUT"
|
{ echo "branch=$ref"; echo "new=false"; } >> "$GITHUB_OUTPUT"
|
||||||
else # comment on an issue -> new branch
|
elif git ls-remote --exit-code --heads origin "ai/issue-$NUM" >/dev/null 2>&1; then
|
||||||
|
# comment on an issue whose branch ALREADY exists (a prior run / open PR) -> RESUME it, so new
|
||||||
|
# commits fast-forward onto the same branch and update its PR. Branching fresh from main here would
|
||||||
|
# be rejected on push as non-fast-forward and the new work would be silently lost (see issue #17).
|
||||||
|
git fetch origin "ai/issue-$NUM" && git checkout "ai/issue-$NUM"
|
||||||
|
{ echo "branch=ai/issue-$NUM"; echo "new=false"; } >> "$GITHUB_OUTPUT"
|
||||||
|
else # comment on an issue, no branch yet -> new branch
|
||||||
git checkout -b "ai/issue-$NUM"
|
git checkout -b "ai/issue-$NUM"
|
||||||
{ echo "branch=ai/issue-$NUM"; echo "new=true"; } >> "$GITHUB_OUTPUT"
|
{ echo "branch=ai/issue-$NUM"; echo "new=true"; } >> "$GITHUB_OUTPUT"
|
||||||
# For dev agents, publish the branch immediately and tell the maintainer where to watch.
|
# For dev agents, publish the branch immediately and tell the maintainer where to watch.
|
||||||
|
|||||||
@@ -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