Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 858f06c74d docs(priorities): refresh architect queue
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
2026-07-02 03:15:15 +00:00
62 changed files with 335 additions and 3767 deletions
-32
View File
@@ -1,32 +0,0 @@
# North Star
The direction the loop aligns every increment to. Depth lives in
[`internal/docs/THESIS.md`](../../internal/docs/THESIS.md); this is the short,
operative version the planner and builder read each run.
## Mission
Make building an **agent** as easy as building a **service**, on one runtime.
Go Micro is a holistic agent harness and service framework encapsulating the
lifecycle of **services → agents → workflows** — pluggable, progressive, and
AI-native by default.
## Right now — developer adoption
The framework's depth is strong; the **on-ramp** is the gap. Weight the developer
experience — a walkable first-agent tutorial, discoverable examples, docs
wayfinding, install friction, debugging, the 0→1 and 0→hero path — **at least as
highly as internal hardening**. A developer succeeding on their first agent
matters more right now than another conformance/observability/interop increment.
Do not let the queue fill entirely with internal depth work.
## Guardrails
- One concern per PR; small and reversible.
- The gate is green CI (`go build`, `go test`, `golangci-lint`, `make harness`),
not human review — keep the suite strong; the loop is only as good as its evaluator.
- **Off-limits without a human** (surface as notes, never auto-merge): breaking
public-API changes, brand/positioning/marketing copy, new dependencies,
architectural rewrites, product-default changes with broad behavioral impact.
- Stay on `claude/*` / `codex/*` branches; base PRs on `master`. See
[`CODEX.md`](../../CODEX.md) and [`internal/docs/CONTINUOUS_IMPROVEMENT.md`](../../internal/docs/CONTINUOUS_IMPROVEMENT.md).
-33
View File
@@ -1,33 +0,0 @@
# Priorities
The ranked work queue for the autonomous improvement loop. The
**architecture-review** pass (the *architect*) owns this file: each run it turns
the [roadmap](../../ROADMAP.md) plus an internal scan (gaps in the
services → agents → workflows lifecycle, API coherence, drift, tech debt, test and
DX friction) into a single ordered list — highest-value first — and links each
item to a tracking issue. The hourly **continuous-improvement** pass works the
**top item whose issue is still open**. So the architect decides *what*, and the
increment loop *builds* it.
**Reading / editing.** An item is done when its linked issue closes (the increment
that builds it adds `Closes #<issue>`). Roadmap phase (Now → Next → Later) is the
primary ordering; internal findings are interleaved by value, not kept in a
separate list. The human can reorder this list — or the issues — at any time to
redirect the loop; direction always wins.
**Off-limits to the loop** (the architect proposes these as notes, never as queue
items the loop can auto-merge): brand/positioning copy, breaking public-API
changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Constrain universe concierge notifications to one buyer side effect** ([#3682](https://github.com/micro/go-micro/issues/3682)) — PR #3684 closed the unfinished plan/delegate run gap, and the latest live provider-conformance failure has shifted to the universe harness: the checkout resume path is durable and reachable, but the concierge can notify both `order-1` and `buyer`. This is the highest-value Now-phase correctness item because the services → agents → workflows story must produce bounded, idempotent side effects under retry/resume before developers can trust the harness.
2. **Make the examples index a walkable first-agent map** ([#3671](https://github.com/micro/go-micro/issues/3671)) — recent README/docs work surfaced the first-agent on-ramp, and blog #33 sharpened the dogfooded loop story; the next adoption gap is example wayfinding. A newcomer should be able to move from first service to first agent to first workflow from the examples surface without stitching together README, website docs, and directories by hand.
3. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — this remains the next scoped Now-phase interop/conformance gap: it protects the A2A streaming promise developers see in the README and site by ensuring the non-native streaming fallback path receives the tool surface, while staying behind the immediate multi-agent correctness and on-ramp blockers.
4. **Parse multi-event A2A SSE fallback responses in the harness** ([#3662](https://github.com/micro/go-micro/issues/3662)) — once the fallback tool path succeeds, the harness must accept legitimate multi-event `message/stream` responses instead of concatenating valid SSE events into invalid JSON. This is a small CI-verifiable harness fix that keeps cross-provider streaming conformance focused on real gateway failures rather than parser brittleness.
5. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — the highest-value remaining Now-phase resilience gap after the live-provider side-effect fixes is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries and live-provider deadline tuning are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
6. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, and fixed provider-emitted text tool calls. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
7. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this is a forward interop investment, not a Now-phase blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue away from adoption, resilience, or observability. Keep it additive and opt-in while the AP2/FIDO work settles.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
-14
View File
@@ -1,14 +0,0 @@
<!--
The BUILDER prompt — go-micro's continuous-improvement increment. Editable
policy; the workflow prepends the agent @mention and substitutes __ISSUE__
before posting. Keep __ISSUE__ literal.
-->
Run one continuous-improvement increment per `internal/docs/CONTINUOUS_IMPROVEMENT.md`, aligned to the North Star in `.github/loop/NORTH_STAR.md` (the services → agents → workflows lifecycle, with developer adoption as the current goal).
PICK THE WORK FROM THE QUEUE: read `.github/loop/PRIORITIES.md` and take the highest-ranked item whose linked issue is still OPEN — that is your task, and its issue number is the one you close. If `PRIORITIES.md` is missing or every listed item's issue is already closed, fall back to the single highest-value roadmap / open-issue / improvement-radar item yourself.
Implement it, and VERIFY `go build ./...`, `go test ./...`, and `golangci-lint run ./...`.
Open the PR YOURSELF from the shell — do NOT use the make_pr tool (in this environment it only records metadata and never creates a PR). Create a uniquely-named branch under the `codex/` prefix: `git switch -c codex/increment-__ISSUE__`, then `git push -u origin codex/increment-__ISSUE__`, then `gh pr create --base master --label codex --title "<title>" --body "<body; include 'Closes #<the priority issue you built>' so it leaves the queue, and 'Closes #__ISSUE__' for this run's tracker>"`. Finally enable auto-merge so GitHub merges it once CI is green: `gh pr merge --squash --auto --delete-branch`.
One concern per PR. Stay out of breaking public API and brand/positioning copy — surface those as notes for the human instead.
-14
View File
@@ -1,14 +0,0 @@
<!--
The COHERENCE prompt — go-micro's DevRel pass (public-surface coherence +
CHANGELOG upkeep + changelog blog). Editable policy; the workflow prepends the
agent @mention and substitutes __ISSUE__ before posting. Keep __ISSUE__ literal.
-->
Act as DevRel for go-micro. Do these, in order.
COHERENCE AUDIT. Audit the public surface — `README.md`, `internal/website/` (landing `index.html` + `docs/`), and the blog under `internal/website/blog/` — for coherence with the North Star in `.github/loop/NORTH_STAR.md` (an agent harness and service framework; the services → agents → workflows lifecycle). Look for: places where README / website / docs contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recently merged PRs); whether the README is crisp and leads with the harness positioning; and one to three genuinely blog-worthy items from recently shipped work.
CHANGELOG UPKEEP (safe factual task — goes in the auto-merged PR). Keep `CHANGELOG.md` living, in Keep-a-Changelog format with newest content at the top under `## [Unreleased]`. Enumerate PRs merged to master since the last update (`gh pr list --state merged --base master --limit 60 --json number,title,mergedAt,labels`) and add a concise, user-facing entry for each genuine change not yet recorded under the right `### Added` / `### Changed` / `### Fixed` / `### Documentation` subheading — SKIP internal loop/CI/priorities-refresh churn. If a new `vX.Y.Z` tag was cut since the last run (`git fetch --tags --force`), rename `## [Unreleased]` to `## [X.Y.Z] - <Month YYYY>` and open a fresh empty `## [Unreleased]` above it. Do not invent entries.
CHANGELOG BLOG POST (blog voice — do NOT auto-merge). If, and only if, enough user-facing work has accumulated since the last changelog post to be worth reading (roughly a week's worth; not a near-empty post every day), draft a short "What's new in Go Micro" post as the next-numbered file in `internal/website/blog/`, mirroring the latest post's frontmatter and prev-nav, and add an entry at the top of `internal/website/blog/index.html`. Base it strictly on the CHANGELOG.
THEN: (A) post a findings report as a comment on this issue (#__ISSUE__) — what's aligned, what drifted, what you fixed, the CHANGELOG entries added, and whether you drafted a blog post (and why/why not). (B) Open ONE auto-merging PR for the SAFE factual work only — coherence/crispness fixes AND the CHANGELOG update (NOT brand/positioning rewrites, NOT the blog post): `git switch -c codex/coherence-__ISSUE__`, `git push -u origin codex/coherence-__ISSUE__`, `gh pr create --base master --label codex --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`. (C) If you drafted a changelog blog post, open it as a SEPARATE PR (`codex/coherence-blog-__ISSUE__`, title prefixed `blog:`) and do NOT enable auto-merge — leave it for the human. Same for any brand/positioning copy. Do not use the make_pr tool.
-16
View File
@@ -1,16 +0,0 @@
<!--
The PLANNER prompt — go-micro's "architect / founder lens". Editable policy;
the workflow prepends the agent @mention and substitutes __ISSUE__ (this run's
tracking issue) before posting. Keep __ISSUE__ literal.
-->
Act as the architect — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together, what is in flight and what just merged, what to prioritize next, and what is missing or has drifted.
(1) TRACK STATE — scan recently merged PRs and open `codex` PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work).
(2) ASSESS against the North Star in `.github/loop/NORTH_STAR.md` — lead with its Mission (*make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON: the blog under `internal/website/blog`, the `README`, and the website (read these, don't rely on the North Star alone), then `ROADMAP.md` (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? CURRENT GOAL — developer adoption: weight the on-ramp (walkable first-agent tutorial, discoverable examples, docs wayfinding, install friction, debugging, 0→1 and 0→hero) at least as highly as internal hardening; do not let the queue fill entirely with internal depth work. Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry) and the dev inner loop (scaffold → run → chat → inspect → deploy). Flag drift in either direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog.
(3) MAINTAIN THE QUEUE in `.github/loop/PRIORITIES.md` — a SINGLE ordered list, highest-value first, each item linking a scoped, CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap with no issue, file one: `gh issue create --label codex --label enhancement --title "<scoped task>" --body "<goal, scope, acceptance criteria>"`.
OUTPUT: post a concise assessment as a comment on this issue (#__ISSUE__) — what shipped, what's in flight, the top risks/gaps, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for `.github/loop/PRIORITIES.md`: `git switch -c codex/planner-__ISSUE__`, `git push -u origin codex/planner-__ISSUE__`, `gh pr create --base master --label codex --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`. If the queue is already accurate, just close this issue (`gh issue close __ISSUE__`).
Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Open the PR yourself from the shell with `gh`; do not use the make_pr tool (it is a no-op stub).
-14
View File
@@ -1,14 +0,0 @@
<!--
The TRIAGE prompt — go-micro's harness-failure feedback path. Editable policy;
the workflow prepends the agent @mention and substitutes __ISSUE__ (this
tracking issue) and __RUNURL__ (the failed run) before posting. Keep both literal.
-->
Triage the failed provider-conformance harness run at __RUNURL__.
Read the logs and root-cause each distinct failure. DEDUPE against open issues — if a failure matches an existing issue, comment "recurred" there instead of filing a duplicate.
For each genuine, self-contained defect, file a scoped issue (`gh issue create --label codex --label enhancement --title "<scoped fix>" --body "<root cause, where, acceptance criteria>"`) so the increment loop builds it and the next harness run verifies it.
IGNORE transient flakes — live-model latency, provider outages, rate limits, timeouts with no code cause. Anything needing a breaking or architectural change: file it as `needs-human` and describe it, rather than auto-queuing it as a routine fix.
Close this issue (`gh issue close __ISSUE__`) when triage is done. Open any PR yourself from the shell with `gh`; do not use the make_pr tool.
+51
View File
@@ -0,0 +1,51 @@
name: "Loop: Architect (Planner)"
# Continuous high-altitude oversight of the whole framework and harness — the
# "founder lens" of the autonomous loop (internal/docs/CONTINUOUS_IMPROVEMENT.md).
# Where DevRel watches the public story and the increment loop ships code, the
# architect watches the SYSTEM and runs alongside the builders: it tracks what is
# in flight and what just merged, keeps the roadmap priorities live, and judges
# cohesion (harness <-> framework <-> dev UX), missing pieces, and realignment.
#
# Its OUTPUT is the ranked queue in internal/docs/PRIORITIES.md plus an assessment
# — NOT large refactors. Breaking public-API and architectural changes stay with
# the human (see CONTINUOUS_IMPROVEMENT.md).
#
# Runs hourly, offset before the increment loop (:29) so it re-prioritizes and
# THEN the loop builds the new top of the queue. Opens a fresh issue and
# dispatches Codex via CODEX_TRIGGER_TOKEN.
on:
workflow_dispatch: {}
schedule:
- cron: "59 * * * *" # hourly at :59, just before the :29 increment run (tunable)
permissions:
issues: write
concurrency:
group: architecture-review
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open an architecture review issue and dispatch Codex
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Architecture review #$RUN_NUMBER" \
--body "Continuous architecture / harness oversight against the North Star in internal/docs/THESIS.md. Output: a re-ranked internal/docs/PRIORITIES.md (only if it changed) plus an assessment.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching Codex (Architect)."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Act as the architect — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together cohesively, what is in flight and what just merged, what to prioritize next on the roadmap, and what is missing or has drifted. Each run: (1) TRACK STATE — scan recently merged PRs and open codex PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) ASSESS against the North Star in internal/docs/THESIS.md — lead with its Mission (*the problem we solve: make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON it names (the blog under internal/website/blog, the README, and the website — read these, don't rely on THESIS.md alone), then ROADMAP.md (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? CURRENT GOAL — DEVELOPER ADOPTION: the framework's depth is strong but its ON-RAMP is the gap, and the strategic priority right now is developer adoption / reviving real usage. Weight the developer on-ramp and DX — a walkable first-agent tutorial, discoverable examples, docs wayfinding/nav, install friction, debugging, the 0→1 and 0→hero experience — AT LEAST as highly as internal hardening. A developer succeeding on their first agent matters more right now than another conformance/observability/interop increment; do NOT let the queue fill entirely with internal depth work — keep open adoption/on-ramp items near the top. Look at coherence and seams across the core packages (agent, ai, flow, gateway/mcp, gateway/a2a, model, server, store, registry), the dev inner loop (scaffold → run → chat → inspect → deploy), missing pieces, duplication/drift, and realignment. Flag drift in EITHER direction: work drifting from the mission, or the North Star/website drifting from the lived story in the blog (which needs re-grounding in the canon). (3) MAINTAIN THE QUEUE in internal/docs/PRIORITIES.md — a SINGLE ordered list, highest-value first, each item linking a scoped CI-verifiable issue (#N); roadmap phase is the primary ordering, internal findings (cohesion gaps, DX friction, missing pieces) interleaved by value. For any prioritized gap that has no issue yet, file one: \`gh issue create --label codex --label enhancement --title \"<scoped task>\" --body \"<goal, scope, acceptance criteria>\"\`. OUTPUT: post a concise assessment as a comment on this issue (#$ISSUE_NUM) — what shipped, what's in flight, the top risks/gaps/missing pieces, and the reasoning behind the ranking. If the ranking actually changed, open ONE PR for PRIORITIES.md: \`git switch -c codex/architect-$ISSUE_NUM\`, \`git push -u origin codex/architect-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate and correctly ranked, do NOT open a PR — just close this issue (\`gh issue close $ISSUE_NUM\`). Do NOT make breaking public-API or architectural changes yourself — surface those in the assessment as notes for the human, never as auto-merged changes. Do not use the make_pr tool (it is a no-op stub)."
+38 -33
View File
@@ -1,60 +1,65 @@
name: "Loop: Builder"
name: "Loop: Builder (Generator)"
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
# cadence it opens a fresh tracking issue and posts the instruction in
# .github/loop/prompts/builder.md to the agent (@codex).
# Durable backbone for the autonomous improvement loop
# (see internal/docs/CONTINUOUS_IMPROVEMENT.md).
#
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
# change what this role does by editing the prompt, not this YAML. A FRESH
# issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch.
# A Claude Max subscription provides no API key for CI, so the loop is driven by
# Codex rather than Claude Code: on a cadence this opens a fresh tracking issue and
# posts an @codex instruction on it, and Codex runs one improvement increment, opens
# a PR (git push + gh pr create — the make_pr tool is a no-op stub), and enables
# GitHub auto-merge (gh pr merge --auto) so the PR lands once the required CI checks
# pass. No separate merge sweep — branch protection + native auto-merge is the gate.
# (See the per-issue rationale below.)
#
# Gated on CODEX_TRIGGER_TOKEN: the agent ignores @mentions from the
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
# Codex does NOT respond to comments authored by the github-actions bot, so the
# dispatch is GATED on a CODEX_TRIGGER_TOKEN secret (a PAT for a user account Codex
# follows). Until that secret is set the workflow runs but no-ops — this avoids
# piling up @codex comments that Codex silently ignores. The moment the secret is
# added the loop activates with no further change.
#
# Each run opens a FRESH issue and dispatches Codex there, rather than re-commenting
# on one tracker issue. Codex derives its PR branch name from the triggering issue's
# context, so repeated dispatches on a single issue all collapse onto one branch name
# (codex/github-mention-<that-issue-slug>) — the first increment opens a PR, the rest
# collide on the occupied branch and silently fail to open one. A unique issue per
# run gives each increment its own branch and a clean PR. The dispatch asks Codex to
# "Closes #<issue>" so each tracking issue auto-closes when its PR merges.
on:
workflow_dispatch: {}
schedule:
- cron: "29 * * * *"
- cron: "29 * * * *" # hourly, off-minute (tune as needed)
permissions:
issues: write
concurrency:
group: loop-builder
group: continuous-improvement
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch builder
- name: Open a fresh increment issue and dispatch Codex
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (the agent ignores bot @mentions)."
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping dispatch."
echo "Codex ignores comments from the github-actions bot, so posting now"
echo "would only create noise. Add a CODEX_TRIGGER_TOKEN secret (a PAT for"
echo "a user account Codex follows) to activate the loop."
exit 0
fi
PROMPT=".github/loop/prompts/builder.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
# A unique issue per run → unique codex/ branch → no collisions.
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: build increment #$RUN_NUMBER" \
--body "Autonomous builder pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
--title "Continuous improvement increment #$RUN_NUMBER" \
--body "Autonomous continuous-improvement increment. North Star: internal/docs/THESIS.md; charter: internal/docs/CONTINUOUS_IMPROVEMENT.md. Tracker: #3024.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching builder."
# The prompt file is the policy; strip its editorial <!-- --> header and
# substitute the tracking issue number (__ISSUE__) at runtime.
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
echo "Opened issue #$ISSUE_NUM — dispatching Codex."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Run one continuous-improvement increment per internal/docs/CONTINUOUS_IMPROVEMENT.md, aligned to the North Star in internal/docs/THESIS.md (the holistic services → agents → workflows lifecycle). PICK THE WORK FROM THE QUEUE: read internal/docs/PRIORITIES.md and take the highest-ranked item whose linked issue is still OPEN — that is your task, and its issue number is the one you close. (If PRIORITIES.md is missing or every listed item's issue is already closed, fall back to picking the single highest-value roadmap/issue/improvement-radar item yourself.) Implement it, and verify \`go build ./...\`, \`go test ./...\`, and \`golangci-lint run ./...\`. Then open the PR YOURSELF from the shell — do NOT use the make_pr tool (in this environment it only records metadata and never creates a PR). Create a uniquely-named branch under the codex/ prefix and open the PR from it: \`git switch -c codex/increment-$ISSUE_NUM\`, then \`git push -u origin codex/increment-$ISSUE_NUM\`, then \`gh pr create --base master --label codex --title \"<title>\" --body \"<body; include 'Closes #<the priority issue you built>' so it leaves the queue, and 'Closes #$ISSUE_NUM' for this run's tracker>\"\`. Finally enable auto-merge so GitHub merges it once CI is green: \`gh pr merge --squash --auto --delete-branch\`. The gh CLI is installed and authenticated and origin points to $REPO. One concern per PR; stay out of brand/positioning copy and breaking public API."
-60
View File
@@ -1,60 +0,0 @@
name: "Loop: Coherence"
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
# cadence it opens a fresh tracking issue and posts the instruction in
# .github/loop/prompts/coherence.md to the agent (@codex).
#
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
# change what this role does by editing the prompt, not this YAML. A FRESH
# issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch.
#
# Gated on CODEX_TRIGGER_TOKEN: the agent ignores @mentions from the
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
on:
workflow_dispatch: {}
schedule:
- cron: "0 7 * * *"
permissions:
issues: write
concurrency:
group: loop-coherence
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch coherence
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (the agent ignores bot @mentions)."
exit 0
fi
PROMPT=".github/loop/prompts/coherence.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: coherence review #$RUN_NUMBER" \
--body "Autonomous coherence pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching coherence."
# The prompt file is the policy; strip its editorial <!-- --> header and
# substitute the tracking issue number (__ISSUE__) at runtime.
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
+61
View File
@@ -0,0 +1,61 @@
name: "Loop: DevRel"
# Daily higher-altitude coherence pass over the PUBLIC surface — README,
# website (landing + docs), and blog — part of the autonomous loop
# (internal/docs/CONTINUOUS_IMPROVEMENT.md). The hourly increment loop ships
# code; this keeps the story coherent: docs/website aligned, README crisp, and
# a steady supply of things worth blogging about.
#
# It also keeps the CHANGELOG living: each run reconciles the `[Unreleased]`
# section of CHANGELOG.md against what actually merged (rolling it into a dated
# version heading whenever a new tag was cut), and — when enough has shipped —
# drafts a "what's new" changelog blog post narrating it.
#
# Like the increment loop it opens a fresh issue and dispatches Codex via
# CODEX_TRIGGER_TOKEN (Codex ignores Actions-bot comments). Autonomy boundary:
# SAFE factual-alignment and crispness fixes — including CHANGELOG.md upkeep —
# auto-merge; brand/positioning copy and the changelog blog post are opened as a
# PR but left for the human to review/merge (blog voice stays with the human).
on:
workflow_dispatch: {}
schedule:
- cron: "0 7 * * *" # daily, 07:00 UTC (tunable)
permissions:
issues: write
concurrency:
group: devrel-review
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open a DevRel review issue and dispatch Codex
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "DevRel coherence review #$RUN_NUMBER" \
--body "Daily DevRel / coherence pass over README, website (landing + docs), and the blog, plus CHANGELOG.md upkeep and a changelog blog post. North Star: internal/docs/THESIS.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching Codex (DevRel)."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Act as DevRel for go-micro. Do FOUR things this run.
COHERENCE AUDIT. Audit the PUBLIC surface — \`README.md\`, \`internal/website/\` (landing \`index.html\` + \`docs/\`), and the blog under \`internal/website/blog/\` — for coherence with the North Star in internal/docs/THESIS.md (an agent harness and service framework; the services → agents → workflows lifecycle). Look for: (1) places where README / website / docs contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recent merged PRs); (2) whether the README is crisp and leads with the harness positioning; (3) one to three genuinely blog-worthy items from recently shipped work.
CHANGELOG UPKEEP (this is a SAFE factual task — it goes in the auto-merged PR). Keep CHANGELOG.md living, in [Keep a Changelog](https://keepachangelog.com/) format with the newest content at the top under \`## [Unreleased]\`. (a) Enumerate PRs merged to master since the last CHANGELOG update — \`gh pr list --repo $REPO --state merged --base master --limit 60 --json number,title,mergedAt,labels\` — and compare against what CHANGELOG.md already lists. (b) For each genuinely user-facing change not yet recorded (new capability, behavior/API change, notable fix — SKIP purely internal loop/CI/priorities-refresh churn), add a concise entry under the right \`### Added\` / \`### Changed\` / \`### Fixed\` / \`### Documentation\` subheading of \`## [Unreleased]\`, phrased for a user (what it does, which package), not a commit subject. (c) If a new \`v6.MINOR.PATCH\` tag has been cut since the last run (\`git fetch --tags --force\`; compare the newest \`v6.*\` tag to the versions already in CHANGELOG.md), RENAME the current \`## [Unreleased]\` heading to \`## [MINOR.PATCH] - <Month YYYY>\` for that tag and open a fresh empty \`## [Unreleased]\` above it. Keep it accurate — do not invent entries; if nothing user-facing merged, leave [Unreleased] as-is.
CHANGELOG BLOG POST (blog voice — open a PR but do NOT auto-merge; leave it for the human). If — and only if — enough user-facing work has accumulated since the last changelog post to be worth reading (a meaningful batch, roughly a week's worth; do NOT post an almost-empty update every day), draft a short 'What's new in Go Micro' post that narrates what shipped in plain language (grouped by theme, linking the docs/examples, closing with install/upgrade). Create it as the next-numbered file in \`internal/website/blog/\` (find the highest N, use N+1), mirroring the frontmatter (layout/title/permalink/description) and the post-nav 'previous post' link of the latest existing post, and add an entry at the TOP of \`internal/website/blog/index.html\`. Base it strictly on the CHANGELOG — no speculation.
THEN do all of these: (A) post a concise findings report as a comment on this issue (#$ISSUE_NUM) — what is aligned, what drifted, what you fixed, the CHANGELOG entries you added, whether you drafted a changelog post (and why / why not), and any other blog ideas. (B) Open ONE auto-merging PR for the SAFE factual work only — coherence/crispness fixes AND the CHANGELOG.md update (NOT brand/marketing/positioning rewrites, NOT the blog post): \`git switch -c codex/devrel-$ISSUE_NUM\`, \`git push -u origin codex/devrel-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, including 'Closes #$ISSUE_NUM'>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. (C) If you drafted a changelog blog post, open it as a SEPARATE PR on its own branch (\`codex/devrel-blog-$ISSUE_NUM\`) with a title prefixed 'blog:' and do NOT enable auto-merge — leave it open for the human to review and merge. Do the same (separate, non-auto-merged PR or just a report note) for any brand/positioning copy. Do not use the make_pr tool (it is a no-op stub). If you touch code, verify go build/test/golangci-lint. Stay out of breaking public-API changes."
-60
View File
@@ -1,60 +0,0 @@
name: "Loop: Planner"
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
# cadence it opens a fresh tracking issue and posts the instruction in
# .github/loop/prompts/planner.md to the agent (@codex).
#
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
# change what this role does by editing the prompt, not this YAML. A FRESH
# issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch.
#
# Gated on CODEX_TRIGGER_TOKEN: the agent ignores @mentions from the
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
on:
workflow_dispatch: {}
schedule:
- cron: "59 * * * *"
permissions:
issues: write
concurrency:
group: loop-planner
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch planner
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (the agent ignores bot @mentions)."
exit 0
fi
PROMPT=".github/loop/prompts/planner.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: planning review #$RUN_NUMBER" \
--body "Autonomous planner pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching planner."
# The prompt file is the policy; strip its editorial <!-- --> header and
# substitute the tracking issue number (__ISSUE__) at runtime.
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
+28 -28
View File
@@ -1,17 +1,19 @@
name: "Loop: Release"
name: "Loop: Release (daily patch)"
# Generated by `micro loop init`. Cuts the next PATCH tag
# (vMAJOR.MINOR.PATCH+1) when the default branch has new commits
# since the latest such tag, and pushes it with a PAT (CODEX_TRIGGER_TOKEN) so any
# tag-triggered release workflow fires. Minor/major bumps stay with a human.
# Keeps the installable framework tracking the loop's daily improvements. Once a
# day, if master has new commits since the latest v6 tag, this cuts the next
# PATCH release (v6.MINOR.PATCH+1) and pushes the tag — which triggers the
# existing goreleaser workflow (release.yml, tag-triggered) to build the release,
# binaries, and images.
#
# The tag MUST be pushed with a PAT, not the default GITHUB_TOKEN: a tag pushed
# by GITHUB_TOKEN does not trigger other workflows (Actions blocks that recursion).
# The tag is pushed with a PAT (CODEX_TRIGGER_TOKEN), NOT the default GITHUB_TOKEN:
# a tag pushed by GITHUB_TOKEN would not trigger release.yml (Actions blocks that
# recursion). Minor/major bumps stay with the human (notable / breaking releases).
on:
workflow_dispatch: {}
schedule:
- cron: "0 23 * * *"
- cron: "0 23 * * *" # daily 23:00 UTC — captures the day's merges (tunable)
permissions:
contents: read
@@ -27,41 +29,39 @@ jobs:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history + all tags
# Do NOT persist the default GITHUB_TOKEN as a git credential: it would
# be sent on the PAT push below and override it, so the tag push would
# authenticate as github-actions[bot] and 403. Letting the PAT in the
# push URL be the only credential is the whole point.
persist-credentials: false
- name: Cut the next patch tag if there are new commits
- name: Cut the next patch release if there are new commits
env:
RELEASE_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN }}
REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_TOKEN" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping."
echo "A tag pushed by the default GITHUB_TOKEN would not trigger the"
echo "goreleaser workflow, so a user PAT is required to cut releases."
exit 0
fi
git fetch --tags --force
LATEST=$(git tag --list 'v*.*.*' --sort=-v:refname | head -1)
LATEST=$(git tag --list 'v6.*.*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then
echo "no vMAJOR.MINOR.PATCH tag found — aborting so nothing weird gets tagged."
echo "no v6.x.x tag found — aborting so nothing weird gets tagged."
exit 1
fi
echo "latest tag: $LATEST"
echo "latest release tag: $LATEST"
COUNT=$(git rev-list --count "$LATEST"..HEAD)
echo "commits since $LATEST: $COUNT"
echo "commits on HEAD since $LATEST: $COUNT"
if [ "$COUNT" -eq 0 ]; then
echo "no new commits since $LATEST — no release."
echo "no new commits since $LATEST — no release today."
exit 0
fi
ver="${LATEST#v}"
major="${ver%%.*}"
rest="${ver#*.}"
minor="${rest%%.*}"
patch="${rest#*.}"
# Bump the patch: v6.MINOR.PATCH -> v6.MINOR.(PATCH+1)
ver="${LATEST#v}" # 6.3.10
major="${ver%%.*}" # 6
rest="${ver#*.}" # 3.10
minor="${rest%%.*}" # 3
patch="${rest#*.}" # 10
case "$major.$minor.$patch" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
@@ -69,8 +69,8 @@ jobs:
NEXT="v${major}.${minor}.$((patch + 1))"
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
git config user.name "loop release bot"
git config user.email "noreply@users.noreply.github.com"
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
git config user.name "go-micro release bot"
git config user.email "noreply@go-micro.dev"
git tag -a "$NEXT" -m "Release $NEXT — automated daily patch ($COUNT commits since $LATEST)"
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
echo "Pushed $NEXT."
echo "Pushed $NEXT. goreleaser (release.yml) will build and publish it."
+32 -29
View File
@@ -1,10 +1,19 @@
name: "Loop: Triage"
name: "Loop: Triage (Evaluator feedback)"
# Generated by `micro loop init`. The feedback path of the evaluator: when the
# CI workflow ("Harness (E2E)") fails on a non-PR run, dispatch the agent
# (@codex) with the instruction in .github/loop/prompts/triage.md
# to root-cause the failure and file scoped fix issues back into the queue — so
# failures become fixes with no human in the middle. Gated on CODEX_TRIGGER_TOKEN.
# Closes the autonomous loop's feedback path: when the live provider-conformance
# harness fails, dispatch Codex to TRIAGE the failing run and file scoped, deduped
# issues that the hourly increment loop then fixes — no human in the middle. It
# only triages the scheduled/manual live run (not every push/PR mock run), dedupes
# against open issues so hourly repeats don't spam, ignores transient flakes, and
# ESCALATES anything needing a breaking/architectural change as needs-human rather
# than auto-building it.
#
# Gated on CODEX_TRIGGER_TOKEN like the rest of the loop (Codex ignores comments
# authored by the github-actions bot).
#
# Note: Codex is serial, so this competes with the hourly increment + architect
# dispatches for the single task slot. If it saturates, lower the harness cadence
# or gate this to a slower schedule.
on:
workflow_run:
@@ -15,42 +24,36 @@ permissions:
issues: write
concurrency:
group: loop-triage
group: harness-triage
cancel-in-progress: false
jobs:
triage:
# Only real failures on branch pushes/schedules — not PR-run failures, which
# the PR author already sees.
if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request' }}
runs-on: ubuntu-latest
# Only when the harness actually failed, and only for the scheduled or manual
# live run — never the per-push/PR mock run.
if: github.event.workflow_run.conclusion == 'failure' && (github.event.workflow_run.event == 'schedule' || github.event.workflow_run.event == 'workflow_dispatch')
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch triage
- name: Open a triage issue and dispatch Codex
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping."
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
exit 0
fi
PROMPT=".github/loop/prompts/triage.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
# Ensure the escalation label exists (idempotent).
gh label create needs-human --repo "$REPO" --color FBCA04 \
--description "Requires a human/architect decision (breaking or architectural)" --force || true
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: triage failed run $RUN_ID" \
--body "The 'Harness (E2E)' workflow failed: $RUN_URL")
--title "Harness failure triage: run $RUN_ID" \
--body "Automated triage of a failed live provider-conformance harness run: $RUN_URL")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching triage."
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" -e "s#__RUNURL__#$RUN_URL#g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
echo "Opened triage issue #$ISSUE_NUM — dispatching Codex."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Act as failure triage for the autonomous loop. The live provider-conformance harness failed: $RUN_URL (run id $RUN_ID). Do this, and do NOT change code or open a PR — triage only: (1) Read the failing logs (\`gh run view $RUN_ID --repo $REPO --log-failed\`) and the provider-conformance artifact/summary. (2) Root-cause each DISTINCT failure. (3) DEDUPE against existing work — list open issues (\`gh issue list --repo $REPO --label codex --state open --limit 100\`); if a matching issue already exists for a failure, add a one-line 'recurred in $RUN_URL' comment to it and do NOT open a duplicate. (4) For each genuine, self-contained, CI-verifiable defect that is NOT already tracked, open a scoped issue: \`gh issue create --repo $REPO --label codex --label enhancement --title \"<scoped task>\" --body \"<root cause, scope, acceptance criteria, and the failing run link>\"\` — the hourly increment loop will build it. (5) If a failure is transient/flaky and not a code defect (e.g. a live-model latency timeout or provider outage), note it in a comment and file NOTHING. (6) If a real fix would require a breaking public-API change or an architectural change, do NOT file it as an auto-buildable task — open an issue labeled \`needs-human\` describing it for the architect/human. When finished, close this triage issue (\`gh issue close $ISSUE_NUM\`)."
-1
View File
@@ -62,7 +62,6 @@ examples/mcp/hello/hello
/plan-delegate
/agent-plan-delegate
/micro-mcp-gateway
/agent-ollama
# Local Jekyll / Bundler artifacts
internal/website/.bundle/
-8
View File
@@ -17,14 +17,6 @@ next version when it ships.
## [Unreleased]
### Added
- **`micro loop`** — scaffold an autonomous improvement loop into any repository: GitHub Actions workflows dispatched to an @mention-driven coding agent, across up to five roles — `planner` (ranked queue), `builder` (top item as a single-concern PR, auto-merged on green CI), `triage` (CI failures → fix issues), and opt-in `coherence` (docs/CHANGELOG alignment) and `release` (daily patch tag). Each dispatch role's instruction lives in an editable `.github/loop/prompts/<role>.md` file — the workflow is the mechanism, the prompt is the policy — so a repo customizes behavior without forking the CLI. `micro loop init --roles …` writes it all; `micro loop verify` checks the wiring. This is the loop that maintains go-micro itself, generalized. (`cmd/micro/loop/`)
---
## [6.3.12] - July 2026
### Added
- **Ollama provider** — run agents against open-weight models locally (`/api/chat`, NDJSON streaming) or via Ollama Cloud (OpenAI-compatible `/v1/chat/completions`, SSE), auto-detected from the base URL, with tool calling in both modes. Point any agent at a non-default endpoint with the new `agent.BaseURL` / `micro.AgentBaseURL` option. (`ai/ollama/`, `examples/agent-ollama/`)
- **Retrieval-backed agent memory** — agents can recall relevant prior turns by similarity, not just the recent window, with a summarizer hook that compacts older history so long conversations stay in budget. (`agent/`)
- **Scheduled flows** — a flow can run an agent (or any step) on a cron-style schedule, with the dispatch traced end to end. (`flow/`)
- **Flow verification/grader loop** — a workflow can grade its own step output against a rubric and retry until it passes, plus run-trace analysis to surface where a flow spends its time. (`flow/`)
+1 -8
View File
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
.PHONY: test test-race test-coverage harness install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
.PHONY: test test-race test-coverage harness provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
# Default target
help:
@@ -19,7 +19,6 @@ help:
@echo " make test-coverage - Run tests with coverage"
@echo " make lint - Run linter"
@echo " make harness - Run deterministic getting-started and end-to-end harnesses"
@echo " make install-smoke - Verify the local install.sh and first-run CLI smoke path"
@echo " make provider-conformance-mock - Run cross-provider harness with deterministic mock provider"
@echo " make provider-conformance - Run harnesses against configured live providers"
@echo " make fmt - Format code"
@@ -49,17 +48,11 @@ test-coverage:
# This mirrors the default CI path so local dogfooding catches scaffold,
# run/chat/inspect, and 0→hero regressions before a PR is opened.
harness:
$(MAKE) install-smoke
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
./internal/harness/zero-to-hero-ci/run.sh
go run ./internal/harness/agent-flow
$(MAKE) provider-conformance-mock
# Verify the documented install script and first-run CLI command boundaries without
# provider keys or network access.
install-smoke:
./internal/harness/install-smoke/run.sh
# Run the shared provider conformance contract with the deterministic mock
# provider. This is the no-secret path used by CI and local dogfooding to keep
# provider-facing agent/tool semantics covered on every machine.
+3 -26
View File
@@ -25,7 +25,6 @@ Running Go Micro in production, or building on it and want help? Paid **support,
## Contents
- [Quick Start](#quick-start)
- [First agent on-ramp](#first-agent-on-ramp)
- [Why an Agent Harness](#why-an-agent-harness)
- [Writing Services](#writing-services)
- [Building Agents](#building-agents) — [Plan & Delegate](#plan--delegate), [Pluggable](#batteries-included-pluggable), [Paid tools (x402)](#paid-tools-x402), [A2A](#reachable-by-other-agents-a2a)
@@ -67,35 +66,14 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
-H 'Content-Type: application/json' -d '{"name":"World"}'
```
This install → scaffold → run → call path is covered by no-secret CI harnesses. To
verify just the local installer and first-run CLI boundaries without network
access or provider keys, use:
```bash
make install-smoke
```
To run the broader local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
This scaffold → run → call path is covered by the no-secret CI harness. To run
the same local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
chat/inspect CLI boundaries, and deploy dry-run), use:
```bash
make harness
```
### First agent on-ramp
After install and the first `micro new`/`micro run` smoke check, take the
walkable agent path in this order:
1. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
service-backed agent and talk to it with `micro chat`.
2. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
`micro agent inspect`, run history, memory, and provider checks when the first
conversation does something unexpected.
3. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
services → agents → workflows loop with scaffold, run, chat, inspect, flow
history, and deploy dry-run commands that match the maintained harness.
### Generate from a prompt — with an LLM key
Set a provider key, describe what you want, and the AI designs services, writes handlers, compiles, and starts them:
@@ -330,7 +308,7 @@ MCP exposes your services as tools; A2A exposes your agents as agents. See the [
| MCP gateway | Every endpoint is an AI tool automatically |
| A2A gateway | Every agent is reachable over the Agent2Agent protocol; cards generated from the registry (`micro a2a`) |
| Payments (x402) | Opt-in per-call payments for tools via the x402 standard; pluggable facilitator (Base, Solana, …) |
| 8 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, Ollama (local + cloud) |
| 7 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud |
| Interactive console | `micro run` includes a chat console for talking to services |
| Service generation | `micro run --prompt` — describe a system, get running services |
@@ -427,7 +405,6 @@ Swap providers with a single import — same interface everywhere:
| Mistral | `mistral-large-latest` |
| Together AI | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
| Ollama | `llama3.2` (local) |
```go
m := ai.New("anthropic", ai.WithAPIKey(key))
-22
View File
@@ -34,7 +34,6 @@ import (
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/ollama"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
@@ -150,9 +149,6 @@ func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
if a.opts.Model != "" {
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
}
if a.opts.BaseURL != "" {
modelOpts = append(modelOpts, ai.WithBaseURL(a.opts.BaseURL))
}
// Reuse the existing tools instance: its name map is populated by
// discoverTools, and rebuilding it here would orphan a base handler that
@@ -368,24 +364,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
RunID: a.runID,
ParentID: parentRunID,
}
if a.opts.Checkpoint != nil {
if unfinished := a.unfinishedPlanSteps(); len(unfinished) > 0 {
err = fmt.Errorf("agent run %s has unfinished plan steps: %s", run.ID, strings.Join(unfinished, ", "))
run.Status = "failed"
run.State.Stage = agentAskStep
run.State.Data = []byte(message)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "failed"
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
return nil, err
}
}
run.Status = "done"
run.State.Stage = ""
if b, marshalErr := json.Marshal(res); marshalErr == nil {
-32
View File
@@ -402,38 +402,6 @@ func (a *agentImpl) completeNextPlanStep() {
}
}
func (a *agentImpl) unfinishedPlanSteps() []string {
plan := a.loadPlan()
if plan == "" {
return nil
}
var data map[string]any
if err := json.Unmarshal([]byte(plan), &data); err != nil {
return nil
}
steps, ok := data["steps"].([]any)
if !ok {
return nil
}
var unfinished []string
for _, raw := range steps {
step, ok := raw.(map[string]any)
if !ok {
continue
}
status, _ := step["status"].(string)
if status != "" && status != "pending" && status != "in_progress" {
continue
}
task, _ := step["task"].(string)
if task == "" {
task = "<unnamed>"
}
unfinished = append(unfinished, task)
}
return unfinished
}
// handleHumanInput records that the model needs operator input before it can continue.
func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
prompt, _ := call.Input["prompt"].(string)
-37
View File
@@ -141,43 +141,6 @@ func TestCheckpointSkipsDuplicateToolWithinAsk(t *testing.T) {
}
}
func TestCheckpointFailsRunWithUnfinishedPlanStep(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "unfinished-plan-agent")
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "create launch tasks", "status": "done"},
map[string]any{"task": "notify owner via comms", "status": "in_progress"},
},
}})
return &ai.Response{Reply: "all done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("unfinished-plan-agent"), WithCheckpoint(cp))
_, err := a.Ask(ctx, "create tasks and notify owner")
if err == nil {
t.Fatal("Ask succeeded with unfinished plan step")
}
if !strings.Contains(err.Error(), "notify owner via comms") {
t.Fatalf("Ask error = %v, want unfinished delegate step named", err)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want failed run remains actionable", len(runs))
}
if runs[0].Status != "failed" {
t.Fatalf("run status = %q, want failed", runs[0].Status)
}
}
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
-7
View File
@@ -40,7 +40,6 @@ type Options struct {
Provider string
Model string
APIKey string
BaseURL string
Address string
Registry registry.Registry
Client client.Client
@@ -169,12 +168,6 @@ func APIKey(k string) Option {
return func(o *Options) { o.APIKey = k }
}
// BaseURL sets the base URL for the LLM provider. Use this to point
// the provider at a non-default endpoint (e.g., local Ollama, a proxy).
func BaseURL(url string) Option {
return func(o *Options) { o.BaseURL = url }
}
// Address sets the network address for the agent's service endpoint.
// Use "127.0.0.1:0" in local harnesses/tests to bind an ephemeral loopback
// port and avoid advertising the default service address.
-729
View File
@@ -1,729 +0,0 @@
// Package ollama implements the Ollama model provider.
//
// Ollama runs open-weight models locally (or via Ollama Cloud). This
// provider supports two API styles:
//
// - Native (/api/chat): local Ollama servers (default, http://localhost:11434)
// - OpenAI-compatible (/v1/chat/completions): Ollama Cloud (https://ollama.com/v1)
//
// The provider auto-detects which style to use based on the base URL.
// Set OLLAMA_BASE_URL to point at your server (local or cloud).
//
// Usage (local):
//
// import _ "go-micro.dev/v6/ai/ollama"
//
// m := ai.New("ollama",
// ai.WithBaseURL("http://localhost:11434"),
// ai.WithModel("llama3.2"),
// )
//
// Usage (Ollama Cloud):
//
// m := ai.New("ollama",
// ai.WithBaseURL("https://ollama.com/v1"),
// ai.WithAPIKey("your-key"),
// ai.WithModel("gpt-oss:120b"),
// )
package ollama
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
)
func init() {
ai.Register("ollama", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("ollama")
}
// Provider implements the ai.Model interface for Ollama.
type Provider struct {
opts ai.Options
// cloudOverride forces cloud mode for testing. When true, the provider
// uses the OpenAI-compatible endpoint regardless of the base URL.
cloudOverride bool
}
// NewProvider creates a new Ollama provider.
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "llama3.2"
}
if options.BaseURL == "" {
options.BaseURL = "http://localhost:11434"
}
return &Provider{opts: options}
}
// Init initializes the provider with options.
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
// Options returns the provider options.
func (p *Provider) Options() ai.Options { return p.opts }
// String returns the provider name.
func (p *Provider) String() string { return "ollama" }
// isCloud returns true when the base URL points at Ollama Cloud (ollama.com),
// which uses the OpenAI-compatible /v1/chat/completions endpoint instead of
// the native /api/chat.
func (p *Provider) isCloud() bool {
if p.cloudOverride {
return true
}
return strings.Contains(p.opts.BaseURL, "ollama.com")
}
// chatPath returns the API endpoint path for chat completions.
func (p *Provider) chatPath() string {
if p.isCloud() {
return "/v1/chat/completions"
}
return "/api/chat"
}
// streamPath returns the API endpoint path for streaming chat.
// Ollama Cloud uses the same /v1/chat/completions with stream:true.
// Local Ollama uses /api/chat with stream:true.
func (p *Provider) streamPath() string {
return p.chatPath()
}
// Generate generates a response from the Ollama model.
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
if p.isCloud() {
return p.generateOpenAI(ctx, req)
}
return p.generateNative(ctx, req)
}
// Stream generates a streaming response.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
if p.isCloud() {
return p.streamOpenAI(ctx, req)
}
return p.streamNative(ctx, req)
}
// ---------------------------------------------------------------------------
// OpenAI-compatible mode (Ollama Cloud: ollama.com/v1)
// ---------------------------------------------------------------------------
func (p *Provider) generateOpenAI(ctx context.Context, req *ai.Request) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
},
})
}
messages := buildOpenAIMessages(req)
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": false,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
resp, rawMsg, err := p.callOpenAI(ctx, apiReq)
if err != nil {
return nil, err
}
// No tool calls or no handler — return as-is.
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
// Tool execution loop.
convMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMsg.content,
"tool_calls": rawMsg.toolCalls,
})
pendingCalls := resp.ToolCalls
for round := 0; round < 10; round++ {
for i := range pendingCalls {
result := p.opts.ToolHandler(ctx, pendingCalls[i])
pendingCalls[i].Result = result.Content
convMessages = append(convMessages, map[string]any{
"role": "tool",
"tool_call_id": pendingCalls[i].ID,
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": convMessages,
"stream": false,
}
if len(tools) > 0 {
followUpReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
followUpReq["max_tokens"] = p.opts.MaxTokens
}
followUpResp, followUpRaw, err := p.callOpenAI(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
convMessages = append(convMessages, map[string]any{
"role": "assistant",
"content": followUpRaw.content,
"tool_calls": followUpRaw.toolCalls,
})
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
func (p *Provider) callOpenAI(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.chatPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, nil, fmt.Errorf("no response from API")
}
choice := chatResp.Choices[0]
response := &ai.Response{
Reply: choice.Message.Content,
Usage: ai.Usage{
InputTokens: chatResp.Usage.PromptTokens,
OutputTokens: chatResp.Usage.CompletionTokens,
TotalTokens: chatResp.Usage.TotalTokens,
},
}
var rawToolCalls []map[string]any
for _, tc := range choice.Message.ToolCalls {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
rawToolCalls = append(rawToolCalls, map[string]any{
"id": tc.ID,
"type": "function",
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
raw := &rawChatMessage{
content: choice.Message.Content,
toolCalls: rawToolCalls,
}
return response, raw, nil
}
func (p *Provider) streamOpenAI(ctx context.Context, req *ai.Request) (ai.Stream, error) {
messages := buildOpenAIMessages(req)
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.streamPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &sseStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// buildOpenAIMessages converts an ai.Request into the OpenAI chat message format.
func buildOpenAIMessages(req *ai.Request) []map[string]any {
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
return messages
}
// sseStream reads OpenAI-style server-sent events (used by Ollama Cloud).
type sseStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *sseStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *sseStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// ---------------------------------------------------------------------------
// Native mode (local Ollama: localhost:11434/api/chat)
// ---------------------------------------------------------------------------
func (p *Provider) generateNative(ctx context.Context, req *ai.Request) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
},
})
}
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": false,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
resp, rawMsg, err := p.callNative(ctx, apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
convMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMsg.content,
})
if len(rawMsg.toolCalls) > 0 {
convMessages[len(convMessages)-1]["tool_calls"] = rawMsg.toolCalls
}
pendingCalls := resp.ToolCalls
for round := 0; round < 10; round++ {
for i := range pendingCalls {
result := p.opts.ToolHandler(ctx, pendingCalls[i])
pendingCalls[i].Result = result.Content
convMessages = append(convMessages, map[string]any{
"role": "tool",
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": convMessages,
"stream": false,
}
if len(tools) > 0 {
followUpReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
followUpReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
followUpResp, followUpRaw, err := p.callNative(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
convMessages = append(convMessages, map[string]any{
"role": "assistant",
"content": followUpRaw.content,
})
if len(followUpRaw.toolCalls) > 0 {
convMessages[len(convMessages)-1]["tool_calls"] = followUpRaw.toolCalls
}
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
func (p *Provider) callNative(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.chatPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments any `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
Done bool `json:"done"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
response := &ai.Response{
Reply: chatResp.Message.Content,
Usage: ai.Usage{
InputTokens: chatResp.PromptEvalCount,
OutputTokens: chatResp.EvalCount,
TotalTokens: chatResp.PromptEvalCount + chatResp.EvalCount,
},
}
var rawToolCalls []map[string]any
for _, tc := range chatResp.Message.ToolCalls {
var input map[string]any
switch v := tc.Function.Arguments.(type) {
case string:
if err := json.Unmarshal([]byte(v), &input); err != nil {
input = map[string]any{}
}
case map[string]any:
input = v
default:
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
Name: tc.Function.Name,
Input: input,
})
rawToolCalls = append(rawToolCalls, map[string]any{
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
raw := &rawChatMessage{
content: chatResp.Message.Content,
toolCalls: rawToolCalls,
}
return response, raw, nil
}
func (p *Provider) streamNative(ctx context.Context, req *ai.Request) (ai.Stream, error) {
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
}
if p.opts.MaxTokens > 0 {
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.streamPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &ndjsonStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// ndjsonStream reads newline-delimited JSON (used by local Ollama).
type ndjsonStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *ndjsonStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" {
continue
}
var chunk struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Done bool `json:"done"`
}
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if chunk.Done {
return nil, io.EOF
}
if chunk.Message.Content != "" {
return &ai.Response{Reply: chunk.Message.Content}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *ndjsonStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// rawChatMessage holds the raw assistant content and tool calls for
// follow-up messages.
type rawChatMessage struct {
content string
toolCalls []map[string]any
}
-333
View File
@@ -1,333 +0,0 @@
package ollama
import (
"context"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
)
// ---------------------------------------------------------------------------
// Provider basics
// ---------------------------------------------------------------------------
func TestProvider_String(t *testing.T) {
p := NewProvider()
if p.String() != "ollama" {
t.Errorf("Expected 'ollama', got '%s'", p.String())
}
}
func TestProvider_Init(t *testing.T) {
p := NewProvider()
err := p.Init(
ai.WithModel("test-model"),
ai.WithAPIKey("test-key"),
ai.WithBaseURL("https://test.com"),
)
if err != nil {
t.Fatalf("Init failed: %v", err)
}
opts := p.Options()
if opts.Model != "test-model" {
t.Errorf("Expected model 'test-model', got '%s'", opts.Model)
}
if opts.APIKey != "test-key" {
t.Errorf("Expected API key 'test-key', got '%s'", opts.APIKey)
}
if opts.BaseURL != "https://test.com" {
t.Errorf("Expected base URL 'https://test.com', got '%s'", opts.BaseURL)
}
}
func TestProvider_Defaults(t *testing.T) {
p := NewProvider()
opts := p.Options()
if opts.Model != "llama3.2" {
t.Errorf("Expected default model 'llama3.2', got '%s'", opts.Model)
}
if opts.BaseURL != "http://localhost:11434" {
t.Errorf("Expected default base URL 'http://localhost:11434', got '%s'", opts.BaseURL)
}
}
func TestProvider_IsCloud(t *testing.T) {
local := NewProvider(ai.WithBaseURL("http://localhost:11434"))
if local.isCloud() {
t.Error("localhost should not be cloud")
}
cloud := NewProvider(ai.WithBaseURL("https://ollama.com/v1"))
if !cloud.isCloud() {
t.Error("ollama.com should be cloud")
}
}
// ---------------------------------------------------------------------------
// Native mode (local Ollama: /api/chat)
// ---------------------------------------------------------------------------
func TestNative_Generate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/api/chat" {
t.Errorf("Expected /api/chat, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"model": "llama3.2",
"message": {"role": "assistant", "content": "Hello from local Ollama!"},
"done": true,
"prompt_eval_count": 10,
"eval_count": 5
}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("llama3.2"))
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Hi",
SystemPrompt: "You are helpful",
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if resp.Reply != "Hello from local Ollama!" {
t.Errorf("Expected 'Hello from local Ollama!', got '%s'", resp.Reply)
}
if resp.Usage.TotalTokens != 15 {
t.Errorf("Expected total tokens 15, got %d", resp.Usage.TotalTokens)
}
}
func TestNative_GenerateWithToolCall(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
w.Write([]byte(`{
"model": "llama3.2",
"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"function": {"name": "get_weather", "arguments": "{\"city\":\"Seoul\"}"}}]
},
"done": true
}`))
} else {
w.Write([]byte(`{
"model": "llama3.2",
"message": {"role": "assistant", "content": "The weather in Seoul is sunny."},
"done": true
}`))
}
}))
defer srv.Close()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if call.Name != "get_weather" {
t.Errorf("Expected tool 'get_weather', got '%s'", call.Name)
}
return ai.ToolResult{ID: call.ID, Content: `{"temp": 22, "condition": "sunny"}`}
}
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("llama3.2"),
ai.WithToolHandler(handler),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "What's the weather?",
Tools: []ai.Tool{{
Name: "get_weather",
Description: "Get weather",
Properties: map[string]any{"city": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if len(resp.ToolCalls) == 0 {
t.Error("Expected tool calls")
}
if resp.Answer != "The weather in Seoul is sunny." {
t.Errorf("Expected final answer, got '%s'", resp.Answer)
}
}
func TestNative_Stream(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{"message":{"role":"assistant","content":"Hello"},"done":false}` + "\n"))
w.Write([]byte(`{"message":{"role":"assistant","content":" world"},"done":false}` + "\n"))
w.Write([]byte(`{"message":{"role":"assistant","content":""},"done":true}` + "\n"))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("llama3.2"))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hi"})
if err != nil {
t.Fatalf("Stream failed: %v", err)
}
defer stream.Close()
var chunks []string
for {
resp, err := stream.Recv()
if err != nil {
break
}
if resp.Reply != "" {
chunks = append(chunks, resp.Reply)
}
}
result := strings.Join(chunks, "")
if result != "Hello world" {
t.Errorf("Expected 'Hello world', got '%s'", result)
}
}
// ---------------------------------------------------------------------------
// Cloud mode (Ollama Cloud: /v1/chat/completions)
// ---------------------------------------------------------------------------
func TestCloud_Generate(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("Expected /v1/chat/completions, got %s", r.URL.Path)
}
w.Header().Set("Content-Type", "application/json")
w.Write([]byte(`{
"usage": {"prompt_tokens": 10, "completion_tokens": 5, "total_tokens": 15},
"choices": [{"message": {"role": "assistant", "content": "Hello from Ollama Cloud!"}}]
}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("gemma4:31b-cloud"), ai.WithAPIKey("test-key"))
p.cloudOverride = true
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Hi",
SystemPrompt: "You are helpful",
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if resp.Reply != "Hello from Ollama Cloud!" {
t.Errorf("Expected 'Hello from Ollama Cloud!', got '%s'", resp.Reply)
}
if resp.Usage.TotalTokens != 15 {
t.Errorf("Expected total tokens 15, got %d", resp.Usage.TotalTokens)
}
}
func TestCloud_GenerateWithToolCall(t *testing.T) {
callCount := 0
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
callCount++
w.Header().Set("Content-Type", "application/json")
if callCount == 1 {
w.Write([]byte(`{
"choices": [{"message": {
"role": "assistant",
"content": "",
"tool_calls": [{"id": "call_1", "function": {"name": "search", "arguments": "{\"query\":\"go interfaces\"}"}}]
}}]
}`))
} else {
w.Write([]byte(`{
"choices": [{"message": {"role": "assistant", "content": "Go interfaces are implicit."}}]
}`))
}
}))
defer srv.Close()
handler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
return ai.ToolResult{ID: call.ID, Content: `{"results": ["Go interfaces are implicit"]}`}
}
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("gemma4:31b-cloud"),
ai.WithAPIKey("test-key"),
ai.WithToolHandler(handler),
)
p.cloudOverride = true
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "Search for Go interfaces",
Tools: []ai.Tool{{
Name: "search",
Description: "Search the knowledge base",
Properties: map[string]any{"query": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate failed: %v", err)
}
if len(resp.ToolCalls) == 0 {
t.Error("Expected tool calls")
}
if resp.Answer != "Go interfaces are implicit." {
t.Errorf("Expected final answer, got '%s'", resp.Answer)
}
}
func TestCloud_Stream(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"Hello\"}}]}\n\n"))
w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\" cloud\"}}]}\n\n"))
w.Write([]byte("data: [DONE]\n\n"))
}))
defer srv.Close()
p := NewProvider(
ai.WithBaseURL(srv.URL),
ai.WithModel("gemma4:31b-cloud"),
ai.WithAPIKey("test-key"),
)
p.cloudOverride = true
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hi"})
if err != nil {
t.Fatalf("Stream failed: %v", err)
}
defer stream.Close()
var chunks []string
for {
resp, err := stream.Recv()
if err != nil {
break
}
if resp.Reply != "" {
chunks = append(chunks, resp.Reply)
}
}
result := strings.Join(chunks, "")
if result != "Hello cloud" {
t.Errorf("Expected 'Hello cloud', got '%s'", result)
}
}
// ---------------------------------------------------------------------------
// Error handling
// ---------------------------------------------------------------------------
func TestProvider_APIError(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(`{"error": "model not found"}`))
}))
defer srv.Close()
p := NewProvider(ai.WithBaseURL(srv.URL), ai.WithModel("nonexistent"))
_, err := p.Generate(context.Background(), &ai.Request{Prompt: "Hi"})
if err == nil {
t.Error("Expected error on API failure")
}
if !strings.Contains(err.Error(), "API error") {
t.Errorf("Expected 'API error' in message, got '%s'", err.Error())
}
}
-51
View File
@@ -625,54 +625,3 @@ Scopes provide fine-grained access control over which tokens can call which serv
The gateway's scope system uses `auth.Account` from the go-micro framework. Scopes on accounts are the same `[]string` field used by the framework's `auth.Rules` and `wrapper/auth` package. The gateway stores scope requirements in the default store under `endpoint-scopes/<service>.<endpoint>` keys and checks them on every HTTP request.
For service-level (RPC) auth within the go-micro mesh, use the `wrapper/auth` package which provides `auth.Rules` with priority-based access control. See the [auth wrapper documentation](../../wrapper/auth/README.md) for details.
## Self-improving loop (`micro loop`)
Turn a repository into a self-improving one: GitHub Actions workflows that
dispatch a coding agent to plan, build, and triage — gated by CI. This is the
same loop that maintains go-micro itself, generalized so any repo (and any
@mention-driven agent) can use it.
```bash
micro loop init # scaffold the loop into the current repo
micro loop verify # check a repo is wired correctly
```
`micro loop init` writes the selected roles' workflows, their prompts, and a queue. Choose roles with `--roles` (default `planner,builder,triage`; `--roles all` for everything):
| Role | Workflow | What it does |
|------|----------|--------------|
| Planner | `loop-planner.yml` | Keeps a ranked queue in `.github/loop/PRIORITIES.md` |
| Builder | `loop-builder.yml` | Builds the top open item as a single-concern PR, auto-merged on green CI |
| Triage | `loop-triage.yml` | Turns CI failures into scoped fix issues, back into the queue |
| Coherence | `loop-coherence.yml` | Keeps README/docs/CHANGELOG aligned with the North Star *(opt-in)* |
| Release | `loop-release.yml` | Cuts the next patch tag when the branch has new commits *(opt-in)* |
The workflows are the **mechanism**; each dispatch role's instruction is an editable file in `.github/loop/prompts/` — the **policy**. Edit those prompts (and `.github/loop/NORTH_STAR.md`) to steer the loop without touching the CLI. That split is what lets go-micro itself use `micro loop` while keeping its own richer prompts.
Common flags:
```bash
micro loop init \
--roles all \
--agent @codex \
--token-secret LOOP_TOKEN \
--branch main \
--ci-workflow CI
```
- `--roles`: which roles to scaffold (`planner,builder,triage`, or `all`)
- `--agent`: how the workflows summon the agent (an `@mention`)
- `--token-secret`: repo secret holding the driving user PAT
- `--branch`: base branch for the loop's PRs
- `--ci-workflow`: `name:` of the CI workflow triage watches
- `--tag-prefix`: tag prefix the release role matches and bumps (default `v`)
Two things the CLI can't do for you (and `micro loop verify` reminds you of):
1. **Add the token secret.** The agent ignores `@mentions` from the
`github-actions` bot, so dispatch posts as a real user via a PAT stored in
the `--token-secret` repo secret. The workflows no-op until it's set.
2. **Set branch protection.** Require the CI checks with **0 approving reviews**
so the builder's native auto-merge lands PRs the moment CI is green — that
green-CI gate is the loop's only safety mechanism, so keep the suite strong.
-466
View File
@@ -1,466 +0,0 @@
// Package loop implements the 'micro loop' command, which scaffolds and
// verifies an autonomous improvement loop for a repository.
//
// The loop is a set of GitHub Actions workflows that dispatch a coding agent by
// @mention on a fresh tracking issue each run. It has up to five roles:
//
// planner keeps a ranked queue in .github/loop/PRIORITIES.md
// builder builds the top open item as a single-concern PR (auto-merged on green CI)
// triage turns CI failures into scoped fix issues back into the queue
// coherence keeps README/docs/CHANGELOG aligned with the North Star (opt-in)
// release cuts the next patch tag when the branch has new commits (opt-in)
//
// The workflows are the MECHANISM; each dispatch role's instruction lives in an
// editable .github/loop/prompts/<role>.md file — the POLICY. That split is what
// lets any repo (including go-micro itself) customize behavior by editing prompt
// files rather than forking the CLI. `micro loop init` writes it all; `micro
// loop verify` checks the wiring.
package loop
import (
"bytes"
"embed"
"fmt"
"os"
"os/exec"
"path/filepath"
"sort"
"strings"
"text/template"
"github.com/urfave/cli/v2"
"go-micro.dev/v6/cmd"
)
//go:embed templates/*
var templatesFS embed.FS
// config is the substitution surface for the templates — the whole config-vs-core
// boundary. The workflows and prompts are the reusable core; these are what a
// given repo tunes.
type config struct {
// Shared.
DefaultBranch string // base branch for the loop's PRs (e.g. main)
AgentMention string // how the workflows summon the agent (e.g. @codex)
TokenSecret string // repo secret holding the user PAT that drives dispatch
CIWorkflow string // name: of the CI workflow triage watches for failures
// Per-dispatch-role (set while rendering each one).
Role string
WorkflowName string
IssueTitle string
Group string
Cron string
// Release role.
TagPrefix string // tag prefix to match/bump, e.g. "v"
ReleaseCron string
}
// dispatchRole is a cron-driven role rendered from templates/dispatch.yml.tmpl.
type dispatchRole struct {
workflowName string
issueTitle string
group string
cronFlag string
defaultCron string
}
var dispatchRoles = map[string]dispatchRole{
"planner": {"Loop: Planner", "Loop: planning review", "loop-planner", "planner-cron", "0 * * * *"},
"builder": {"Loop: Builder", "Loop: build increment", "loop-builder", "builder-cron", "30 * * * *"},
"coherence": {"Loop: Coherence", "Loop: coherence review", "loop-coherence", "coherence-cron", "0 7 * * *"},
}
// allRoles is the full set, in a stable order, for --roles=all and help text.
var allRoles = []string{"planner", "builder", "triage", "coherence", "release"}
const (
promptDir = ".github/loop/prompts"
loopDir = ".github/loop"
wfDir = ".github/workflows"
)
func init() {
cmd.Register(&cli.Command{
Name: "loop",
Usage: "Scaffold an autonomous improvement loop for a repository",
Description: `Set up a self-improving loop for a repo: GitHub Actions workflows that
dispatch a coding agent to plan, build, triage, and (optionally) keep docs
coherent and cut releases — gated by CI.
Roles (choose with --roles, default: planner,builder,triage):
planner keeps a ranked queue in .github/loop/PRIORITIES.md
builder builds the top open item as a single-concern PR (auto-merged on green CI)
triage turns CI failures into scoped fix issues back into the queue
coherence keeps README/docs/CHANGELOG aligned with the North Star
release cuts the next patch tag when the branch has new commits
Each dispatch role's instruction is an editable file in .github/loop/prompts/ —
edit those to steer behavior. Direction lives in .github/loop/NORTH_STAR.md.
Examples:
# Scaffold the default loop (planner, builder, triage)
micro loop init
# The full loop, all five roles
micro loop init --roles all
# Customize the agent, token secret, base branch, and CI workflow name
micro loop init --agent @codex --token-secret LOOP_TOKEN \
--branch main --ci-workflow CI
# Check that a repo is wired correctly
micro loop verify`,
Subcommands: []*cli.Command{
{
Name: "init",
Usage: "Scaffold the loop workflows, prompts, and queue into a repo",
Flags: []cli.Flag{
&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."},
&cli.StringFlag{Name: "roles", Usage: "Comma-separated roles, or 'all'", Value: "planner,builder,triage"},
&cli.StringFlag{Name: "branch", Usage: "Base branch for the loop's PRs (auto-detected if empty)"},
&cli.StringFlag{Name: "agent", Usage: "How the workflows summon the agent (an @mention)", Value: "@codex"},
&cli.StringFlag{Name: "token-secret", Usage: "Repo secret holding the user PAT that drives dispatch", Value: "LOOP_TOKEN"},
&cli.StringFlag{Name: "ci-workflow", Usage: "name: of the CI workflow triage watches for failures", Value: "CI"},
&cli.StringFlag{Name: "planner-cron", Usage: "Cron schedule for the planner", Value: "0 * * * *"},
&cli.StringFlag{Name: "builder-cron", Usage: "Cron schedule for the builder", Value: "30 * * * *"},
&cli.StringFlag{Name: "coherence-cron", Usage: "Cron schedule for the coherence role", Value: "0 7 * * *"},
&cli.StringFlag{Name: "release-cron", Usage: "Cron schedule for the release role", Value: "0 23 * * *"},
&cli.StringFlag{Name: "tag-prefix", Usage: "Tag prefix the release role matches and bumps", Value: "v"},
&cli.BoolFlag{Name: "force", Usage: "Overwrite existing loop files"},
},
Action: runInit,
},
{
Name: "verify",
Usage: "Verify a repo is wired for the loop",
Flags: []cli.Flag{&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."}},
Action: runVerify,
},
},
})
}
func runInit(c *cli.Context) error {
dir := c.String("dir")
roles, err := parseRoles(c.String("roles"))
if err != nil {
return err
}
cfg := config{
DefaultBranch: c.String("branch"),
AgentMention: strings.TrimSpace(c.String("agent")),
TokenSecret: strings.TrimSpace(c.String("token-secret")),
CIWorkflow: c.String("ci-workflow"),
TagPrefix: c.String("tag-prefix"),
ReleaseCron: c.String("release-cron"),
}
if cfg.DefaultBranch == "" {
cfg.DefaultBranch = detectDefaultBranch(dir)
}
if !strings.HasPrefix(cfg.AgentMention, "@") {
cfg.AgentMention = "@" + cfg.AgentMention
}
crons := map[string]string{
"planner": c.String("planner-cron"),
"builder": c.String("builder-cron"),
"coherence": c.String("coherence-cron"),
}
if err := scaffold(dir, cfg, roles, crons, c.Bool("force")); err != nil {
return err
}
printNextSteps(cfg, roles)
return nil
}
// parseRoles resolves the --roles flag into a validated, stable-ordered set.
func parseRoles(spec string) ([]string, error) {
if strings.TrimSpace(spec) == "all" {
return append([]string(nil), allRoles...), nil
}
want := map[string]bool{}
for _, r := range strings.Split(spec, ",") {
r = strings.TrimSpace(r)
if r == "" {
continue
}
if !isRole(r) {
return nil, fmt.Errorf("unknown role %q (valid: %s, or 'all')", r, strings.Join(allRoles, ", "))
}
want[r] = true
}
if len(want) == 0 {
return nil, fmt.Errorf("no roles selected")
}
var out []string
for _, r := range allRoles { // preserve canonical order
if want[r] {
out = append(out, r)
}
}
return out, nil
}
func isRole(r string) bool {
for _, x := range allRoles {
if x == r {
return true
}
}
return false
}
// scaffold renders the selected roles into dir. The split is deliberate:
// - Workflows are the MECHANISM — regenerated, and overwritten with --force.
// - Prompts, NORTH_STAR, and PRIORITIES are the POLICY — written once and
// never clobbered, even with --force, so re-running init to refresh the
// workflow mechanics can't wipe curated instructions, direction, or queue.
func scaffold(dir string, cfg config, roles []string, crons map[string]string, force bool) error {
for _, role := range roles {
switch role {
case "triage":
if err := renderTo(dir, "templates/loop-triage.yml.tmpl", filepath.Join(wfDir, "loop-triage.yml"), cfg, force); err != nil {
return err
}
if err := renderKeep(dir, "templates/prompts/triage.md.tmpl", filepath.Join(promptDir, "triage.md"), cfg); err != nil {
return err
}
case "release":
if err := renderTo(dir, "templates/loop-release.yml.tmpl", filepath.Join(wfDir, "loop-release.yml"), cfg, force); err != nil {
return err
}
default: // dispatch roles
d := dispatchRoles[role]
rc := cfg
rc.Role = role
rc.WorkflowName = d.workflowName
rc.IssueTitle = d.issueTitle
rc.Group = d.group
rc.Cron = crons[role]
if rc.Cron == "" {
rc.Cron = d.defaultCron
}
if err := renderTo(dir, "templates/dispatch.yml.tmpl", filepath.Join(wfDir, "loop-"+role+".yml"), rc, force); err != nil {
return err
}
if err := renderKeep(dir, "templates/prompts/"+role+".md.tmpl", filepath.Join(promptDir, role+".md"), cfg); err != nil {
return err
}
}
}
// Direction + queue: policy, written once, never clobbered.
if err := renderKeep(dir, "templates/NORTH_STAR.md", filepath.Join(loopDir, "NORTH_STAR.md"), cfg); err != nil {
return err
}
return renderKeep(dir, "templates/PRIORITIES.md", filepath.Join(loopDir, "PRIORITIES.md"), cfg)
}
// renderTo renders a template with cfg and writes it to dir/dest (honoring force).
func renderTo(dir, tmplName, dest string, cfg config, force bool) error {
rendered, err := render(tmplName, cfg)
if err != nil {
return err
}
if err := writeFile(filepath.Join(dir, dest), rendered, force); err != nil {
return err
}
fmt.Printf(" wrote %s\n", dest)
return nil
}
// renderKeep writes dir/dest only if it does not already exist — used for
// policy files (prompts, North Star, queue) so re-running init never clobbers
// customizations, regardless of --force.
func renderKeep(dir, tmplName, dest string, cfg config) error {
full := filepath.Join(dir, dest)
if fileExists(full) {
fmt.Printf(" kept %s (already exists)\n", dest)
return nil
}
rendered, err := render(tmplName, cfg)
if err != nil {
return err
}
if err := writeFile(full, rendered, true); err != nil {
return err
}
fmt.Printf(" wrote %s\n", dest)
return nil
}
// verifyState reports what's wrong with dir's loop setup: warnings are
// non-fatal, missing are required files that aren't present.
func verifyState(dir string) (warnings, missing []string) {
// A loop needs direction, a queue, and at least one role workflow.
for _, dest := range []string{filepath.Join(loopDir, "NORTH_STAR.md"), filepath.Join(loopDir, "PRIORITIES.md")} {
if !fileExists(filepath.Join(dir, dest)) {
missing = append(missing, dest)
}
}
present := presentLoopWorkflows(dir)
if len(present) == 0 {
missing = append(missing, wfDir+"/loop-*.yml (no role workflows found)")
}
// Every dispatch/triage role workflow needs its prompt file. (release has none.)
for _, role := range present {
if role == "release" {
continue
}
prompt := filepath.Join(promptDir, role+".md")
if !fileExists(filepath.Join(dir, prompt)) {
missing = append(missing, prompt+" (prompt for the loop-"+role+" workflow)")
}
}
// The loop is only as good as its gate.
if !hasCIWorkflow(dir) {
warnings = append(warnings, "no non-loop workflow found in "+wfDir+" — the loop needs a CI gate (build/test/lint) to merge safely")
}
return warnings, missing
}
// presentLoopWorkflows returns the role names for which a loop-<role>.yml exists.
func presentLoopWorkflows(dir string) []string {
entries, err := os.ReadDir(filepath.Join(dir, wfDir))
if err != nil {
return nil
}
var out []string
for _, e := range entries {
name := e.Name()
if !strings.HasPrefix(name, "loop-") {
continue
}
role := strings.TrimSuffix(strings.TrimSuffix(strings.TrimPrefix(name, "loop-"), ".yml"), ".yaml")
out = append(out, role)
}
sort.Strings(out)
return out
}
func runVerify(c *cli.Context) error {
dir := c.String("dir")
warnings, missing := verifyState(dir)
for _, m := range missing {
fmt.Printf(" MISSING %s\n", m)
}
for _, w := range warnings {
fmt.Printf(" WARN %s\n", w)
}
if len(missing) > 0 {
return fmt.Errorf("loop is not fully scaffolded (%d item(s) missing) — run `micro loop init`", len(missing))
}
fmt.Printf(" OK loop is wired: %s\n", strings.Join(presentLoopWorkflows(dir), ", "))
fmt.Println()
fmt.Println("Reminders the CLI can't check:")
fmt.Println(" • The token secret must be set in the repo (Settings → Secrets).")
fmt.Println(" • Branch protection must require the CI checks with 0 approvals,")
fmt.Println(" so the builder's auto-merge can land PRs on green CI.")
if len(warnings) > 0 {
return fmt.Errorf("%d warning(s) — see above", len(warnings))
}
return nil
}
func render(tmplName string, cfg config) ([]byte, error) {
b, err := templatesFS.ReadFile(tmplName)
if err != nil {
return nil, err
}
// Custom delimiters so GitHub Actions' own ${{ }} expressions pass through
// untouched — only << >> placeholders are substituted.
t, err := template.New(filepath.Base(tmplName)).Delims("<<", ">>").Option("missingkey=error").Parse(string(b))
if err != nil {
return nil, fmt.Errorf("parse %s: %w", tmplName, err)
}
var buf bytes.Buffer
if err := t.Execute(&buf, cfg); err != nil {
return nil, fmt.Errorf("render %s: %w", tmplName, err)
}
return buf.Bytes(), nil
}
func writeFile(path string, content []byte, force bool) error {
if fileExists(path) && !force {
return fmt.Errorf("%s already exists (use --force to overwrite)", path)
}
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
return err
}
return os.WriteFile(path, content, 0o644)
}
func fileExists(path string) bool {
info, err := os.Stat(path)
return err == nil && !info.IsDir()
}
// hasCIWorkflow reports whether .github/workflows holds any workflow that is
// not one of the loop's own (i.e. a plausible CI gate).
func hasCIWorkflow(dir string) bool {
entries, err := os.ReadDir(filepath.Join(dir, wfDir))
if err != nil {
return false
}
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasPrefix(name, "loop-") {
continue
}
if strings.HasSuffix(name, ".yml") || strings.HasSuffix(name, ".yaml") {
return true
}
}
return false
}
// detectDefaultBranch best-effort resolves the repo's default branch, falling
// back to "main".
func detectDefaultBranch(dir string) string {
out, err := exec.Command("git", "-C", dir, "symbolic-ref", "--short", "refs/remotes/origin/HEAD").Output()
if err == nil {
ref := strings.TrimSpace(string(out))
if i := strings.LastIndex(ref, "/"); i >= 0 {
ref = ref[i+1:]
}
if ref != "" {
return ref
}
}
return "main"
}
func printNextSteps(cfg config, roles []string) {
fmt.Printf(`
Loop scaffolded (%s). Next steps (the CLI can't do these for you):
1. Edit .github/loop/NORTH_STAR.md — the direction the loop aligns to.
Seed .github/loop/PRIORITIES.md with a few real items.
Tune the per-role instructions in .github/loop/prompts/ if you like.
2. Add a repo secret named %s: a fine-grained user PAT (contents + pull
requests + issues write) for an account the agent (%s) responds to.
The workflows no-op until this secret exists.
3. Ensure a CI workflow named %q exists and that branch protection on %q
requires its checks with 0 approving reviews — that green-CI gate is
what lets the builder auto-merge safely.
4. Commit these files, then trigger a run from the Actions tab.
Verify anytime with: micro loop verify
`, strings.Join(roles, ", "), cfg.TokenSecret, cfg.AgentMention, cfg.CIWorkflow, cfg.DefaultBranch)
}
-262
View File
@@ -1,262 +0,0 @@
package loop
import (
"os"
"path/filepath"
"strings"
"testing"
)
var testCfg = config{
DefaultBranch: "main",
AgentMention: "@codex",
TokenSecret: "LOOP_TOKEN",
CIWorkflow: "CI",
TagPrefix: "v",
ReleaseCron: "0 23 * * *",
}
var testCrons = map[string]string{"planner": "0 * * * *", "builder": "30 * * * *", "coherence": "0 7 * * *"}
// renderable is every template a full scaffold touches, with the per-role config
// applied the same way scaffold does.
func renderCases() map[string]config {
cases := map[string]config{
"templates/loop-triage.yml.tmpl": testCfg,
"templates/loop-release.yml.tmpl": testCfg,
"templates/prompts/triage.md.tmpl": testCfg,
"templates/prompts/planner.md.tmpl": testCfg,
"templates/prompts/builder.md.tmpl": testCfg,
"templates/prompts/coherence.md.tmpl": testCfg,
}
for role, d := range dispatchRoles {
rc := testCfg
rc.Role, rc.WorkflowName, rc.IssueTitle, rc.Group, rc.Cron = role, d.workflowName, d.issueTitle, d.group, d.defaultCron
cases["dispatch:"+role] = rc
}
return cases
}
func TestRenderIsPlaceholderFreeAndKeepsGHAExpressions(t *testing.T) {
for name, cfg := range renderCases() {
tmplName := name
if strings.HasPrefix(name, "dispatch:") {
tmplName = "templates/dispatch.yml.tmpl"
}
rendered, err := render(tmplName, cfg)
if err != nil {
t.Fatalf("render %s: %v", name, err)
}
s := string(rendered)
// No unresolved substitution delimiters remain in any template.
if strings.Contains(s, "<<") || strings.Contains(s, ">>") {
t.Errorf("%s still contains << >> placeholders", name)
}
}
}
func TestBaseBranchSubstitutedIntoPrompts(t *testing.T) {
// The base branch appears in the PR-opening instructions of these prompts.
for _, p := range []string{"planner", "builder", "coherence"} {
s := mustRender(t, "templates/prompts/"+p+".md.tmpl", testCfg)
if !strings.Contains(s, "--base main") {
t.Errorf("%s prompt missing substituted base branch", p)
}
}
}
func TestWorkflowTemplatesPreserveGHAAndAreStructural(t *testing.T) {
// Only the workflow YAML templates (not the markdown prompts).
wf := map[string]config{
"templates/loop-triage.yml.tmpl": testCfg,
"templates/loop-release.yml.tmpl": testCfg,
}
for role, d := range dispatchRoles {
rc := testCfg
rc.Role, rc.WorkflowName, rc.IssueTitle, rc.Group, rc.Cron = role, d.workflowName, d.issueTitle, d.group, d.defaultCron
wf["dispatch:"+role] = rc
}
for name, cfg := range wf {
tmplName := name
if strings.HasPrefix(name, "dispatch:") {
tmplName = "templates/dispatch.yml.tmpl"
}
s := mustRender(t, tmplName, cfg)
if !strings.Contains(s, "${{ secrets.LOOP_TOKEN") {
t.Errorf("%s lost its ${{ secrets.LOOP_TOKEN }} expression", name)
}
for _, key := range []string{"name:", "on:", "jobs:"} {
if !strings.Contains(s, key) {
t.Errorf("%s missing top-level %q", name, key)
}
}
}
}
func TestDispatchWorkflowsStripPromptComments(t *testing.T) {
// The posted body must not include the prompt's editorial <!-- --> header;
// the workflow strips it. Guard the sed directive in both dispatch paths.
rc := testCfg
d := dispatchRoles["planner"]
rc.Role, rc.WorkflowName, rc.IssueTitle, rc.Group, rc.Cron = "planner", d.workflowName, d.issueTitle, d.group, d.defaultCron
for _, tc := range []struct {
name, tmpl string
cfg config
}{
{"dispatch", "templates/dispatch.yml.tmpl", rc},
{"triage", "templates/loop-triage.yml.tmpl", testCfg},
} {
s := mustRender(t, tc.tmpl, tc.cfg)
if !strings.Contains(s, `/<!--/,/-->/d`) {
t.Errorf("%s workflow does not strip prompt HTML comments before posting", tc.name)
}
}
}
func TestPromptsLeaveRuntimeTokensLiteral(t *testing.T) {
// __ISSUE__ must survive render (the workflow substitutes it at runtime).
for _, p := range []string{"planner", "builder", "coherence", "triage"} {
s := mustRender(t, "templates/prompts/"+p+".md.tmpl", testCfg)
if !strings.Contains(s, "__ISSUE__") {
t.Errorf("%s prompt lost its __ISSUE__ runtime token", p)
}
}
// triage additionally uses __RUNURL__.
if s := mustRender(t, "templates/prompts/triage.md.tmpl", testCfg); !strings.Contains(s, "__RUNURL__") {
t.Error("triage prompt lost its __RUNURL__ runtime token")
}
}
func TestScaffoldAllRolesWritesEverything(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, wfDir, "ci.yml"), "name: CI\n")
roles := []string{"planner", "builder", "triage", "coherence", "release"}
if err := scaffold(dir, testCfg, roles, testCrons, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
wantWorkflows := []string{"loop-planner.yml", "loop-builder.yml", "loop-triage.yml", "loop-coherence.yml", "loop-release.yml"}
for _, w := range wantWorkflows {
if !fileExists(filepath.Join(dir, wfDir, w)) {
t.Errorf("expected %s", w)
}
}
// Dispatch + triage roles have prompts; release does not.
for _, p := range []string{"planner.md", "builder.md", "triage.md", "coherence.md"} {
if !fileExists(filepath.Join(dir, promptDir, p)) {
t.Errorf("expected prompt %s", p)
}
}
if fileExists(filepath.Join(dir, promptDir, "release.md")) {
t.Error("release should not have a prompt")
}
if _, missing := verifyState(dir); len(missing) != 0 {
t.Errorf("verify reported missing after full scaffold: %v", missing)
}
}
func TestScaffoldDefaultRolesOmitsOptional(t *testing.T) {
dir := t.TempDir()
if err := scaffold(dir, testCfg, []string{"planner", "builder", "triage"}, testCrons, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
if fileExists(filepath.Join(dir, wfDir, "loop-coherence.yml")) {
t.Error("coherence should not be written by default")
}
if fileExists(filepath.Join(dir, wfDir, "loop-release.yml")) {
t.Error("release should not be written by default")
}
}
func TestReinitForceKeepsPromptsRefreshesWorkflows(t *testing.T) {
dir := t.TempDir()
roles := []string{"planner", "builder", "triage"}
if err := scaffold(dir, testCfg, roles, testCrons, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
// Customize a prompt and edit direction/queue, as a real user would.
customPrompt := filepath.Join(dir, promptDir, "builder.md")
mustWrite(t, customPrompt, "MY CUSTOM BUILDER POLICY")
northStar := filepath.Join(dir, loopDir, "NORTH_STAR.md")
mustWrite(t, northStar, "MY MISSION")
// Re-run with --force to refresh workflow mechanics.
if err := scaffold(dir, testCfg, roles, testCrons, true); err != nil {
t.Fatalf("re-scaffold --force: %v", err)
}
// Policy (prompt, North Star) must survive --force untouched.
if b, _ := os.ReadFile(customPrompt); string(b) != "MY CUSTOM BUILDER POLICY" {
t.Errorf("--force clobbered a customized prompt: %q", b)
}
if b, _ := os.ReadFile(northStar); string(b) != "MY MISSION" {
t.Errorf("--force clobbered the North Star: %q", b)
}
// Mechanism (workflow) must be regenerated (present and non-empty).
if b, _ := os.ReadFile(filepath.Join(dir, wfDir, "loop-builder.yml")); !strings.Contains(string(b), "Loop: Builder") {
t.Error("--force did not refresh the workflow")
}
}
func TestParseRoles(t *testing.T) {
if got, err := parseRoles("all"); err != nil || len(got) != len(allRoles) {
t.Errorf("all => %v, %v", got, err)
}
// Canonical order preserved regardless of input order.
got, err := parseRoles("release,planner")
if err != nil {
t.Fatal(err)
}
if strings.Join(got, ",") != "planner,release" {
t.Errorf("expected canonical order planner,release; got %v", got)
}
if _, err := parseRoles("bogus"); err == nil {
t.Error("expected error for unknown role")
}
if _, err := parseRoles(""); err == nil {
t.Error("expected error for empty roles")
}
}
func TestVerifyMissingPromptFails(t *testing.T) {
dir := t.TempDir()
if err := scaffold(dir, testCfg, []string{"planner", "builder", "triage"}, testCrons, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
// Delete a prompt → verify must flag it.
if err := os.Remove(filepath.Join(dir, promptDir, "builder.md")); err != nil {
t.Fatal(err)
}
_, missing := verifyState(dir)
found := false
for _, m := range missing {
if strings.Contains(m, "builder.md") {
found = true
}
}
if !found {
t.Errorf("expected verify to flag the missing builder prompt; got %v", missing)
}
}
func mustRender(t *testing.T, tmplName string, cfg config) string {
t.Helper()
b, err := render(tmplName, cfg)
if err != nil {
t.Fatalf("render %s: %v", tmplName, err)
}
return string(b)
}
func mustWrite(t *testing.T, path, content string) {
t.Helper()
if err := os.MkdirAll(filepath.Dir(path), 0o755); err != nil {
t.Fatal(err)
}
if err := os.WriteFile(path, []byte(content), 0o644); err != nil {
t.Fatal(err)
}
}
-23
View File
@@ -1,23 +0,0 @@
# North Star
> **Edit this file.** It is the single source of direction the loop aligns every
> increment to. The planner ranks work against it; the builder builds toward it.
> Be concrete — vague direction produces vague increments.
## Mission
<One or two sentences: the problem this repository solves and who it's for.>
## Right now
<The current priority — what "better" means this month. The planner weights the
queue toward this.>
## Guardrails
- One concern per PR; small and reversible.
- The gate is green CI, not a human review — keep the test/lint suite strong,
because the loop is only as good as its evaluator.
- **Off-limits without a human** (surface as notes, never auto-merge): breaking
public API changes, brand/positioning/marketing copy, new dependencies,
architectural rewrites, product-default changes with broad behavioral impact.
-16
View File
@@ -1,16 +0,0 @@
# Priorities
A single ranked queue, highest-value first. Each item links a scoped issue the
loop can build and CI can verify. The **planner** keeps this current; the
**builder** takes the top item whose issue is still open.
<!--
Seed this with a few real items to give the loop a running start, e.g.:
1. Add retry with backoff to the HTTP client — #123
2. Document the config file format — #124
3. Fix flaky timeout in the cache tests — #125
The planner will re-rank, drop completed items, and file issues for new gaps.
Reorder or edit this file at any time to redirect the loop.
-->
@@ -1,60 +0,0 @@
name: "<< .WorkflowName >>"
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
# cadence it opens a fresh tracking issue and posts the instruction in
# .github/loop/prompts/<< .Role >>.md to the agent (<< .AgentMention >>).
#
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
# change what this role does by editing the prompt, not this YAML. A FRESH
# issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch.
#
# Gated on << .TokenSecret >>: the agent ignores @mentions from the
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
on:
workflow_dispatch: {}
schedule:
- cron: "<< .Cron >>"
permissions:
issues: write
concurrency:
group: << .Group >>
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch << .Role >>
env:
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "<< .TokenSecret >> is not set — skipping (the agent ignores bot @mentions)."
exit 0
fi
PROMPT=".github/loop/prompts/<< .Role >>.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "<< .IssueTitle >> #$RUN_NUMBER" \
--body "Autonomous << .Role >> pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching << .Role >>."
# The prompt file is the policy; strip its editorial <!-- --> header and
# substitute the tracking issue number (__ISSUE__) at runtime.
{
echo "<< .AgentMention >>"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
@@ -1,76 +0,0 @@
name: "Loop: Release"
# Generated by `micro loop init`. Cuts the next PATCH tag
# (<< .TagPrefix >>MAJOR.MINOR.PATCH+1) when the default branch has new commits
# since the latest such tag, and pushes it with a PAT (<< .TokenSecret >>) so any
# tag-triggered release workflow fires. Minor/major bumps stay with a human.
#
# The tag MUST be pushed with a PAT, not the default GITHUB_TOKEN: a tag pushed
# by GITHUB_TOKEN does not trigger other workflows (Actions blocks that recursion).
on:
workflow_dispatch: {}
schedule:
- cron: "<< .ReleaseCron >>"
permissions:
contents: read
concurrency:
group: loop-release
cancel-in-progress: false
jobs:
release:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
with:
fetch-depth: 0 # need full history + all tags
# Do NOT persist the default GITHUB_TOKEN as a git credential: it would
# be sent on the PAT push below and override it, so the tag push would
# authenticate as github-actions[bot] and 403. Letting the PAT in the
# push URL be the only credential is the whole point.
persist-credentials: false
- name: Cut the next patch tag if there are new commits
env:
RELEASE_TOKEN: ${{ secrets.<< .TokenSecret >> }}
REPO: ${{ github.repository }}
run: |
if [ -z "$RELEASE_TOKEN" ]; then
echo "<< .TokenSecret >> is not set — skipping."
exit 0
fi
git fetch --tags --force
LATEST=$(git tag --list '<< .TagPrefix >>*.*.*' --sort=-v:refname | head -1)
if [ -z "$LATEST" ]; then
echo "no << .TagPrefix >>MAJOR.MINOR.PATCH tag found — aborting so nothing weird gets tagged."
exit 1
fi
echo "latest tag: $LATEST"
COUNT=$(git rev-list --count "$LATEST"..HEAD)
echo "commits since $LATEST: $COUNT"
if [ "$COUNT" -eq 0 ]; then
echo "no new commits since $LATEST — no release."
exit 0
fi
ver="${LATEST#<< .TagPrefix >>}"
major="${ver%%.*}"
rest="${ver#*.}"
minor="${rest%%.*}"
patch="${rest#*.}"
case "$major.$minor.$patch" in
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
esac
NEXT="<< .TagPrefix >>${major}.${minor}.$((patch + 1))"
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
git config user.name "loop release bot"
git config user.email "noreply@users.noreply.github.com"
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
echo "Pushed $NEXT."
@@ -1,56 +0,0 @@
name: "Loop: Triage"
# Generated by `micro loop init`. The feedback path of the evaluator: when the
# CI workflow ("<< .CIWorkflow >>") fails on a non-PR run, dispatch the agent
# (<< .AgentMention >>) with the instruction in .github/loop/prompts/triage.md
# to root-cause the failure and file scoped fix issues back into the queue — so
# failures become fixes with no human in the middle. Gated on << .TokenSecret >>.
on:
workflow_run:
workflows: ["<< .CIWorkflow >>"]
types: [completed]
permissions:
issues: write
concurrency:
group: loop-triage
cancel-in-progress: false
jobs:
triage:
# Only real failures on branch pushes/schedules — not PR-run failures, which
# the PR author already sees.
if: ${{ github.event.workflow_run.conclusion == 'failure' && github.event.workflow_run.event != 'pull_request' }}
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch triage
env:
GH_TOKEN: ${{ secrets.<< .TokenSecret >> || github.token }}
HAS_TOKEN: ${{ secrets.<< .TokenSecret >> != '' }}
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "<< .TokenSecret >> is not set — skipping."
exit 0
fi
PROMPT=".github/loop/prompts/triage.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: triage failed run $RUN_ID" \
--body "The '<< .CIWorkflow >>' workflow failed: $RUN_URL")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching triage."
{
echo "<< .AgentMention >>"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" -e "s#__RUNURL__#$RUN_URL#g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
@@ -1,14 +0,0 @@
<!--
The BUILDER prompt — the editable policy for the builder role. The workflow
prepends the agent @mention and substitutes __ISSUE__ before posting. Keep
__ISSUE__ literal.
-->
Build one increment for this repository, aligned to `.github/loop/NORTH_STAR.md`.
PICK THE WORK: take the highest-ranked item in `.github/loop/PRIORITIES.md` whose linked issue is still OPEN — that is your task, and its issue is the one you close. If the queue is empty or every item's issue is closed, pick the single highest-value improvement yourself.
Implement it, then VERIFY the project builds, tests, and lints (use the commands documented in the README or the CI workflow).
Open the PR YOURSELF from the shell — do NOT use a make_pr tool (it may be a no-op stub): `git switch -c loop/increment-__ISSUE__`, `git push -u origin loop/increment-__ISSUE__`, `gh pr create --base << .DefaultBranch >> --title "<title>" --body "<body; include 'Closes #<the item's issue>' so it leaves the queue, and 'Closes #__ISSUE__' for this run's tracker>"`, then `gh pr merge --squash --auto --delete-branch` so it lands once CI is green.
One concern per PR. Stay out of breaking public API changes and brand/positioning copy — surface those as notes for a human instead.
@@ -1,14 +0,0 @@
<!--
The COHERENCE (DevRel) prompt — the editable policy for the coherence role. The
workflow prepends the agent @mention and substitutes __ISSUE__ before posting.
Keep __ISSUE__ literal.
-->
Act as DevRel for this repository — keep the public story coherent and honest.
Audit the public surface — `README`, docs, and any website/blog — for coherence with `.github/loop/NORTH_STAR.md`: places that contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recently merged PRs). If the repo keeps a `CHANGELOG.md`, reconcile its `[Unreleased]` section against what actually merged.
SAFE factual-alignment and crispness fixes (and the CHANGELOG upkeep): open ONE PR and auto-merge it — `git switch -c loop/coherence-__ISSUE__`, `git push -u origin loop/coherence-__ISSUE__`, `gh pr create --base << .DefaultBranch >> --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`.
Brand / positioning / marketing copy and any opinion blog posts are NOT auto-merge material — the public voice stays with a human. Describe them in a comment on this issue, or open a PR WITHOUT enabling auto-merge, and leave it for review.
Post a short findings report as a comment on this issue (#__ISSUE__): what's aligned, what drifted, what you fixed. Open PRs yourself from the shell with `gh`; do not use a make_pr tool.
@@ -1,15 +0,0 @@
<!--
The PLANNER prompt. This file is the editable policy for the planner role —
change what the planner does by editing this text. The workflow prepends the
agent @mention and substitutes __ISSUE__ (this run's tracking issue) before
posting it. Keep __ISSUE__ literal.
-->
Act as the planner for this repository.
(1) Read `.github/loop/NORTH_STAR.md` for direction, then scan recently merged PRs and open issues so the queue reflects reality — drop done items, don't re-queue work already in flight.
(2) Maintain a SINGLE ranked queue in `.github/loop/PRIORITIES.md`, highest-value first, each item linking a scoped, CI-verifiable issue (#N). For any prioritized gap that has no issue, file one: `gh issue create --title "<scoped task>" --body "<goal, scope, acceptance criteria>"`.
(3) If the ranking actually changed, open ONE PR for `PRIORITIES.md`: `git switch -c loop/planner-__ISSUE__`, `git push -u origin loop/planner-__ISSUE__`, `gh pr create --base << .DefaultBranch >> --title "<title>" --body "<summary, Closes #__ISSUE__>"`, then `gh pr merge --squash --auto --delete-branch`. If the queue is already accurate, just close this issue (`gh issue close __ISSUE__`).
Do NOT make breaking or architectural changes yourself — surface those as notes for a human. Open the PR yourself from the shell with `gh`; do not use a make_pr tool (it may be a no-op stub).
@@ -1,14 +0,0 @@
<!--
The TRIAGE prompt — the editable policy for the triage role. The workflow
prepends the agent @mention and substitutes __ISSUE__ (this tracking issue) and
__RUNURL__ (the failed CI run) before posting. Keep both literal.
-->
Triage the failed CI run at __RUNURL__.
Read the logs and root-cause each distinct failure. DEDUPE against open issues — if a failure matches an existing issue, comment "recurred" there instead of filing a duplicate.
For each genuine, self-contained defect, file a scoped issue (`gh issue create --title "<scoped fix>" --body "<root cause, where, acceptance criteria>"`) so the planner/builder can pick it up and the next CI run verifies it.
IGNORE transient flakes — network blips, provider outages, timeouts with no code cause. Anything needing a breaking or architectural change: label it `needs-human` and describe it, rather than auto-filing it as a routine fix.
Close this issue (`gh issue close __ISSUE__`) when triage is done.
-1
View File
@@ -13,7 +13,6 @@ import (
_ "go-micro.dev/v6/cmd/micro/cli/deploy"
_ "go-micro.dev/v6/cmd/micro/flow"
_ "go-micro.dev/v6/cmd/micro/inspect"
_ "go-micro.dev/v6/cmd/micro/loop"
_ "go-micro.dev/v6/cmd/micro/mcp"
_ "go-micro.dev/v6/cmd/micro/resource"
_ "go-micro.dev/v6/cmd/micro/run"
-246
View File
@@ -1,246 +0,0 @@
// Agent Ollama — a self-contained agent powered by Ollama Cloud.
//
// This example demonstrates the full harness loop — service tools, custom
// tools, agent memory, guardrails, and streaming — using the Ollama
// provider with gpt-oss:120b on Ollama Cloud.
//
// It creates a "knowledge" service with two endpoints (Add, Search) that
// the agent discovers as tools, plus a custom "current_time" tool. The
// agent answers natural-language questions by calling those tools.
//
// Run (Ollama Cloud — default):
//
// OLLAMA_API_KEY=your-key go run main.go
//
// Run (local Ollama):
//
// OLLAMA_BASE_URL=http://localhost:11434 \
// OLLAMA_MODEL=llama3.2 \
// go run main.go
package main
import (
"context"
"encoding/json"
"fmt"
"os"
"strings"
"sync"
"time"
"go-micro.dev/v6"
"go-micro.dev/v6/agent"
)
// ---------------------------------------------------------------------------
// knowledge service — a tiny in-memory knowledge base
// ---------------------------------------------------------------------------
type KnowledgeEntry struct {
ID string `json:"id" description:"Unique entry identifier"`
Topic string `json:"topic" description:"Topic or category"`
Content string `json:"content" description:"The knowledge content"`
}
type AddKnowledgeRequest struct {
Topic string `json:"topic" description:"Topic or category (required)"`
Content string `json:"content" description:"The knowledge content (required)"`
}
type AddKnowledgeResponse struct {
Entry *KnowledgeEntry `json:"entry" description:"The added entry"`
}
type SearchKnowledgeRequest struct {
Topic string `json:"topic,omitempty" description:"Filter by topic (optional)"`
Keyword string `json:"keyword,omitempty" description:"Search keyword in content (optional)"`
}
type SearchKnowledgeResponse struct {
Entries []*KnowledgeEntry `json:"entries" description:"Matching entries"`
}
type KnowledgeService struct {
mu sync.RWMutex
entries []*KnowledgeEntry
nextID int
}
// Add stores a new knowledge entry.
//
// @example {"topic": "go", "content": "Go interfaces are implicit."}
func (s *KnowledgeService) Add(ctx context.Context, req *AddKnowledgeRequest, rsp *AddKnowledgeResponse) error {
s.mu.Lock()
defer s.mu.Unlock()
s.nextID++
e := &KnowledgeEntry{
ID: fmt.Sprintf("kb-%d", s.nextID),
Topic: req.Topic,
Content: req.Content,
}
s.entries = append(s.entries, e)
rsp.Entry = e
return nil
}
// Search finds knowledge entries by topic or keyword.
//
// @example {"topic": "go"}
// @example {"keyword": "interface"}
func (s *KnowledgeService) Search(ctx context.Context, req *SearchKnowledgeRequest, rsp *SearchKnowledgeResponse) error {
s.mu.RLock()
defer s.mu.RUnlock()
for _, e := range s.entries {
if req.Topic != "" && !strings.EqualFold(e.Topic, req.Topic) {
continue
}
if req.Keyword != "" && !strings.Contains(strings.ToLower(e.Content), strings.ToLower(req.Keyword)) {
continue
}
rsp.Entries = append(rsp.Entries, e)
}
return nil
}
// ---------------------------------------------------------------------------
// main
// ---------------------------------------------------------------------------
func main() {
// Ollama Cloud is the default. Override with env vars for local Ollama.
baseURL := os.Getenv("OLLAMA_BASE_URL")
if baseURL == "" {
baseURL = "https://ollama.com/v1"
}
model := os.Getenv("OLLAMA_MODEL")
if model == "" {
model = "gpt-oss:120b"
}
apiKey := os.Getenv("OLLAMA_API_KEY")
fmt.Println("╔══════════════════════════════════════════╗")
fmt.Println("║ Ollama-Powered Go Micro Agent ║")
fmt.Println("╚══════════════════════════════════════════╝")
fmt.Println()
fmt.Printf(" Ollama URL: %s\n", baseURL)
fmt.Printf(" Model: %s\n", model)
if apiKey != "" {
fmt.Printf(" API Key: (set)\n")
} else {
fmt.Printf(" API Key: (none — set OLLAMA_API_KEY)\n")
}
fmt.Println()
// 1. Start the knowledge service. Its handlers become agent tools.
svc := micro.NewService("knowledge")
svc.Handle(new(KnowledgeService))
go svc.Run()
// Give the service a moment to register.
time.Sleep(2 * time.Second)
// 2. Create the agent. It discovers the knowledge service endpoints
// as tools automatically, plus gets a custom "current_time" tool.
ag := micro.NewAgent("ollama-assistant",
micro.AgentServices("knowledge"),
micro.AgentPrompt(
"You are a helpful knowledge assistant. You can search and add to "+
"a knowledge base using the knowledge service tools. "+
"When asked about the current time, use the current_time tool. "+
"Be concise and factual.",
),
micro.AgentProvider("ollama"),
micro.AgentModel(model),
micro.AgentAPIKey(apiKey),
micro.AgentBaseURL(baseURL),
micro.AgentMaxSteps(10),
micro.AgentLoopLimit(3),
// Custom tool — any function, not tied to a service.
agent.WithTool(
"current_time",
"Get the current date and time in a human-readable format",
map[string]any{
"timezone": map[string]any{
"type": "string",
"description": "Optional timezone (defaults to local)",
},
},
func(ctx context.Context, input map[string]any) (string, error) {
tz, _ := input["timezone"].(string)
if tz == "" {
return time.Now().Format("2006-01-02 15:04:05 MST"), nil
}
loc, err := time.LoadLocation(tz)
if err != nil {
return "", fmt.Errorf("unknown timezone: %s", tz)
}
return time.Now().In(loc).Format("2006-01-02 15:04:05 MST"), nil
},
),
)
// 3. Seed initial knowledge via the agent's first question.
questions := []string{
"What time is it now?",
"Add a new knowledge entry: topic 'go', content 'Go interfaces are implicit — a type implements an interface by having the required methods.'",
"Add another entry: topic 'go', content 'Go is a statically typed, compiled language designed at Google.'",
"Add another entry: topic 'ai', content 'Large language models generate text by predicting the next token in a sequence.'",
"Search the knowledge base for entries about Go.",
"Search for everything in the knowledge base.",
}
fmt.Println("─── Agent Demo ───")
fmt.Println()
for i, q := range questions {
fmt.Printf("Q%d: %s\n", i+1, q)
fmt.Print("A: ")
resp, err := ag.Ask(context.Background(), q)
if err != nil {
fmt.Printf("error: %v\n", err)
fmt.Println()
continue
}
// Show tool calls the agent made.
if len(resp.ToolCalls) > 0 {
for _, tc := range resp.ToolCalls {
args, _ := json.Marshal(tc.Input)
fmt.Printf(" [tool] %s(%s)\n", tc.Name, string(args))
}
}
fmt.Println(resp.Reply)
if resp.Reply == "" && len(resp.ToolCalls) == 0 {
fmt.Println("(no response)")
}
fmt.Println()
}
// 4. Streaming demonstration.
fmt.Println("─── Streaming Demo ───")
fmt.Println()
streamQ := "Explain what Go Micro is in two sentences."
fmt.Printf("Q: %s\n", streamQ)
fmt.Print("A: ")
stream, err := ag.Stream(context.Background(), streamQ)
if err != nil {
fmt.Printf("stream error: %v\n", err)
} else {
for {
chunk, err := stream.Recv()
if err != nil {
break
}
if chunk.Reply != "" {
fmt.Print(chunk.Reply)
}
}
fmt.Println()
}
fmt.Println()
fmt.Println("Done.")
}
+5 -15
View File
@@ -19,27 +19,17 @@ Actions instead of subagents. Each role is a workflow:
| Role | Workflow (action name) | What it does |
|------|------------------------|--------------|
| **Planner** | `loop-planner.yml`*Loop: Planner* | Tracks live state, prioritizes the roadmap + an internal scan, and maintains the ranked queue in [`.github/loop/PRIORITIES.md`](../../.github/loop/PRIORITIES.md). Decides *what*. |
| **Planner** | `loop-architect.yml`*Loop: Architect (Planner)* | Tracks live state, prioritizes the roadmap + an internal scan, and maintains the ranked queue in [`PRIORITIES.md`](PRIORITIES.md). Decides *what*. |
| **Generator** | `loop-builder.yml`*Loop: Builder (Generator)* | Builds the top open queue item as a single-concern PR (via Codex) and self-merges on green CI. Does the work. |
| **Evaluator** | `harness.yml`*Harness (E2E)*, plus the CI gate (`tests.yaml`, `lint.yaml`) | Grades every change: the mock harness + unit/lint on each push/PR, and real-model conformance hourly. A *separate* grader — never the generator judging itself. |
| **Evaluator → feedback** | `loop-triage.yml`*Loop: Triage (Evaluator feedback)* | On harness failure, root-causes, dedupes, and files scoped fix issues back into the planner's queue. The hill-climbing feedback path. |
| **Coherence** | `loop-coherence.yml`*Loop: Coherence* | Keeps README/website/docs/blog aligned with the North Star, keeps `CHANGELOG.md` living (reconciling `[Unreleased]` against merged PRs and rolling it into version headings as tags cut), and drafts the changelog blog post. |
| **Coherence** | `loop-devrel.yml`*Loop: DevRel* | Keeps README/website/docs/blog aligned with the North Star, keeps `CHANGELOG.md` living (reconciling `[Unreleased]` against merged PRs and rolling it into version headings as tags cut), and drafts the changelog blog post. |
| **Release** | `loop-release.yml`*Loop: Release (daily patch)* | Cuts a daily patch tag when master has new commits, so the *installable* framework tracks the loop's improvements (triggers `release.yml`/goreleaser). Minor/major bumps stay with the human. |
Generation is separated from evaluation on purpose: an agent grading its own work
reliably over-rates it, so **CI and the harness — not the builder — are the gate**.
The human sets direction and owns the calls that need taste (see Guardrails).
> **Go Micro dogfoods its own tool.** These `.github/workflows/loop-*.yml` files
> are generated by [`micro loop`](../../cmd/micro/loop) (`micro loop init --roles all`).
> The workflows are the *mechanism*; each role's instruction is the editable
> *policy* in [`.github/loop/prompts/`](../../.github/loop/prompts), and the
> direction/queue live in [`.github/loop/NORTH_STAR.md`](../../.github/loop/NORTH_STAR.md)
> and [`.github/loop/PRIORITIES.md`](../../.github/loop/PRIORITIES.md). To change
> what a role does, edit its prompt — not the YAML. Re-run `micro loop init
> --roles all --force` to regenerate the workflow mechanics (it won't clobber the
> North Star, queue, or prompts).
## Autonomy
Full autonomy, **no approval gates**. Each increment: Claude Code picks the work,
@@ -157,7 +147,7 @@ The hourly loop ships increments; two periodic passes keep the *whole* heading i
the right direction. Both use the same mechanism (fresh issue → `@codex` →
output) but produce direction and coherence, not just code.
- **Coherence (DevRel) — daily** (`.github/workflows/loop-coherence.yml`, prompt `.github/loop/prompts/coherence.md`). Audits the public
- **DevRel — daily** (`.github/workflows/loop-devrel.yml`). Audits the public
surface (README, website landing + docs, blog) for coherence with the North
Star, README crispness, and blog-worthy material. It also keeps `CHANGELOG.md`
living: each run reconciles the `[Unreleased]` section against the PRs that
@@ -170,12 +160,12 @@ output) but produce direction and coherence, not just code.
the `CHANGELOG.md` upkeep* — auto-merge like any increment; brand/positioning
copy and the changelog blog post are opened as a PR (or surfaced in the report)
and left for the human to review/merge — blog voice stays with the human.
- **Planner (Architect) — continuous (hourly)** (`.github/workflows/loop-planner.yml`, prompt `.github/loop/prompts/planner.md`).
- **Architect — continuous (hourly)** (`.github/workflows/loop-architect.yml`).
The *founder lens*, running alongside the builders. Each run it **tracks live
state** (what just merged, what's in flight), **prioritizes the roadmap**
(`ROADMAP.md`, Now → Next → Later) against an internal scan (lifecycle gaps, API
coherence and seams, dev-UX friction, missing pieces, drift/realignment), and
**maintains the ranked queue** in [`.github/loop/PRIORITIES.md`](../../.github/loop/PRIORITIES.md) — re-ranking
**maintains the ranked queue** in [`PRIORITIES.md`](PRIORITIES.md) — re-ranking
to reflect reality, backing each top item with a scoped issue, and posting an
assessment. It runs at `:59`, just before the `:29` increment, so it
re-prioritizes and *then* the loop builds the new top. **Its output is the
+31
View File
@@ -0,0 +1,31 @@
# Priorities
The ranked work queue for the autonomous improvement loop. The
**architecture-review** pass (the *architect*) owns this file: each run it turns
the [roadmap](../../ROADMAP.md) plus an internal scan (gaps in the
services → agents → workflows lifecycle, API coherence, drift, tech debt, test and
DX friction) into a single ordered list — highest-value first — and links each
item to a tracking issue. The hourly **continuous-improvement** pass works the
**top item whose issue is still open**. So the architect decides *what*, and the
increment loop *builds* it.
**Reading / editing.** An item is done when its linked issue closes (the increment
that builds it adds `Closes #<issue>`). Roadmap phase (Now → Next → Later) is the
primary ordering; internal findings are interleaved by value, not kept in a
separate list. The human can reorder this list — or the issues — at any time to
redirect the loop; direction always wins.
**Off-limits to the loop** (the architect proposes these as notes, never as queue
items the loop can auto-merge): brand/positioning copy, breaking public-API
changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Add a CI-verified 0→hero reference app** ([#3623](https://github.com/micro/go-micro/issues/3623)) — the first-agent walkthrough smoke test shipped, so the adoption bottleneck moves from proving one tutorial command to giving developers one maintained real-world path through services → agents → workflows. Keep this first because the current strategic goal is developer adoption: a new user should not have to assemble the 0→hero story from fragmented examples before they can run, chat, inspect, and see a workflow.
2. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — recent increment PRs closed the previous top reliability items (first-agent preflight, first-agent walkthrough smoke, universe notify finalization, and plan/delegate side-effect dedupe). This is now the highest scoped Now-phase interop/conformance gap: it protects the A2A streaming promise developers see in the README and site without letting internal protocol work outrank the on-ramp.
3. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — the highest-value remaining Now-phase resilience gap is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries and live-provider deadline tuning are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
4. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, and fixed provider-emitted text tool calls. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
5. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this is a forward interop investment, not a Now-phase blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue away from adoption, resilience, or observability. Keep it additive and opt-in while the AP2/FIDO work settles.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
-61
View File
@@ -1,61 +0,0 @@
#!/usr/bin/env bash
# Smoke-test the documented install.sh path without network access.
# It builds the local CLI, packages it like a release archive, installs it into a
# temporary bin directory through internal/scripts/install.sh, then checks the
# first-run command boundaries shown in the getting-started docs.
set -euo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
ARCHIVE_DIR="$TMP_DIR/archive"
INSTALL_DIR="$TMP_DIR/install"
ARCHIVE="$TMP_DIR/micro-local.tar.gz"
mkdir -p "$ARCHIVE_DIR" "$INSTALL_DIR"
CGO_ENABLED=0 go build -o "$ARCHIVE_DIR/micro" ./cmd/micro
chmod +x "$ARCHIVE_DIR/micro"
tar -C "$ARCHIVE_DIR" -czf "$ARCHIVE" micro
MICRO_INSTALL_DIR="$INSTALL_DIR" \
MICRO_INSTALL_ARCHIVE="$ARCHIVE" \
MICRO_VERSION="local-smoke" \
PATH="$INSTALL_DIR:$PATH" \
"$ROOT/internal/scripts/install.sh" > "$TMP_DIR/install.out"
MICRO="$INSTALL_DIR/micro"
if [[ ! -x "$MICRO" ]]; then
echo "installed micro binary not found at $MICRO" >&2
cat "$TMP_DIR/install.out" >&2
exit 1
fi
require_output() {
local description=$1
local expected=$2
shift 2
local output
if ! output=$("$MICRO" "$@" 2>&1); then
echo "micro $* failed while checking $description" >&2
echo "$output" >&2
exit 1
fi
if [[ "$output" != *"$expected"* ]]; then
echo "micro $* missing expected text '$expected' for $description" >&2
echo "$output" >&2
exit 1
fi
}
require_output "version" "micro version" --version
require_output "root help" "COMMANDS" --help
require_output "service scaffold" "micro new" new --help
require_output "first-agent preflight" "preflight" agent preflight --help
require_output "local runtime" "micro run" run --help
require_output "agent chat" "micro chat" chat --help
require_output "agent inspection" "micro inspect agent" inspect agent --help
require_output "flow inspection" "micro inspect flow" inspect flow --help
echo "✓ install smoke path verified"
+11 -63
View File
@@ -54,64 +54,24 @@ type ListResponse struct {
}
type TaskService struct {
mu sync.Mutex
tasks []*Task
byTitle map[string]*Task
nextID int
mu sync.Mutex
tasks []*Task
nextID int
}
// Add creates a new task with the given title. Replayed live-model tool calls
// are idempotent by launch task title so the conformance harness proves exactly
// one durable side effect per intended task even if a provider resends a call.
// Add creates a new task with the given title.
// @example {"title": "Design"}
func (s *TaskService) Add(ctx context.Context, req *AddRequest, rsp *AddResponse) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.byTitle == nil {
s.byTitle = map[string]*Task{}
}
key := launchTaskKey(req.Title)
if t := s.byTitle[key]; t != nil {
rsp.Task = t
fmt.Printf(" \033[32m[task]\033[0m reused %s %q\n", t.ID, t.Title)
return nil
}
s.nextID++
t := &Task{ID: fmt.Sprintf("task-%d", s.nextID), Title: canonicalLaunchTitle(req.Title)}
t := &Task{ID: fmt.Sprintf("task-%d", s.nextID), Title: req.Title}
s.tasks = append(s.tasks, t)
s.byTitle[key] = t
rsp.Task = t
fmt.Printf(" \033[32m[task]\033[0m created %s %q\n", t.ID, t.Title)
return nil
}
func launchTaskKey(title string) string {
s := strings.ToLower(strings.TrimSpace(title))
switch {
case strings.Contains(s, "design"):
return "design"
case strings.Contains(s, "build"):
return "build"
case strings.Contains(s, "ship"):
return "ship"
default:
return s
}
}
func canonicalLaunchTitle(title string) string {
switch launchTaskKey(title) {
case "design":
return "Design"
case "build":
return "Build"
case "ship":
return "Ship"
default:
return strings.TrimSpace(title)
}
}
// List returns all tasks.
// @example {}
func (s *TaskService) List(ctx context.Context, req *ListRequest, rsp *ListResponse) error {
@@ -135,29 +95,17 @@ type SendResponse struct {
Sent bool `json:"sent"`
}
type NotifyService struct {
mu sync.Mutex
sent int
bySend map[string]bool
mu sync.Mutex
sent int
}
// Send delivers a notification message to a recipient. Duplicate delivery
// attempts for the same recipient/message are treated as successful replays
// without producing another side effect.
// Send delivers a notification message to a recipient.
// @example {"to": "owner@acme.com", "message": "ready"}
func (s *NotifyService) Send(ctx context.Context, req *SendRequest, rsp *SendResponse) error {
s.mu.Lock()
if s.bySend == nil {
s.bySend = map[string]bool{}
}
key := strings.ToLower(strings.TrimSpace(req.To)) + "\x00" + strings.ToLower(strings.TrimSpace(req.Message))
if !s.bySend[key] {
s.bySend[key] = true
s.sent++
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s message=%q\n", req.To, req.Message)
} else {
fmt.Printf(" \033[35m[notify]\033[0m reused to=%s message=%q\n", req.To, req.Message)
}
s.sent++
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s message=%q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
@@ -333,7 +281,7 @@ func runPlanDelegate(provider string) error {
agent.Name("conductor"),
agent.Address("127.0.0.1:0"),
agent.Services("task"),
agent.Prompt("You coordinate launch work. Plan first, create exactly one Design task, one Build task, and one Ship task, then delegate exactly one readiness notification to the \"comms\" agent. Do not create duplicate tasks and do not send notifications yourself."),
agent.Prompt("You coordinate launch work. Plan first, create tasks, and delegate notifications to the \"comms\" agent."),
agent.Provider(provider), agent.APIKey(apiKey),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
agent.WithCheckpoint(conductorCheckpoint),
@@ -219,35 +219,3 @@ func TestZeroToHeroContract(t *testing.T) {
t.Fatalf("0→hero harness: %v", err)
}
}
func TestTaskServiceAddIsIdempotentForLaunchTitles(t *testing.T) {
svc := new(TaskService)
for _, title := range []string{"Design", "design task", "Build", "Build launch task", "Ship", "ship readiness"} {
var rsp AddResponse
if err := svc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
if rsp.Task == nil {
t.Fatalf("Add(%q) returned nil task", title)
}
}
if got := svc.count(); got != 3 {
t.Fatalf("task count = %d, want 3 after duplicate launch-title replays", got)
}
}
func TestNotifyServiceSendIsIdempotentForDuplicateDelivery(t *testing.T) {
svc := new(NotifyService)
for i := 0; i < 3; i++ {
var rsp SendResponse
if err := svc.Send(context.Background(), &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp); err != nil {
t.Fatalf("Send attempt %d: %v", i+1, err)
}
if !rsp.Sent {
t.Fatalf("Send attempt %d reported Sent=false", i+1)
}
}
if got := svc.count(); got != 1 {
t.Fatalf("notify count = %d, want 1 after duplicate delivery replays", got)
}
}
+12 -49
View File
@@ -117,22 +117,18 @@ type Notify struct {
// Send delivers a notification.
// @example {"to": "buyer@acme.com", "message": "Your order is confirmed"}
func (s *Notify) Send(_ context.Context, req *SendRequest, rsp *SendResponse) error {
keys := notificationDedupeKeys(req)
key := req.To + "\x00" + req.Message
s.mu.Lock()
if s.seen == nil {
s.seen = make(map[string]struct{})
}
for _, key := range keys {
if _, ok := s.seen[key]; ok {
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m 📨 duplicate suppressed to=%s %q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
}
for _, key := range keys {
s.seen[key] = struct{}{}
if _, ok := s.seen[key]; ok {
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m 📨 duplicate suppressed to=%s %q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
s.seen[key] = struct{}{}
s.mu.Unlock()
atomic.AddInt64(&s.sent, 1)
@@ -141,21 +137,6 @@ func (s *Notify) Send(_ context.Context, req *SendRequest, rsp *SendResponse) er
return nil
}
func notificationDedupeKeys(req *SendRequest) []string {
keys := []string{req.To + "\x00" + req.Message}
message := strings.ToLower(req.Message)
if strings.Contains(message, "confirm") {
// Live models occasionally emit equivalent confirmation copy more than
// once while a resumed checkout is completing (for example, a concise
// "order-1 confirmed" followed by a fuller buyer-facing sentence). The
// harness has one checkout order, so treat confirmation messages to the
// same buyer as the same side effect while preserving exact-message
// idempotency for all other notifications.
keys = append(keys, req.To+"\x00confirmation")
}
return keys
}
func dispatchNotifyStep(agentName string, ntf *Notify) flow.StepFunc {
dispatch := flow.Dispatch(agentName)
return func(ctx context.Context, in flow.State) (flow.State, error) {
@@ -209,10 +190,6 @@ func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
if strings.Contains(strings.ToLower(req.Prompt), "a2a reachability probe") {
return &ai.Response{Answer: "concierge reachable"}, nil
}
// The concierge is asked to notify the buyer. Find the notify tool and call it.
for _, t := range req.Tools {
if strings.Contains(t.Name, "Send") && m.opts.ToolHandler != nil {
@@ -244,19 +221,10 @@ func check(cond bool, format string, args ...any) {
// a2aReachable calls the named agent through the gateway using the A2A
// client — exercising both directions of the protocol — and reports
// whether the agent replied. The probe is intentionally side-effect-free:
// the checkout flow already proved notify tool execution, and reachability
// should not depend on a live model deciding to send another notification.
func a2aReachable(ctx context.Context, base, agent string) error {
probe := "A2A reachability probe only. Reply with the words concierge reachable. Do not call tools or send notifications."
reply, err := a2a.NewClient(base+"/agents/"+agent).Send(ctx, probe)
if err != nil {
return err
}
if strings.TrimSpace(reply) == "" {
return fmt.Errorf("empty A2A reply")
}
return nil
// whether the agent replied.
func a2aReachable(base, agent string) bool {
reply, err := a2a.NewClient(base+"/agents/"+agent).Send(context.Background(), "notify the buyer")
return err == nil && reply != ""
}
func providerKey(provider string) string {
@@ -432,12 +400,7 @@ func runUniverse(provider string) int {
// translated to its Agent.Chat RPC.
gw := httptest.NewServer(a2a.New(a2a.Options{Registry: reg, Client: cl, BaseURL: "http://gw"}).Handler())
defer gw.Close()
beforeA2A := atomic.LoadInt64(&ntf.sent)
reachCtx, cancelReach := context.WithTimeout(ctx, 10*time.Second)
reachErr := a2aReachable(reachCtx, gw.URL, "concierge")
cancelReach()
check(reachErr == nil, "concierge agent reachable over the A2A gateway: %v", reachErr)
check(atomic.LoadInt64(&ntf.sent) == beforeA2A, "A2A reachability probe did not send extra buyer notifications")
check(a2aReachable(gw.URL, "concierge"), "concierge agent reachable over the A2A gateway")
fmt.Println("\n\033[1m> shutting down the universe\033[0m")
// defers stop the agent and flow (deregistering them).
-22
View File
@@ -68,25 +68,3 @@ func TestNotifyStepCompletesAfterObservedSideEffectTimeout(t *testing.T) {
t.Fatalf("notifications sent after duplicate = %d, want 1", got)
}
}
func TestNotifySuppressesEquivalentConfirmationMessages(t *testing.T) {
ntf := new(Notify)
ctx := context.Background()
for _, req := range []*SendRequest{
{To: "buyer@acme.com", Message: "Your order order-1 has been confirmed."},
{To: "buyer@acme.com", Message: "order-1 confirmed"},
} {
var rsp SendResponse
if err := ntf.Send(ctx, req, &rsp); err != nil {
t.Fatalf("send notification %q: %v", req.Message, err)
}
if !rsp.Sent {
t.Fatalf("send notification %q did not report sent", req.Message)
}
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("equivalent confirmation notifications sent = %d, want 1", got)
}
}
+4 -6
View File
@@ -27,17 +27,15 @@ and A2A with only the LLM mocked.
## Local and CI entry points
The default GitHub harness workflow runs this script on every push and pull
request after the install smoke check and 0→1 scaffold contract. Developers can
verify the installer seam alone with `make install-smoke`, or run the same
no-secret contract locally with:
request after the 0→1 scaffold contract. Developers can run the same no-secret
contract locally with:
```sh
make harness
```
That target intentionally exercises the install script smoke path, both 0→1
scaffold variants, the 0→hero scenario, the event-driven agent-flow harness, and
mock provider conformance, so
That target intentionally exercises both 0→1 scaffold variants, the 0→hero
scenario, the event-driven agent-flow harness, and mock provider conformance, so
the public scaffold → run/chat → inspect → deploy lifecycle stays executable
outside CI as well. Live provider checks remain separate and gated by configured
API keys (`make provider-conformance` or the scheduled/manual CI job).
@@ -17,7 +17,6 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
"go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1",
"go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1",
"go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1",
"go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1",
"./internal/harness/zero-to-hero-ci/run.sh",
"go run ./internal/harness/agent-flow",
"make provider-conformance-mock",
+1 -4
View File
@@ -10,8 +10,5 @@ go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroC
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
# Deterministic no-secret reference scenarios. These use the real Go Micro
# runtime and mock only the LLM provider. The support example is the maintained
# runnable 0→hero app; keep it in this CI path so its documented run/chat/inspect
# journey cannot drift from the framework.
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
# runtime and mock only the LLM provider.
go test ./internal/harness/universe ./internal/harness/plan-delegate -run 'Test.*Harness|TestPlanDelegateEndToEnd|TestPlanDelegateFlowHandoff' -count=1
+4 -11
View File
@@ -23,12 +23,8 @@ case $OS in
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
# Determine install directory. MICRO_INSTALL_DIR is intended for CI/local smoke
# tests that verify the installer without writing to a real system directory.
if [ -n "${MICRO_INSTALL_DIR:-}" ]; then
INSTALL_DIR="$MICRO_INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
elif [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
# Determine install directory
if [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
INSTALL_DIR="/usr/local/bin"
else
INSTALL_DIR="$HOME/.local/bin"
@@ -48,11 +44,8 @@ fi
TMP_DIR=$(mktemp -d)
TMP_FILE="${TMP_DIR}/micro.tar.gz"
# Download, or use a local release archive supplied by the deterministic smoke
# harness. The default path still fetches the documented GitHub release artifact.
if [ -n "${MICRO_INSTALL_ARCHIVE:-}" ]; then
cp "$MICRO_INSTALL_ARCHIVE" "$TMP_FILE"
elif command -v curl &> /dev/null; then
# Download
if command -v curl &> /dev/null; then
curl -fsSL "$URL" -o "$TMP_FILE"
elif command -v wget &> /dev/null; then
wget -q "$URL" -O "$TMP_FILE"
+4 -5
View File
@@ -5,10 +5,6 @@ core:
url: /docs/getting-started.html
- title: AI Integration
url: /docs/ai-integration.html
- title: Your First Agent
url: /docs/guides/your-first-agent.html
- title: 0→hero Reference
url: /docs/guides/zero-to-hero.html
- title: MCP & AI Agents
url: /docs/mcp.html
- title: Deployment
@@ -36,8 +32,12 @@ examples:
- title: Real-World Examples
url: /docs/examples/realworld/
guides:
- title: Your First Agent
url: /docs/guides/your-first-agent.html
- title: Debugging your agent
url: /docs/guides/debugging-agents.html
- title: 0→hero Reference
url: /docs/guides/zero-to-hero.html
- title: Plan & Delegate
url: /docs/guides/plan-delegate.html
- title: Agent Guardrails
@@ -78,7 +78,6 @@ project:
- title: Server (optional)
url: /docs/server.html
search_order:
- /docs/guides/your-first-agent.html
- /docs/guides/zero-to-hero.html
- /docs/guides/debugging-agents.html
- /docs/getting-started.html
-108
View File
@@ -1,108 +0,0 @@
---
layout: blog
title: "The Loop, Shipped: Introducing micro loop"
permalink: /blog/33
description: "The autonomous loop that builds Go Micro is now a command. micro loop init drops a planner, builder, triage, coherence, and release pipeline into any repository — GitHub Actions as the runtime, editable prompt files as the policy. And Go Micro now runs on it."
---
# The Loop, Shipped: Introducing `micro loop`
*July 2, 2026 • By the Go Micro Team*
A few weeks ago we wrote [How Go Micro Builds Itself](/blog/31): a loop of two AI agents — Codex writing scoped increments, Claude Code orchestrating and reviewing, a human setting direction — kept honest by CI. It was our own thesis, *an agent operating a system*, pointed at the repository that defines the thesis.
People asked the obvious question: **can I run that on my repo?**
Now you can. `micro loop init` drops the whole pipeline into any repository, and — the part that makes it real rather than aspirational — Go Micro itself now runs on it. The workflows that maintain this project are generated by the same command we're shipping to you.
## It's not a binary. It's CI plus prompts.
The first thing to understand is what the loop *is*, physically, because it's not what people expect. There is almost no program here. The loop is a handful of GitHub Actions workflows and a few Markdown files. **GitHub Actions is the runtime.** Nothing compiled sits in a server "running the loop."
So `micro loop` the command is not the loop. It's a scaffolder. `micro loop init` writes the workflows and the prompts into your repo and exits. From then on, GitHub runs everything on a schedule.
That reframing is the whole design:
- **The workflows are the *mechanism*** — open a fresh tracking issue, summon an agent, let it open a PR, merge on green CI. This is generic. It's the same for every repo.
- **The prompt files are the *policy*** — what the agent is actually told to do. These live in `.github/loop/prompts/<role>.md`, and they're yours to edit.
Separating the two is what lets a tool be both general and specific. The mechanics never change; the instructions are entirely under your control. You steer the loop by editing prose, not YAML.
## The five roles
A full loop has five roles, each a workflow that dispatches an agent (or, for release, just cuts a tag):
| Role | What it does |
|------|--------------|
| **planner** | Reads your direction, scans what merged and what's open, and maintains a ranked queue in `.github/loop/PRIORITIES.md`. Decides *what*. |
| **builder** | Takes the top open item from the queue, implements it as a single-concern PR, and enables auto-merge so it lands the moment CI is green. Does the work. |
| **triage** | When CI fails on a branch, root-causes the failure and files scoped fix issues back into the queue. The feedback path. |
| **coherence** | Keeps README, docs, and `CHANGELOG.md` aligned with your North Star; drafts release notes. Optional. |
| **release** | Cuts the next patch tag when the branch has new commits, so the installable artifact tracks the loop. Optional. |
This is the [planner / generator / evaluator](https://www.anthropic.com/engineering/harness-design-long-running-apps) shape of a long-running agent harness, distributed across GitHub Actions instead of subagents. Generation is deliberately separated from evaluation: an agent grading its own work reliably over-rates it, so **CI — not the builder — is the gate.**
## Quickstart
```bash
micro loop init # planner, builder, triage
micro loop init --roles all # add coherence + release
micro loop verify # check the wiring
```
`init` writes the workflows, a `NORTH_STAR.md` (your direction) and `PRIORITIES.md` (your queue), and the per-role prompt files. Then there are exactly two things the CLI can't do for you — and `micro loop verify` reminds you of both:
1. **Add a token secret.** The agent ignores `@mentions` from the `github-actions` bot, so dispatch has to post as a real user via a personal access token stored as a repo secret. Until it's set, the workflows deliberately no-op.
2. **Set branch protection.** Require your CI checks with **zero** approving reviews, so the builder's auto-merge lands PRs the instant CI is green. That green-CI gate is the loop's only safety mechanism — which means it's worth saying plainly: **the loop is only as good as your tests.**
Edit `.github/loop/NORTH_STAR.md` to point it, tune the prompts if you like, commit, and it's running.
## The decisions that matter (and the things that broke)
Building this taught us that the hard part of an autonomous loop isn't the agent — it's the wiring around it. A few decisions are load-bearing, and we learned most of them by getting them wrong first.
**Agent-agnostic by `@mention`.** The dispatch summons an agent by mentioning it on an issue (`@codex` by default; `--agent` to change it). Any coding agent that responds to an issue mention and can run `gh` fits. The mechanics don't care which one.
**A fresh issue every run.** Agents derive their PR branch name from the triggering issue. Reuse one tracker issue and every run collapses onto a single branch name — the first PR opens, the rest silently collide and never appear. A unique issue per run gives each increment a clean branch. We found this the way you'd expect: a loop that looked healthy but quietly stopped producing PRs.
**Bots can't summon bots.** Codex ignores comments authored by `github-actions[bot]`, so the dispatch has to post as a user. That's the whole reason the token secret exists, and why the loop no-ops without it rather than pile up ignored comments.
**Don't let checkout clobber your token.** Our nightly release action once failed to push a tag with a permission error — despite having a valid token — because `actions/checkout` persists the default `GITHUB_TOKEN` as a git credential that overrode the personal token on the push. The generated release workflow ships with that fix baked in (`persist-credentials: false`), so it can't bite you.
None of these are exotic. They're exactly the papercuts you'd hit assembling this yourself over a week, and they're the reason a scaffolder earns its place: the defaults encode the lessons.
## Go Micro runs on it
Here's the part we care about most. This repository's loop is no longer hand-written. We ran `micro loop init --roles all` on Go Micro itself, moved our queue and direction into `.github/loop/`, and preserved our own richer instructions — the architect's founder-lens prompt, the adoption steer, the harness-failure triage, the changelog pass — as the editable prompt files. Behavior is identical; only the mechanism is now generated.
We proved it end to end before writing this. The planner fired, opened a fresh tracking issue as the token user, and posted its full instruction to Codex — clean, correctly addressed, picked up within seconds. The loop that will help maintain the post you're reading is the loop this post is about.
That's the honesty test for a dogfooded tool: if we wouldn't run it on our own main branch, we shouldn't ask you to run it on yours.
## What it is not
It would be easy to oversell this, so let's be clear about the edges.
- **It is not a replacement for judgment.** The generated prompts keep an explicit off-limits list — breaking public APIs, positioning and brand copy, new dependencies, architectural rewrites. Those get surfaced to a human, never auto-merged. Taste stays with you.
- **It is only as good as your gate.** Point it at a repo with weak CI and it will happily merge weak work. The evaluator *is* the safety model. Invest there first.
- **It spends tokens and pushes commits.** This is real automation with real effects. Start with the three-role default, watch it, and add `coherence` and `release` once you trust it.
- **It's early.** The mechanics are solid and battle-tested on this repo, but this is version one. The next steps are more agent adapters and, eventually, a standalone home so the workflows can be reused directly.
## The shape of the thing
Go Micro's premise is that when an agent has to operate inside a real system, it *is* a distributed system — services, state, retries, observability — and the simplest place to build it is the runtime where your services already live. `micro loop` is that premise turned back on the repository: an agent, operating a system, where the system is the codebase itself.
You've read about how Go Micro builds itself. Now you can point the same loop at your own repo:
```bash
micro loop init
```
---
*Go Micro is an open source agent harness and service framework for Go. [Star us on GitHub](https://github.com/micro/go-micro).*
<div class="post-nav">
<div><a href="/blog/32">&larr; An Agent Is a Service: Where Agent Frameworks Are Going</a></div>
<div><a href="/blog/">All Posts</a></div>
</div>
-7
View File
@@ -11,13 +11,6 @@ permalink: /blog/
<div class="posts">
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/33">The Loop, Shipped: Introducing micro loop</a></h2>
<p class="meta" style="color: #666; font-size: 0.85rem;">July 2, 2026</p>
<p>The autonomous loop that builds Go Micro is now a command. <code>micro loop init</code> drops a planner, builder, triage, coherence, and release pipeline into any repository — GitHub Actions as the runtime, editable prompt files as the policy — and Go Micro now runs on it.</p>
<a href="/blog/33">Read more &rarr;</a>
</article>
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/32">An Agent Is a Service: Where Agent Frameworks Are Going</a></h2>
<p class="meta" style="color: #666; font-size: 0.85rem;">June 30, 2026</p>
@@ -43,7 +43,6 @@ The built-in providers currently register these capability interfaces:
| `gemini` | Yes | No | No | No |
| `groq` | Yes | No | No | Yes |
| `mistral` | Yes | No | No | Yes |
| `ollama` | Yes | No | No | Yes |
| `openai` | Yes | Yes | No | Yes |
| `together` | Yes | No | No | Yes |
@@ -14,7 +14,6 @@ import (
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/ollama"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
+1 -8
View File
@@ -23,8 +23,7 @@ cloud credentials?"
| Chat | `micro chat` remains the interactive agent entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
| Inspect | `micro inspect agent`, `micro inspect flow`, and `micro flow runs` remain discoverable for run history. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
| Deploy | `micro deploy --dry-run` resolves deploy targets without touching remote infrastructure. | `go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1` |
| Runtime reference app | `examples/support` runs typed services, an agent using those services as tools, an event-driven flow handoff, and an approval gate with only the model mocked. | `go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1` |
| Runtime harnesses | Real services, agents, durable flows, store-backed history, delegation, and A2A run with only the model mocked. | `./internal/harness/zero-to-hero-ci/run.sh` and `make provider-conformance-mock` |
| Runtime | Real services, agents, durable flows, store-backed history, delegation, and A2A run with only the model mocked. | `./internal/harness/zero-to-hero-ci/run.sh` and `make provider-conformance-mock` |
## Run the runnable example
@@ -54,9 +53,6 @@ SSH access, or remote service is required.
Use the smaller checks when you are working on one seam:
```sh
# Install script and first-run CLI boundary, with no network or provider keys.
make install-smoke
# Scaffold → run/call contract.
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
@@ -67,9 +63,6 @@ go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
# Maintained 0→hero support-desk reference app.
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
# Durable services → agents → workflows reference scenarios.
./internal/harness/zero-to-hero-ci/run.sh
+1 -1
View File
@@ -16,7 +16,7 @@ It's built on a pluggable architecture of Go interfaces: service discovery, clie
## Learn More
Start with [Getting Started](getting-started.html) for install and the first local service. Then follow the first-agent on-ramp: [Your First Agent](guides/your-first-agent.html) to build and chat with a service-backed agent, [Debugging your agent](guides/debugging-agents.html) to inspect runs and memory, and the [0→hero reference path](guides/zero-to-hero.html) to walk the full scaffold run chat inspect deploy dry-run lifecycle covered by CI.
Start with [Getting Started](getting-started.html) for install and the first local service. If you want the full services → agents → workflows on-ramp in one walkable sequence, use the [0→hero reference path](guides/zero-to-hero.html): it links the exact scaffold, run, chat, inspect, and deploy dry-run commands covered by CI.
Otherwise continue to read the docs for more information about the framework.
+1 -9
View File
@@ -37,16 +37,8 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
## Next Steps
You now have the service half of the services → agents → workflows lifecycle running locally. Keep the on-ramp going in this order:
1. **[Your First Agent](guides/your-first-agent.html)** - turn this service into an agent-callable tool, chat with it, and learn the `micro agent preflight``micro run``micro chat` loop.
2. **[Debugging your agent](guides/debugging-agents.html)** - inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent does something surprising.
3. **[0→hero Reference](guides/zero-to-hero.html)** - walk the maintained scaffold → run → chat → inspect → deploy dry-run path that proves services, agents, and workflows together.
After that first-agent path, branch out to:
- **[Full Tutorial](getting-started.html)** - In-depth guide
- **[Examples](examples/)** - Runnable examples mapped to services, agents, and workflows
- **[Examples](examples/)** - Learn by example
- **[API Reference](https://pkg.go.dev/go-micro.dev/v6)** - Complete API docs
- **[Deployment](deployment.html)** - Deploy to production
+4 -11
View File
@@ -23,12 +23,8 @@ case $OS in
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
# Determine install directory. MICRO_INSTALL_DIR is intended for CI/local smoke
# tests that verify the installer without writing to a real system directory.
if [ -n "${MICRO_INSTALL_DIR:-}" ]; then
INSTALL_DIR="$MICRO_INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
elif [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
# Determine install directory
if [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
INSTALL_DIR="/usr/local/bin"
else
INSTALL_DIR="$HOME/.local/bin"
@@ -48,11 +44,8 @@ fi
TMP_DIR=$(mktemp -d)
TMP_FILE="${TMP_DIR}/micro.tar.gz"
# Download, or use a local release archive supplied by the deterministic smoke
# harness. The default path still fetches the documented GitHub release artifact.
if [ -n "${MICRO_INSTALL_ARCHIVE:-}" ]; then
cp "$MICRO_INSTALL_ARCHIVE" "$TMP_FILE"
elif command -v curl &> /dev/null; then
# Download
if command -v curl &> /dev/null; then
curl -fsSL "$URL" -o "$TMP_FILE"
elif command -v wget &> /dev/null; then
wget -q "$URL" -O "$TMP_FILE"
-4
View File
@@ -100,10 +100,6 @@ func AgentModel(m string) AgentOption { return agent.Model(m) }
// AgentAPIKey sets the API key for the LLM provider.
func AgentAPIKey(k string) AgentOption { return agent.APIKey(k) }
// AgentBaseURL sets the base URL for the LLM provider. Use this to point
// the provider at a non-default endpoint (e.g., local Ollama, a proxy).
func AgentBaseURL(url string) AgentOption { return agent.BaseURL(url) }
// ApproveFunc gates an agent's tool calls before they run.
type ApproveFunc = agent.ApproveFunc
-109
View File
@@ -1,109 +0,0 @@
package x402
import (
"crypto/ed25519"
"crypto/rand"
"encoding/base64"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
)
// CDPFacilitatorURL is Coinbase Developer Platform's hosted x402 facilitator,
// which can settle real payments on Base mainnet (the open x402.org facilitator
// is testnet-only).
const CDPFacilitatorURL = "https://api.cdp.coinbase.com/platform/v2/x402"
// CDP returns a Facilitator (and Settler) backed by Coinbase's hosted
// facilitator, authenticating each verify/settle call with a short-lived
// Ed25519 Bearer JWT minted from a CDP Secret API Key. keyID and keySecret are
// the CDP API Key ID and base64 Ed25519 secret; the secret is used only to sign
// the JWT (stdlib crypto — no chain code, no external dependency).
//
// cfg.Facilitator = x402.CDP(os.Getenv("CDP_API_KEY_ID"), os.Getenv("CDP_API_KEY_SECRET"))
func CDP(keyID, keySecret string) *HTTPFacilitator {
return &HTTPFacilitator{
URL: CDPFacilitatorURL,
Authorize: cdpAuthorizer(keyID, keySecret),
}
}
// cdpAuthorizer returns an Authorize hook that attaches a CDP Bearer JWT bound
// to the request's method and URL.
func cdpAuthorizer(keyID, keySecret string) func(*http.Request) error {
return func(r *http.Request) error {
tok, err := cdpBearer(keyID, keySecret, r.Method, r.URL.Host, r.URL.Path)
if err != nil {
return err
}
r.Header.Set("Authorization", "Bearer "+tok)
return nil
}
}
// cdpBearer builds a CDP Bearer JWT (EdDSA / Ed25519) authorizing a single REST
// call, per CDP's authentication spec: the token binds to "METHOD host/path"
// and is valid for two minutes.
func cdpBearer(keyID, keySecret, method, host, path string) (string, error) {
if keyID == "" || keySecret == "" {
return "", fmt.Errorf("CDP API key id and secret are required")
}
key, err := ed25519KeyFromSecret(keySecret)
if err != nil {
return "", err
}
nonce := make([]byte, 16)
if _, err := rand.Read(nonce); err != nil {
return "", err
}
header := map[string]any{
"alg": "EdDSA",
"typ": "JWT",
"kid": keyID,
"nonce": hex.EncodeToString(nonce),
}
now := time.Now().Unix()
claims := map[string]any{
"sub": keyID,
"iss": "cdp",
"aud": []string{"cdp_service"},
"nbf": now,
"exp": now + 120,
"uri": method + " " + host + path,
}
hb, _ := json.Marshal(header)
cb, _ := json.Marshal(claims)
signing := b64url(hb) + "." + b64url(cb)
sig := ed25519.Sign(key, []byte(signing))
return signing + "." + b64url(sig), nil
}
// ed25519KeyFromSecret decodes a CDP Ed25519 secret. CDP secrets are base64 and
// decode to 64 bytes (32-byte seed + 32-byte public key) — Go's PrivateKey
// layout; a bare 32-byte seed is also accepted.
func ed25519KeyFromSecret(secret string) (ed25519.PrivateKey, error) {
secret = strings.TrimSpace(secret)
if strings.Contains(secret, "BEGIN") {
return nil, fmt.Errorf("CDP secret looks like a PEM/EC key; x402 bearer auth needs an Ed25519 Secret API Key")
}
raw, err := base64.StdEncoding.DecodeString(secret)
if err != nil {
if raw, err = base64.RawURLEncoding.DecodeString(secret); err != nil {
return nil, fmt.Errorf("CDP secret is not valid base64: %w", err)
}
}
switch len(raw) {
case ed25519.PrivateKeySize: // 64: seed + public key
return ed25519.PrivateKey(raw), nil
case ed25519.SeedSize: // 32: seed only
return ed25519.NewKeyFromSeed(raw), nil
default:
return nil, fmt.Errorf("CDP secret decoded to %d bytes; expected 32 or 64 (Ed25519)", len(raw))
}
}
func b64url(b []byte) string { return base64.RawURLEncoding.EncodeToString(b) }
-38
View File
@@ -1,38 +0,0 @@
package x402
import "strings"
// assetInfo describes a known stablecoin: its contract address and EIP-712
// domain (name, version) used to build a transfer-authorization signature.
type assetInfo struct {
Address string
Name string
Version string
}
// knownAssets maps a CAIP-2 network to its default stablecoin (USDC). Used to
// fill in the asset and its EIP-712 domain when the operator does not specify
// them, so a client can sign without hand-configured token metadata.
var knownAssets = map[string]assetInfo{
"eip155:8453": {"0x833589fCD6eDb6E08f4c7C32D4f71b54bdA02913", "USD Coin", "2"}, // Base mainnet
"eip155:84532": {"0x036CbD53842c5426634e7929541eC2318f3dCF7e", "USDC", "2"}, // Base Sepolia
}
// NormalizeNetwork maps common short chain names to their CAIP-2 identifiers,
// which hosted facilitators (Coinbase CDP) use, and passes anything else
// through unchanged. Empty defaults to Base mainnet.
func NormalizeNetwork(n string) string {
switch strings.ToLower(strings.TrimSpace(n)) {
case "", "base", "eip155:8453":
return "eip155:8453"
case "base-sepolia", "eip155:84532":
return "eip155:84532"
default:
return n
}
}
func defaultAsset(network string) (assetInfo, bool) {
a, ok := knownAssets[network]
return a, ok
}
+42 -189
View File
@@ -15,11 +15,6 @@
// })
// mux.Handle("/paid", pay(handler))
//
// For real settlement on Base mainnet, point it at the Coinbase CDP
// facilitator, which requires an authenticated request:
//
// cfg.Facilitator = x402.CDP(os.Getenv("CDP_API_KEY_ID"), os.Getenv("CDP_API_KEY_SECRET"))
//
// x402 is governed by the x402 Foundation (Linux Foundation). See
// https://x402.org and https://docs.cdp.coinbase.com/x402.
package x402
@@ -27,41 +22,32 @@ package x402
import (
"bytes"
"context"
"encoding/base64"
"encoding/json"
"fmt"
"net/http"
"os"
"strings"
)
// Version is the x402 protocol version this package speaks.
const Version = 1
// Header names defined by the protocol. Version 1 uses X-PAYMENT /
// X-PAYMENT-RESPONSE; version 2 renamed them to PAYMENT-SIGNATURE /
// PAYMENT-RESPONSE. We accept either request header and emit both response
// headers so any conformant client interoperates.
// Header names defined by the protocol.
const (
PaymentHeader = "X-PAYMENT" // request: the client's payment payload
PaymentHeaderV2 = "PAYMENT-SIGNATURE" // request: v2 alias
PaymentResponseHeader = "X-PAYMENT-RESPONSE" // response: settlement details
PaymentResponseHeaderV2 = "PAYMENT-RESPONSE" // response: v2 alias
PaymentHeader = "X-PAYMENT" // request: the client's payment payload
PaymentResponseHeader = "X-PAYMENT-RESPONSE" // response: settlement details
)
// Requirements describes what a client must pay to access a resource —
// the body of a 402 response (one entry of "accepts").
type Requirements struct {
Scheme string `json:"scheme"` // payment scheme, e.g. "exact"
Network string `json:"network"` // chain, e.g. "base", "eip155:8453"
MaxAmountRequired string `json:"maxAmountRequired"` // amount in the asset's smallest unit
Resource string `json:"resource"` // the resource being paid for
Description string `json:"description,omitempty"` // human/agent-readable description
MimeType string `json:"mimeType,omitempty"` // response mime type
PayTo string `json:"payTo"` // receiving address
Asset string `json:"asset,omitempty"` // token contract/mint (default: network USDC)
MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` // how long the client has to pay
Extra map[string]string `json:"extra,omitempty"` // scheme extras, e.g. EIP-712 domain {name, version}
Scheme string `json:"scheme"` // payment scheme, e.g. "exact"
Network string `json:"network"` // chain, e.g. "base", "solana"
MaxAmountRequired string `json:"maxAmountRequired"` // amount in the asset's smallest unit
Resource string `json:"resource"` // the resource being paid for
Description string `json:"description,omitempty"` // human/agent-readable description
PayTo string `json:"payTo"` // receiving address
Asset string `json:"asset,omitempty"` // token contract/mint (default: network USDC)
MaxTimeoutSeconds int `json:"maxTimeoutSeconds,omitempty"` // how long the client has to pay
}
// challenge is the JSON body returned with a 402 response.
@@ -71,7 +57,7 @@ type challenge struct {
Error string `json:"error,omitempty"`
}
// Result is the outcome of verifying (or settling) a payment.
// Result is the outcome of verifying a payment.
type Result struct {
Valid bool // whether the payment satisfies the requirements
Payer string // the paying address, if known
@@ -79,29 +65,20 @@ type Result struct {
Settlement string // settlement reference (e.g. tx hash), set into X-PAYMENT-RESPONSE
}
// Facilitator verifies a payment a client presented against the stated
// requirements. Implementations talk to a chain or a hosted facilitator; the
// gateway stays chain-agnostic, so a Base facilitator and a Solana facilitator
// are just different implementations behind this interface.
// Facilitator verifies (and optionally settles) a payment a client
// presented against the stated requirements. Implementations talk to a
// chain or a hosted facilitator; the gateway stays chain-agnostic, so a
// Base facilitator and a Solana facilitator are just different
// implementations behind this interface.
type Facilitator interface {
Verify(ctx context.Context, payment string, req Requirements) (Result, error)
}
// Settler is an optional capability: a Facilitator that also settles a verified
// payment on-chain (captures the funds) and returns a settlement reference.
// Require calls Settle after a successful Verify when the facilitator
// implements it — for the "exact" scheme, verify authorizes and settle
// captures, so without settlement no funds actually move.
type Settler interface {
Settle(ctx context.Context, payment string, req Requirements) (Result, error)
}
// Config configures payment enforcement for a set of routes or tools.
type Config struct {
// PayTo is the address payments are sent to. Required.
PayTo string `json:"payTo"`
// Network is the chain to settle on (default "base"). Accepts short
// names ("base", "base-sepolia") or CAIP-2 ids ("eip155:8453").
// Network is the chain to settle on (default "base").
Network string `json:"network,omitempty"`
// Asset is the token contract/mint (default: the network's USDC).
Asset string `json:"asset,omitempty"`
@@ -115,13 +92,8 @@ type Config struct {
Amounts map[string]string `json:"amounts,omitempty"`
// Description is shown to the paying client/agent.
Description string `json:"description,omitempty"`
// Extra carries scheme-specific data echoed in the requirement's "extra".
// For the EVM "exact" scheme this is the asset's EIP-712 domain, e.g.
// {"name":"USD Coin","version":"2"}; when empty it is filled in for known
// assets so clients can build a valid transfer signature.
Extra map[string]string `json:"extra,omitempty"`
// Facilitator verifies (and, if it implements Settler, settles) payments.
// Defaults to an HTTPFacilitator pointed at FacilitatorURL.
// Facilitator verifies payments. Defaults to an HTTPFacilitator
// pointed at FacilitatorURL.
Facilitator Facilitator `json:"-"`
// FacilitatorURL is the verify/settle endpoint used when Facilitator
// is nil (e.g. Coinbase CDP or Alchemy).
@@ -152,62 +124,35 @@ func (c Config) facilitator() Facilitator {
}
func (c Config) requirements(amount, resource string) Requirements {
net := c.network()
asset := c.Asset
extra := c.Extra
// Fill the asset and its EIP-712 domain for known networks so a client
// can sign without the operator hand-configuring token metadata. Keyed on
// the CAIP-2 form so both "base" and "eip155:8453" resolve.
if def, ok := defaultAsset(NormalizeNetwork(net)); ok {
if asset == "" {
asset = def.Address
}
if extra == nil && strings.EqualFold(asset, def.Address) {
extra = map[string]string{"name": def.Name, "version": def.Version}
}
}
return Requirements{
Scheme: "exact",
Network: net,
Network: c.network(),
MaxAmountRequired: amount,
Resource: resource,
Description: c.Description,
MimeType: "application/json",
PayTo: c.PayTo,
Asset: asset,
Asset: c.Asset,
MaxTimeoutSeconds: 60,
Extra: extra,
}
}
// Payment returns the client's payment payload from either the v1 or v2
// request header, or "" if none is present.
func Payment(r *http.Request) string {
if p := r.Header.Get(PaymentHeader); p != "" {
return p
}
return r.Header.Get(PaymentHeaderV2)
}
// Require enforces payment of amount for a single request. It returns
// true if the request may proceed — the amount is free ("" or "0"), or a
// valid payment was presented (and settled, when the facilitator supports
// it) — and false once it has written a 402 challenge, in which case the
// caller must stop. resource names what is being paid for (a tool name or
// URL path).
// valid payment was presented and false once it has written a 402
// challenge, in which case the caller must stop. resource names what is
// being paid for (a tool name or URL path).
func (c Config) Require(w http.ResponseWriter, r *http.Request, amount, resource string) bool {
if amount == "" || amount == "0" {
return true // free
}
req := c.requirements(amount, resource)
payment := Payment(r)
payment := r.Header.Get(PaymentHeader)
if payment == "" {
writeChallenge(w, req, "payment required")
return false
}
fac := c.facilitator()
res, err := fac.Verify(r.Context(), payment, req)
res, err := c.facilitator().Verify(r.Context(), payment, req)
if err != nil {
writeChallenge(w, req, "payment verification failed: "+err.Error())
return false
@@ -220,28 +165,8 @@ func (c Config) Require(w http.ResponseWriter, r *http.Request, amount, resource
writeChallenge(w, req, reason)
return false
}
// Capture the funds when the facilitator can settle. Verify alone only
// authorizes the "exact" transfer; settlement broadcasts it.
if s, ok := fac.(Settler); ok {
sres, err := s.Settle(r.Context(), payment, req)
if err != nil {
writeChallenge(w, req, "payment settlement failed: "+err.Error())
return false
}
if !sres.Valid {
reason := sres.Reason
if reason == "" {
reason = "settlement rejected"
}
writeChallenge(w, req, reason)
return false
}
if sres.Settlement != "" {
res.Settlement = sres.Settlement
}
}
if res.Settlement != "" {
setSettlementHeaders(w, res.Settlement)
w.Header().Set(PaymentResponseHeader, res.Settlement)
}
return true
}
@@ -259,11 +184,6 @@ func Middleware(cfg Config) func(http.Handler) http.Handler {
}
}
func setSettlementHeaders(w http.ResponseWriter, settlement string) {
w.Header().Set(PaymentResponseHeader, settlement)
w.Header().Set(PaymentResponseHeaderV2, settlement)
}
func writeChallenge(w http.ResponseWriter, req Requirements, reason string) {
w.Header().Set("Content-Type", "application/json")
w.WriteHeader(http.StatusPaymentRequired) // 402
@@ -291,111 +211,44 @@ func LoadConfig(path string) (*Config, error) {
return &c, nil
}
// HTTPFacilitator verifies and settles payments by POSTing to an x402
// facilitator's /verify and /settle endpoints (Coinbase CDP, Alchemy, or
// self-hosted). It carries no chain or crypto code itself; when the endpoint
// requires authentication (e.g. CDP), set Authorize to attach credentials —
// see CDP.
// HTTPFacilitator verifies payments by POSTing to an x402 facilitator's
// verify endpoint (Coinbase CDP, Alchemy, or self-hosted). It carries no
// chain or crypto code itself.
type HTTPFacilitator struct {
URL string
Client *http.Client
// Authorize, when set, is called on each facilitator request to attach
// authentication (e.g. a Bearer token). Nil for open facilitators.
Authorize func(*http.Request) error
}
// Verify checks the payment is valid against the requirements.
func (f *HTTPFacilitator) Verify(ctx context.Context, payment string, req Requirements) (Result, error) {
out, err := f.post(ctx, "/verify", payment, req)
if err != nil {
return Result{}, err
}
return Result{Valid: out.IsValid, Reason: firstNonEmpty(out.InvalidReason, out.Error), Payer: out.Payer}, nil
}
// Settle captures a verified payment on-chain and returns the transaction
// reference, satisfying Settler.
func (f *HTTPFacilitator) Settle(ctx context.Context, payment string, req Requirements) (Result, error) {
out, err := f.post(ctx, "/settle", payment, req)
if err != nil {
return Result{}, err
}
// Facilitators report settlement as "success" with a "transaction" ref.
ok := out.Success || out.IsValid
return Result{Valid: ok, Reason: firstNonEmpty(out.ErrorReason, out.InvalidReason, out.Error), Payer: out.Payer, Settlement: out.Transaction}, nil
}
type facilitatorResponse struct {
IsValid bool `json:"isValid"`
Success bool `json:"success"`
InvalidReason string `json:"invalidReason"`
ErrorReason string `json:"errorReason"`
Error string `json:"error"`
Payer string `json:"payer"`
Transaction string `json:"transaction"`
}
func (f *HTTPFacilitator) post(ctx context.Context, path, payment string, req Requirements) (facilitatorResponse, error) {
var out facilitatorResponse
if f.URL == "" {
return out, fmt.Errorf("no facilitator configured")
return Result{}, fmt.Errorf("no facilitator configured")
}
body, _ := json.Marshal(map[string]any{
"x402Version": Version,
"paymentPayload": decodePayment(payment),
"paymentPayload": payment,
"paymentRequirements": req,
})
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, strings.TrimRight(f.URL, "/")+path, bytes.NewReader(body))
hreq, err := http.NewRequestWithContext(ctx, http.MethodPost, f.URL+"/verify", bytes.NewReader(body))
if err != nil {
return out, err
return Result{}, err
}
hreq.Header.Set("Content-Type", "application/json")
if f.Authorize != nil {
if err := f.Authorize(hreq); err != nil {
return out, fmt.Errorf("authorize: %w", err)
}
}
cl := f.Client
if cl == nil {
cl = http.DefaultClient
}
resp, err := cl.Do(hreq)
if err != nil {
return out, err
return Result{}, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
var buf bytes.Buffer
_, _ = buf.ReadFrom(resp.Body)
return out, fmt.Errorf("facilitator %s returned %d: %s", path, resp.StatusCode, strings.TrimSpace(buf.String()))
var out struct {
IsValid bool `json:"isValid"`
InvalidReason string `json:"invalidReason"`
Payer string `json:"payer"`
}
if err := json.NewDecoder(resp.Body).Decode(&out); err != nil {
return out, err
return Result{}, err
}
return out, nil
}
// decodePayment turns the base64 X-PAYMENT header into the PaymentPayload
// object facilitators expect. If it is not base64 JSON, the raw value is passed
// through unchanged (some facilitators accept the encoded string).
func decodePayment(payment string) any {
payment = strings.TrimSpace(payment)
for _, dec := range []*base64.Encoding{base64.StdEncoding, base64.RawURLEncoding} {
if raw, err := dec.DecodeString(payment); err == nil {
var obj any
if json.Unmarshal(raw, &obj) == nil {
return obj
}
}
}
return payment
}
func firstNonEmpty(vals ...string) string {
for _, v := range vals {
if strings.TrimSpace(v) != "" {
return v
}
}
return ""
return Result{Valid: out.IsValid, Reason: out.InvalidReason, Payer: out.Payer}, nil
}
-162
View File
@@ -1,162 +0,0 @@
package x402
import (
"crypto/ed25519"
"encoding/base64"
"encoding/json"
"net/http"
"net/http/httptest"
"strings"
"testing"
)
// TestVerifyAndSettle checks that Require verifies then settles against an
// HTTP facilitator and surfaces the settlement in both response headers.
func TestVerifyAndSettle(t *testing.T) {
var verifyHit, settleHit bool
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/verify":
verifyHit = true
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true, "payer": "0xabc"})
case "/settle":
settleHit = true
_ = json.NewEncoder(w).Encode(map[string]any{"success": true, "transaction": "0xdeadbeef"})
default:
http.NotFound(w, r)
}
}))
defer srv.Close()
cfg := Config{PayTo: "0xpay", Network: "eip155:8453", FacilitatorURL: srv.URL}
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, base64.StdEncoding.EncodeToString([]byte(`{"network":"eip155:8453"}`)))
rec := httptest.NewRecorder()
if !cfg.Require(rec, r, "10000", "chat") {
t.Fatalf("Require returned false; body=%s", rec.Body.String())
}
if !verifyHit || !settleHit {
t.Fatalf("expected both verify and settle to be hit: verify=%v settle=%v", verifyHit, settleHit)
}
if got := rec.Header().Get(PaymentResponseHeader); got != "0xdeadbeef" {
t.Errorf("X-PAYMENT-RESPONSE = %q, want 0xdeadbeef", got)
}
if got := rec.Header().Get(PaymentResponseHeaderV2); got != "0xdeadbeef" {
t.Errorf("PAYMENT-RESPONSE = %q, want 0xdeadbeef", got)
}
}
// TestSettlementFailureChallenges checks that a failed settlement blocks the
// request with a fresh 402 rather than letting it through.
func TestSettlementFailureChallenges(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/verify":
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true})
case "/settle":
_ = json.NewEncoder(w).Encode(map[string]any{"success": false, "errorReason": "insufficient_funds"})
}
}))
defer srv.Close()
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL}
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeader, "eyJ4IjoxfQ==")
rec := httptest.NewRecorder()
if cfg.Require(rec, r, "10000", "chat") {
t.Fatal("Require should return false when settlement fails")
}
if rec.Code != http.StatusPaymentRequired {
t.Errorf("status = %d, want 402", rec.Code)
}
}
// TestPaymentSignatureHeaderAccepted checks the v2 request header is honored.
func TestPaymentSignatureHeaderAccepted(t *testing.T) {
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
_ = json.NewEncoder(w).Encode(map[string]any{"isValid": true, "success": true, "transaction": "0x1"})
}))
defer srv.Close()
cfg := Config{PayTo: "0xpay", FacilitatorURL: srv.URL}
r := httptest.NewRequest(http.MethodGet, "/tool", nil)
r.Header.Set(PaymentHeaderV2, "eyJ4IjoxfQ==") // PAYMENT-SIGNATURE only
rec := httptest.NewRecorder()
if !cfg.Require(rec, r, "10000", "chat") {
t.Fatalf("Require should honor PAYMENT-SIGNATURE; body=%s", rec.Body.String())
}
}
// TestRequirementsExtraForKnownNetwork checks the EIP-712 domain is filled in.
func TestRequirementsExtraForKnownNetwork(t *testing.T) {
for _, net := range []string{"base", "eip155:8453"} {
req := Config{PayTo: "0xpay", Network: net}.requirements("10000", "chat")
if req.Extra["name"] == "" || req.Extra["version"] == "" {
t.Errorf("network %q: extra not filled: %v", net, req.Extra)
}
if req.Asset == "" {
t.Errorf("network %q: asset not defaulted", net)
}
}
}
// TestDecodePayment checks the base64 header is decoded to an object for the
// facilitator (which expects the payload object, not the raw string).
func TestDecodePayment(t *testing.T) {
enc := base64.StdEncoding.EncodeToString([]byte(`{"network":"eip155:8453","payload":{"x":1}}`))
obj := decodePayment(enc)
m, ok := obj.(map[string]any)
if !ok {
t.Fatalf("decodePayment did not return an object: %T", obj)
}
if m["network"] != "eip155:8453" {
t.Errorf("decoded network = %v", m["network"])
}
// Non-base64 passes through unchanged.
if got := decodePayment("not-base64!"); got != "not-base64!" {
t.Errorf("passthrough failed: %v", got)
}
}
// TestCDPBearer certifies the CDP JWT is well-formed and its signature verifies.
func TestCDPBearer(t *testing.T) {
pub, priv, _ := ed25519.GenerateKey(nil)
secret := base64.StdEncoding.EncodeToString(priv)
tok, err := cdpBearer("key-id", secret, "POST", "api.cdp.coinbase.com", "/platform/v2/x402/verify")
if err != nil {
t.Fatalf("cdpBearer: %v", err)
}
parts := strings.Split(tok, ".")
if len(parts) != 3 {
t.Fatalf("want 3 JWT segments, got %d", len(parts))
}
sig, _ := base64.RawURLEncoding.DecodeString(parts[2])
if !ed25519.Verify(pub, []byte(parts[0]+"."+parts[1]), sig) {
t.Fatal("JWT signature does not verify")
}
var claims map[string]any
cb, _ := base64.RawURLEncoding.DecodeString(parts[1])
_ = json.Unmarshal(cb, &claims)
if claims["iss"] != "cdp" || claims["uri"] != "POST api.cdp.coinbase.com/platform/v2/x402/verify" {
t.Errorf("bad claims: %v", claims)
}
}
// TestCDPAuthorizeAttachesBearer checks CDP() signs facilitator requests.
func TestCDPAuthorizeAttachesBearer(t *testing.T) {
_, priv, _ := ed25519.GenerateKey(nil)
fac := CDP("key-id", base64.StdEncoding.EncodeToString(priv))
req, _ := http.NewRequest(http.MethodPost, "https://api.cdp.coinbase.com/platform/v2/x402/verify", nil)
if err := fac.Authorize(req); err != nil {
t.Fatalf("authorize: %v", err)
}
if !strings.HasPrefix(req.Header.Get("Authorization"), "Bearer ey") {
t.Errorf("missing Bearer JWT: %q", req.Header.Get("Authorization"))
}
}
var _ Settler = (*HTTPFacilitator)(nil)