Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| fb9d5daf2a |
@@ -48,4 +48,4 @@ Add any other context about the problem here.
|
||||
- [Troubleshooting Guide](https://github.com/micro/go-micro/tree/master/internal/website/docs/getting-started.md)
|
||||
- [Examples](https://github.com/micro/go-micro/tree/master/examples)
|
||||
- [API Reference](https://pkg.go.dev/go-micro.dev/v5)
|
||||
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
|
||||
- [Discord Community](https://discord.gg/WeMU5AGxD)
|
||||
|
||||
@@ -39,4 +39,4 @@ Add any other context, code examples, or screenshots about the feature request h
|
||||
- [Roadmap](https://github.com/micro/go-micro/blob/master/ROADMAP.md)
|
||||
- [Contributing Guide](https://github.com/micro/go-micro/blob/master/CONTRIBUTING.md)
|
||||
- [Architecture Docs](https://github.com/micro/go-micro/tree/master/internal/website/docs/architecture.md)
|
||||
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
|
||||
- [Discord Community](https://discord.gg/WeMU5AGxD)
|
||||
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "Loop: Architect (Planner)"
|
||||
name: Architecture Review
|
||||
|
||||
# Continuous high-altitude oversight of the whole framework and harness — the
|
||||
# "founder lens" of the autonomous loop (internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
@@ -48,4 +48,4 @@ jobs:
|
||||
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)."
|
||||
"@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? 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)."
|
||||
@@ -1,4 +1,4 @@
|
||||
name: "Loop: Builder (Generator)"
|
||||
name: Continuous Improvement
|
||||
|
||||
# Durable backbone for the autonomous improvement loop
|
||||
# (see internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
@@ -0,0 +1,47 @@
|
||||
name: DevRel Review
|
||||
|
||||
# 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.
|
||||
#
|
||||
# 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 auto-merge; brand/positioning copy
|
||||
# and blog drafts are surfaced in the report for the human, never auto-merged.
|
||||
|
||||
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. 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. 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 / CHANGELOG.md); (2) whether the README is crisp and leads with the harness positioning; (3) one to three genuinely blog-worthy items from recently shipped work. Then do BOTH of these: (A) post a concise findings report as a comment on this issue (#$ISSUE_NUM) — what is aligned, what drifted, what you fixed, and the blog ideas; (B) for SAFE factual-alignment and crispness fixes only (NOT brand/marketing/positioning rewrites), open one PR: \`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\`. Leave brand/positioning copy and blog drafts for the human — describe them in the report, do NOT open auto-merging PRs for them. 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."
|
||||
@@ -12,7 +12,7 @@ on:
|
||||
pull_request:
|
||||
branches: ["**"]
|
||||
schedule:
|
||||
- cron: "17 * * * *" # hourly, so real-model conformance keeps pace with the dev/loop velocity
|
||||
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
|
||||
workflow_dispatch:
|
||||
inputs:
|
||||
providers:
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
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."
|
||||
@@ -1,84 +0,0 @@
|
||||
name: "Loop: Release (daily patch)"
|
||||
|
||||
# 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 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 * * *" # daily 23:00 UTC — captures the day's merges (tunable)
|
||||
|
||||
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.
|
||||
# actions/checkout otherwise sets an http.extraheader Authorization
|
||||
# for github.com that is sent on ALL pushes — including our manual
|
||||
# PAT push below — and overrides the PAT, so the tag push
|
||||
# authenticates as github-actions[bot] and 403s (the job only has
|
||||
# contents: read). With this off, the PAT embedded in the push URL
|
||||
# is the only credential. (Fixes run 28554612450.)
|
||||
persist-credentials: false
|
||||
- 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 'v6.*.*' --sort=-v:refname | head -1)
|
||||
if [ -z "$LATEST" ]; then
|
||||
echo "no v6.x.x tag found — aborting so nothing weird gets tagged."
|
||||
exit 1
|
||||
fi
|
||||
echo "latest release tag: $LATEST"
|
||||
|
||||
COUNT=$(git rev-list --count "$LATEST"..HEAD)
|
||||
echo "commits on HEAD since $LATEST: $COUNT"
|
||||
if [ "$COUNT" -eq 0 ]; then
|
||||
echo "no new commits since $LATEST — no release today."
|
||||
exit 0
|
||||
fi
|
||||
|
||||
# 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 ;;
|
||||
esac
|
||||
NEXT="v${major}.${minor}.$((patch + 1))"
|
||||
echo "cutting: $NEXT ($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. goreleaser (release.yml) will build and publish it."
|
||||
@@ -1,59 +0,0 @@
|
||||
name: "Loop: Triage (Evaluator feedback)"
|
||||
|
||||
# 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:
|
||||
workflows: ["Harness (E2E)"]
|
||||
types: [completed]
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: harness-triage
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
triage:
|
||||
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:
|
||||
- name: Open a triage issue and dispatch Codex
|
||||
env:
|
||||
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.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_TRIGGER_TOKEN" != "true" ]; then
|
||||
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
|
||||
exit 0
|
||||
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 "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 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\`)."
|
||||
@@ -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/
|
||||
|
||||
+2
-35
@@ -2,41 +2,8 @@
|
||||
|
||||
All notable changes to Go Micro are documented here.
|
||||
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/) and versions
|
||||
follow [Semantic Versioning](https://semver.org/), matching the git tags and
|
||||
[GitHub releases](https://github.com/micro/go-micro/releases) (`v6.MINOR.PATCH`).
|
||||
Patch releases are cut automatically as the loop merges improvements; the
|
||||
`[Unreleased]` section below is kept current between tags and rolled into the
|
||||
next version when it ships.
|
||||
|
||||
> Earlier `2026.0x` headings are historical calendar-style markers from before
|
||||
> v6 tagging; they are kept for continuity and not reused.
|
||||
|
||||
---
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
- **`micro loop`** — scaffold an autonomous improvement loop into any repository: GitHub Actions workflows for a planner (keeps a ranked queue), builder (builds the top item as a single-concern PR, auto-merged on green CI), and triage (turns CI failures into fix issues), dispatched to an @mention-driven coding agent. `micro loop init` writes the workflows + `NORTH_STAR`/`PRIORITIES`; `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/`)
|
||||
- **A2A streaming & continuity** — outbound agent streaming flows through the A2A binding (`message/stream`), with `tasks/resubscribe` and `input-required` handoffs for multi-turn interop. (`gateway/a2a/`)
|
||||
|
||||
### Changed
|
||||
- **Agent tool-call resilience** — opt-in retries around agent tool calls, and a fallback that executes tool calls emitted as text by weaker models so they still make progress. (`agent/`)
|
||||
- **Hardened agent durability** — terminal failure statuses are classified and surfaced, and durable resume-after-restart is covered by tests. (`agent/`)
|
||||
|
||||
### Documentation
|
||||
- **"Your first agent" walkthrough** and a canonical 0-to-hero reference path, lowering the on-ramp from install to a running agent. (`internal/website/docs/`)
|
||||
- **Discord** linked prominently across the README, website nav/footer, and docs. (`https://discord.gg/G8Gk5j3uXr`)
|
||||
Format follows [Keep a Changelog](https://keepachangelog.com/). Go Micro uses
|
||||
calendar-based versions (YYYY.MM) for the AI-native era.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -6,17 +6,6 @@ Thank you for your interest in contributing to Go Micro! This document provides
|
||||
|
||||
Be respectful, inclusive, and collaborative. We're all here to build great software together.
|
||||
|
||||
## How Go Micro is built
|
||||
|
||||
Go Micro is developed by an **autonomous improvement loop** — a planner, a
|
||||
generator, and a separate evaluator, running as scheduled GitHub Actions with a
|
||||
human setting direction. It's the framework's own thesis (an agent operating a
|
||||
system) pointed at itself: an agent harness, built by agents. The full process —
|
||||
the planner → generator → evaluator pipeline, the correctness-only merge gate, and
|
||||
the guardrails — is documented in
|
||||
[`internal/docs/CONTINUOUS_IMPROVEMENT.md`](internal/docs/CONTINUOUS_IMPROVEMENT.md).
|
||||
Human contributions follow the same gate: green CI, one concern per PR.
|
||||
|
||||
## Getting Started
|
||||
|
||||
1. Fork the repository
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,9 +1,7 @@
|
||||
# Go Micro [](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [](https://goreportcard.com/report/github.com/go-micro/go-micro) [](https://discord.gg/G8Gk5j3uXr)
|
||||
# Go Micro [](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [](https://goreportcard.com/report/github.com/go-micro/go-micro)
|
||||
|
||||
Go Micro is an **agent harness** and service framework for Go.
|
||||
|
||||
**Community:** questions, ideas, or just want to build alongside us? [Join the Discord](https://discord.gg/G8Gk5j3uXr).
|
||||
|
||||
A harness is the runtime around an agent: the tools it can call, the memory it keeps, the guardrails that bound it, the workflows that trigger it, the services it depends on, and the protocols other agents use to reach it.
|
||||
|
||||
Go Micro gives you the harness as Go code. Build an agent and it gets a model, memory, tools, planning, delegation, guardrails, and service discovery; it is reachable over [MCP](https://modelcontextprotocol.io/) and [A2A](https://a2a-protocol.org). Write services and every endpoint becomes an AI-callable tool. Orchestrate the deterministic parts with durable flows. Agents, services, and flows share one runtime because an agent is a distributed system, and building one is building a service.
|
||||
@@ -16,7 +14,7 @@ Go Micro gives you the harness as Go code. Build an agent and it gets a model, m
|
||||
|
||||
<a href="https://go-micro.dev/blog/8"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
|
||||
|
||||
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/G8Gk5j3uXr) — reach out on Discord.
|
||||
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/WeMU5AGxD) — reach out on Discord.
|
||||
|
||||
## Commercial Support
|
||||
|
||||
@@ -25,7 +23,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)
|
||||
@@ -47,7 +44,7 @@ Install the CLI:
|
||||
curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
|
||||
# Or with Go
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
go install go-micro.dev/v6/cmd/micro@v6
|
||||
```
|
||||
|
||||
### Fastest start — no API key
|
||||
@@ -67,35 +64,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 +306,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 +403,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))
|
||||
@@ -448,7 +423,6 @@ See [all examples](examples/README.md).
|
||||
|
||||
- [Getting Started](internal/website/docs/getting-started.md)
|
||||
- [AI Integration](internal/website/docs/ai-integration.md)
|
||||
- [Your First Agent](internal/website/docs/guides/your-first-agent.md)
|
||||
- [0→hero Reference](internal/website/docs/guides/zero-to-hero.md)
|
||||
- [Agents and Workflows](internal/website/docs/guides/agents-and-workflows.md)
|
||||
- [Agent Design](internal/docs/AGENT_DESIGN.md)
|
||||
|
||||
+1
-1
@@ -66,7 +66,7 @@ hosted service, enterprise tier, or venture funding. See
|
||||
## Contributing & feedback
|
||||
|
||||
Pick an item, open an issue to discuss the approach, and submit a PR. Or join the
|
||||
[Discord](https://discord.gg/G8Gk5j3uXr). Include tests, run `make test` and
|
||||
[Discord](https://discord.gg/WeMU5AGxD). Include tests, run `make test` and
|
||||
`make lint`.
|
||||
|
||||
## Version support
|
||||
|
||||
+1
-1
@@ -174,6 +174,6 @@ We currently do not offer a bug bounty program, but we greatly appreciate respon
|
||||
|
||||
For security questions that are not vulnerabilities, please:
|
||||
- Open a discussion: https://github.com/micro/go-micro/discussions
|
||||
- Join Discord: https://discord.gg/G8Gk5j3uXr
|
||||
- Join Discord: https://discord.gg/WeMU5AGxD
|
||||
- Email: support@go-micro.dev
|
||||
|
||||
|
||||
@@ -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
|
||||
@@ -333,19 +329,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
|
||||
return nil, fmt.Errorf("agent run %s paused for approval: %s", run.ID, a.pause.Message)
|
||||
}
|
||||
|
||||
if len(resp.ToolCalls) == 0 {
|
||||
if calls, answer, ok := a.executeTextToolCalls(ctx, resp.Reply, toolList); ok {
|
||||
resp.ToolCalls = calls
|
||||
if resp.Answer == "" {
|
||||
resp.Answer = answer
|
||||
}
|
||||
trimmedReply := strings.TrimSpace(resp.Reply)
|
||||
if strings.HasPrefix(trimmedReply, "{") || strings.HasPrefix(trimmedReply, "[") || strings.HasPrefix(trimmedReply, "```") {
|
||||
resp.Reply = ""
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if resp.Reply != "" {
|
||||
a.mem.Add("assistant", resp.Reply)
|
||||
}
|
||||
|
||||
+1
-35
@@ -290,11 +290,7 @@ func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
|
||||
if call.Name == toolPlan {
|
||||
return a.handlePlan(call)
|
||||
}
|
||||
res := next(ctx, call)
|
||||
if res.Refused == "" && toolErrorMessage(res) == "" {
|
||||
a.completeNextPlanStep()
|
||||
}
|
||||
return res
|
||||
return next(ctx, call)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -372,36 +368,6 @@ func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
|
||||
return ai.ToolResult{ID: call.ID, Value: call.Input, Content: string(data)}
|
||||
}
|
||||
|
||||
func (a *agentImpl) completeNextPlanStep() {
|
||||
plan := a.loadPlan()
|
||||
if plan == "" {
|
||||
return
|
||||
}
|
||||
var data map[string]any
|
||||
if err := json.Unmarshal([]byte(plan), &data); err != nil {
|
||||
return
|
||||
}
|
||||
steps, ok := data["steps"].([]any)
|
||||
if !ok {
|
||||
return
|
||||
}
|
||||
for _, raw := range steps {
|
||||
step, ok := raw.(map[string]any)
|
||||
if !ok {
|
||||
continue
|
||||
}
|
||||
status, _ := step["status"].(string)
|
||||
if status == "" || status == "pending" || status == "in_progress" {
|
||||
step["status"] = "done"
|
||||
b, err := json.Marshal(data)
|
||||
if err == nil {
|
||||
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: b})
|
||||
}
|
||||
return
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 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)
|
||||
|
||||
@@ -102,45 +102,6 @@ func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestCheckpointSkipsDuplicateToolWithinAsk(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "tool-dedupe-agent")
|
||||
toolRuns := 0
|
||||
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 Design task", "status": "pending"},
|
||||
},
|
||||
}})
|
||||
for i := 0; i < 3; i++ {
|
||||
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.create", Input: map[string]any{"title": "Design"}})
|
||||
if res.Content != "created Design" {
|
||||
t.Fatalf("tool result %d = %q, want cached created Design", i, res.Content)
|
||||
}
|
||||
}
|
||||
return &ai.Response{Reply: "done"}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
a := newTestAgent(Name("tool-dedupe-agent"), WithCheckpoint(cp),
|
||||
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
|
||||
toolRuns++
|
||||
return "created Design", nil
|
||||
}))
|
||||
if _, err := a.Ask(ctx, "create Design once"); err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if toolRuns != 1 {
|
||||
t.Fatalf("tool executions = %d, want duplicate calls within the run replayed from checkpoint", toolRuns)
|
||||
}
|
||||
if plan := a.loadPlan(); !strings.Contains(plan, `"status":"done"`) {
|
||||
t.Fatalf("plan = %s, want completed action marked done", plan)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
|
||||
ctx := context.Background()
|
||||
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "restart-resume-agent")
|
||||
|
||||
@@ -171,50 +171,3 @@ func TestAgentProviderConformanceFakeError(t *testing.T) {
|
||||
t.Fatalf("Ask error = %v, want conformance provider failure", err)
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
|
||||
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
|
||||
if opts.ToolHandler == nil {
|
||||
return nil, errors.New("missing tool handler")
|
||||
}
|
||||
return &ai.Response{
|
||||
Reply: `{"name":"conformance_echo","input":{"value":"agent-conformance"}}`,
|
||||
}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
var sawTool bool
|
||||
a := New(
|
||||
Name("conformance-text-tool"),
|
||||
Provider("fake"),
|
||||
WithRegistry(registry.NewMemoryRegistry()),
|
||||
WithStore(store.NewMemoryStore()),
|
||||
WithMemory(NewInMemory(4)),
|
||||
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
|
||||
"value": map[string]any{"type": "string"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
sawTool = true
|
||||
if input["value"] != "agent-conformance" {
|
||||
return "", fmt.Errorf("unexpected value %v", input["value"])
|
||||
}
|
||||
return `{"marker":"agent-conformance-ok"}`, nil
|
||||
}),
|
||||
)
|
||||
|
||||
resp, err := a.Ask(context.Background(), "Run the text tool call fallback.")
|
||||
if err != nil {
|
||||
t.Fatalf("Ask: %v", err)
|
||||
}
|
||||
if !sawTool {
|
||||
t.Fatal("text tool call fallback did not execute the tool")
|
||||
}
|
||||
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "conformance_echo" {
|
||||
t.Fatalf("ToolCalls = %+v, want conformance_echo", resp.ToolCalls)
|
||||
}
|
||||
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
|
||||
t.Fatalf("Reply = %q, want tool result marker", resp.Reply)
|
||||
}
|
||||
if strings.Contains(resp.Reply, `"name":"conformance_echo"`) {
|
||||
t.Fatalf("Reply = %q, want tool result instead of raw JSON", resp.Reply)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -1,141 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"regexp"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
)
|
||||
|
||||
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
|
||||
|
||||
type textToolCall struct {
|
||||
ID string `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Tool string `json:"tool"`
|
||||
Input map[string]any `json:"input"`
|
||||
Arguments map[string]any `json:"arguments"`
|
||||
}
|
||||
|
||||
// executeTextToolCalls is a compatibility fallback for providers that return a
|
||||
// tool call as text JSON instead of a structured tool_calls field. It only runs
|
||||
// calls whose names match the tools offered to the model, so ordinary JSON
|
||||
// answers are left untouched.
|
||||
func (a *agentImpl) executeTextToolCalls(ctx context.Context, reply string, tools []ai.Tool) ([]ai.ToolCall, string, bool) {
|
||||
calls := parseTextToolCalls(reply, tools)
|
||||
if len(calls) == 0 {
|
||||
return nil, "", false
|
||||
}
|
||||
|
||||
handler := a.toolHandler()
|
||||
results := make([]string, 0, len(calls))
|
||||
for i := range calls {
|
||||
result := handler(ctx, calls[i])
|
||||
calls[i].Result = result.Content
|
||||
if result.Refused != "" {
|
||||
calls[i].Error = result.Refused
|
||||
}
|
||||
if result.Content != "" {
|
||||
results = append(results, result.Content)
|
||||
}
|
||||
}
|
||||
return calls, strings.Join(results, "\n"), true
|
||||
}
|
||||
|
||||
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
|
||||
allowed := map[string]bool{}
|
||||
for _, tool := range tools {
|
||||
allowed[tool.Name] = true
|
||||
if tool.OriginalName != "" {
|
||||
allowed[tool.OriginalName] = true
|
||||
}
|
||||
}
|
||||
if len(allowed) == 0 {
|
||||
return nil
|
||||
}
|
||||
|
||||
for _, candidate := range jsonCandidates(text) {
|
||||
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
|
||||
return calls
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func jsonCandidates(text string) []string {
|
||||
trimmed := strings.TrimSpace(text)
|
||||
var out []string
|
||||
if trimmed != "" {
|
||||
out = append(out, trimmed)
|
||||
}
|
||||
for _, match := range fencedJSONBlock.FindAllStringSubmatch(text, -1) {
|
||||
if len(match) > 1 {
|
||||
out = append(out, strings.TrimSpace(match[1]))
|
||||
}
|
||||
}
|
||||
if start, end := strings.IndexAny(text, "[{"), strings.LastIndexAny(text, "]}"); start >= 0 && end > start {
|
||||
out = append(out, strings.TrimSpace(text[start:end+1]))
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func decodeTextToolCalls(candidate string, allowed map[string]bool) []ai.ToolCall {
|
||||
var root any
|
||||
if err := json.Unmarshal([]byte(candidate), &root); err != nil {
|
||||
return nil
|
||||
}
|
||||
return collectTextToolCalls(root, allowed)
|
||||
}
|
||||
|
||||
func collectTextToolCalls(v any, allowed map[string]bool) []ai.ToolCall {
|
||||
switch x := v.(type) {
|
||||
case []any:
|
||||
var out []ai.ToolCall
|
||||
for _, item := range x {
|
||||
out = append(out, collectTextToolCalls(item, allowed)...)
|
||||
}
|
||||
return out
|
||||
case map[string]any:
|
||||
if nested, ok := firstNestedToolCalls(x); ok {
|
||||
return collectTextToolCalls(nested, allowed)
|
||||
}
|
||||
call := mapToTextToolCall(x)
|
||||
name := call.Name
|
||||
if name == "" {
|
||||
name = call.Tool
|
||||
}
|
||||
input := call.Input
|
||||
if input == nil {
|
||||
input = call.Arguments
|
||||
}
|
||||
if name == "" || !allowed[name] || input == nil {
|
||||
return nil
|
||||
}
|
||||
id := call.ID
|
||||
if id == "" {
|
||||
id = fmt.Sprintf("text-call-%s", strings.ReplaceAll(name, ".", "_"))
|
||||
}
|
||||
return []ai.ToolCall{{ID: id, Name: name, Input: input}}
|
||||
default:
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func firstNestedToolCalls(m map[string]any) (any, bool) {
|
||||
for _, key := range []string{"tool_calls", "toolCalls", "calls"} {
|
||||
if v, ok := m[key]; ok {
|
||||
return v, true
|
||||
}
|
||||
}
|
||||
return nil, false
|
||||
}
|
||||
|
||||
func mapToTextToolCall(m map[string]any) textToolCall {
|
||||
b, _ := json.Marshal(m)
|
||||
var call textToolCall
|
||||
_ = json.Unmarshal(b, &call)
|
||||
return call
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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())
|
||||
}
|
||||
}
|
||||
@@ -625,49 +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 three workflows and a queue:
|
||||
|
||||
| Role | File | What it does |
|
||||
|------|------|--------------|
|
||||
| Planner | `.github/workflows/loop-planner.yml` | Keeps a ranked queue in `.github/loop/PRIORITIES.md` |
|
||||
| Builder | `.github/workflows/loop-builder.yml` | Builds the top open item as a single-concern PR, auto-merged on green CI |
|
||||
| Triage | `.github/workflows/loop-triage.yml` | Turns CI failures into scoped fix issues, back into the queue |
|
||||
|
||||
Direction lives in `.github/loop/NORTH_STAR.md` — edit it to steer the loop.
|
||||
|
||||
Common flags:
|
||||
|
||||
```bash
|
||||
micro loop init \
|
||||
--agent @codex \
|
||||
--token-secret LOOP_TOKEN \
|
||||
--branch main \
|
||||
--ci-workflow CI
|
||||
```
|
||||
|
||||
- `--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
|
||||
|
||||
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.
|
||||
|
||||
@@ -36,14 +36,6 @@ func init() {
|
||||
Name: "agent",
|
||||
Usage: "Manage AI agents",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "preflight",
|
||||
Aliases: []string{"doctor"},
|
||||
Usage: "Check local prerequisites before the first provider-backed agent",
|
||||
Action: func(c *cli.Context) error {
|
||||
return runAgentPreflight(os.Stdout, defaultPreflightDeps())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List registered agents",
|
||||
|
||||
@@ -1,140 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
type preflightCheck struct {
|
||||
Name string
|
||||
OK bool
|
||||
Detail string
|
||||
Fix string
|
||||
}
|
||||
|
||||
type preflightDeps struct {
|
||||
lookPath func(string) (string, error)
|
||||
commandOutput func(string, ...string) ([]byte, error)
|
||||
executable func() (string, error)
|
||||
version func() string
|
||||
getenv func(string) string
|
||||
listen func(string, string) (net.Listener, error)
|
||||
}
|
||||
|
||||
func defaultPreflightDeps() preflightDeps {
|
||||
return preflightDeps{
|
||||
lookPath: exec.LookPath,
|
||||
commandOutput: func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() },
|
||||
executable: os.Executable,
|
||||
version: func() string { return cmd.App().Version },
|
||||
getenv: os.Getenv,
|
||||
listen: net.Listen,
|
||||
}
|
||||
}
|
||||
|
||||
func runAgentPreflight(w io.Writer, deps preflightDeps) error {
|
||||
checks := agentPreflightChecks(deps)
|
||||
failures := 0
|
||||
fmt.Fprintln(w, "First-agent preflight")
|
||||
for _, check := range checks {
|
||||
mark := "✓"
|
||||
if !check.OK {
|
||||
mark = "✗"
|
||||
failures++
|
||||
}
|
||||
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
|
||||
if !check.OK && check.Fix != "" {
|
||||
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
return fmt.Errorf("first-agent preflight failed: %d check(s) need attention", failures)
|
||||
}
|
||||
fmt.Fprintln(w, "\nReady for the first-agent walkthrough: micro run, then open http://localhost:8080/agent or use micro chat.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func agentPreflightChecks(deps preflightDeps) []preflightCheck {
|
||||
if deps.lookPath == nil {
|
||||
deps.lookPath = exec.LookPath
|
||||
}
|
||||
if deps.commandOutput == nil {
|
||||
deps.commandOutput = func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() }
|
||||
}
|
||||
if deps.executable == nil {
|
||||
deps.executable = os.Executable
|
||||
}
|
||||
if deps.version == nil {
|
||||
deps.version = func() string { return cmd.App().Version }
|
||||
}
|
||||
if deps.getenv == nil {
|
||||
deps.getenv = os.Getenv
|
||||
}
|
||||
if deps.listen == nil {
|
||||
deps.listen = net.Listen
|
||||
}
|
||||
|
||||
checks := []preflightCheck{checkGoToolchain(deps), checkMicroBinary(deps), checkProviderKey(deps), checkPortAvailable(deps, ":8080", "micro run gateway and /agent playground")}
|
||||
return checks
|
||||
}
|
||||
|
||||
func checkGoToolchain(deps preflightDeps) preflightCheck {
|
||||
path, err := deps.lookPath("go")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "Go toolchain", Fix: "Install Go 1.24 or newer and ensure go is on PATH."}
|
||||
}
|
||||
out, err := deps.commandOutput("go", "version")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "Go toolchain", Detail: strings.TrimSpace(string(out)), Fix: "Ensure the go command runs successfully."}
|
||||
}
|
||||
return preflightCheck{Name: "Go toolchain", OK: true, Detail: fmt.Sprintf("%s (%s)", firstLine(out), path)}
|
||||
}
|
||||
|
||||
func checkMicroBinary(deps preflightDeps) preflightCheck {
|
||||
exe, err := deps.executable()
|
||||
if err != nil || exe == "" {
|
||||
return preflightCheck{Name: "micro binary", Fix: "Install the micro CLI or run this check through go run ./cmd/micro agent preflight."}
|
||||
}
|
||||
version := deps.version()
|
||||
if version == "" {
|
||||
version = "version unavailable"
|
||||
}
|
||||
return preflightCheck{Name: "micro binary", OK: true, Detail: fmt.Sprintf("%s (%s)", version, exe)}
|
||||
}
|
||||
|
||||
func checkProviderKey(deps preflightDeps) preflightCheck {
|
||||
keys := []string{"MICRO_AI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "ATLASCLOUD_API_KEY"}
|
||||
var found []string
|
||||
for _, k := range keys {
|
||||
if deps.getenv(k) != "" {
|
||||
found = append(found, k)
|
||||
}
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return preflightCheck{Name: "provider API key", Detail: "no supported provider key found", Fix: "Export MICRO_AI_API_KEY or a provider key such as ANTHROPIC_API_KEY before running provider-backed agents."}
|
||||
}
|
||||
return preflightCheck{Name: "provider API key", OK: true, Detail: "found " + strings.Join(found, ", ")}
|
||||
}
|
||||
|
||||
func checkPortAvailable(deps preflightDeps, addr, use string) preflightCheck {
|
||||
ln, err := deps.listen("tcp", addr)
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "local port " + addr, Detail: "busy or unavailable for " + use, Fix: "Stop the process using " + addr + " or run micro run --address with a free port."}
|
||||
}
|
||||
_ = ln.Close()
|
||||
return preflightCheck{Name: "local port " + addr, OK: true, Detail: "available for " + use}
|
||||
}
|
||||
|
||||
func firstLine(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
@@ -1,78 +0,0 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubListener struct{}
|
||||
|
||||
func (stubListener) Accept() (net.Conn, error) { return nil, errors.New("closed") }
|
||||
func (stubListener) Close() error { return nil }
|
||||
func (stubListener) Addr() net.Addr { return stubAddr(":8080") }
|
||||
|
||||
type stubAddr string
|
||||
|
||||
func (a stubAddr) Network() string { return "tcp" }
|
||||
func (a stubAddr) String() string { return string(a) }
|
||||
|
||||
func TestRunAgentPreflightPassesWithKeyAndFreePort(t *testing.T) {
|
||||
deps := preflightDeps{
|
||||
lookPath: func(name string) (string, error) { return "/usr/bin/" + name, nil },
|
||||
commandOutput: func(name string, args ...string) ([]byte, error) {
|
||||
return []byte("go version go1.24.0 linux/amd64\n"), nil
|
||||
},
|
||||
executable: func() (string, error) { return "/usr/local/bin/micro", nil },
|
||||
getenv: func(key string) string {
|
||||
if key == "ANTHROPIC_API_KEY" {
|
||||
return "set"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
listen: func(network, address string) (net.Listener, error) { return stubListener{}, nil },
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := runAgentPreflight(&out, deps); err != nil {
|
||||
t.Fatalf("runAgentPreflight() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"First-agent preflight", "✓ Go toolchain", "✓ micro binary", "✓ provider API key", "✓ local port :8080", "Ready for the first-agent walkthrough"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentPreflightReportsActionableFailures(t *testing.T) {
|
||||
deps := preflightDeps{
|
||||
lookPath: func(name string) (string, error) { return "", errors.New("not found") },
|
||||
executable: func() (string, error) { return "", errors.New("unknown") },
|
||||
getenv: func(key string) string { return "" },
|
||||
listen: func(network, address string) (net.Listener, error) { return nil, errors.New("in use") },
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := runAgentPreflight(&out, deps)
|
||||
if err == nil {
|
||||
t.Fatal("runAgentPreflight() error = nil")
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"✗ Go toolchain", "Install Go 1.24", "✗ micro binary", "✗ provider API key", "ANTHROPIC_API_KEY", "✗ local port :8080", "micro run --address"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstLine(t *testing.T) {
|
||||
if got := firstLine([]byte("one\ntwo")); got != "one" {
|
||||
t.Fatalf("firstLine() = %q", got)
|
||||
}
|
||||
if got := firstLine([]byte(" single ")); got != "single" {
|
||||
t.Fatalf("firstLine() = %q", got)
|
||||
}
|
||||
}
|
||||
@@ -1,51 +0,0 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
microcmd "go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
commands := map[string]bool{}
|
||||
subcommands := map[string]map[string]bool{}
|
||||
for _, command := range microcmd.DefaultCmd.App().Commands {
|
||||
commands[command.Name] = true
|
||||
for _, subcommand := range command.Subcommands {
|
||||
if subcommands[command.Name] == nil {
|
||||
subcommands[command.Name] = map[string]bool{}
|
||||
}
|
||||
subcommands[command.Name][subcommand.Name] = true
|
||||
}
|
||||
}
|
||||
|
||||
for _, want := range []string{"new", "run", "chat", "inspect", "agent"} {
|
||||
if !commands[want] {
|
||||
t.Fatalf("first-agent walkthrough missing %q command", want)
|
||||
}
|
||||
}
|
||||
if !subcommands["agent"]["preflight"] {
|
||||
t.Fatal("first-agent walkthrough missing preflight boundary: agent preflight")
|
||||
}
|
||||
if !subcommands["inspect"]["agent"] {
|
||||
t.Fatal("first-agent walkthrough missing inspect boundary: inspect agent")
|
||||
}
|
||||
|
||||
chat := commandByName(t, "chat")
|
||||
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") {
|
||||
t.Fatalf("micro chat should describe the service-to-agent walkthrough boundary; description was %q", chat.Description)
|
||||
}
|
||||
}
|
||||
|
||||
func commandByName(t *testing.T, name string) *cli.Command {
|
||||
t.Helper()
|
||||
for _, command := range microcmd.DefaultCmd.App().Commands {
|
||||
if command.Name == name {
|
||||
return command
|
||||
}
|
||||
}
|
||||
t.Fatalf("missing command %q", name)
|
||||
return nil
|
||||
}
|
||||
@@ -1,300 +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 — a planner that keeps a ranked
|
||||
// queue, a builder that builds the top item as a single-concern PR, and a triage
|
||||
// pass that turns CI failures into fix issues — that dispatch a coding agent by
|
||||
// @mention on a fresh tracking issue each run. `micro loop init` writes those
|
||||
// workflows (plus a NORTH_STAR and PRIORITIES queue) into a repo; `micro loop
|
||||
// verify` checks that a repo is wired correctly.
|
||||
package loop
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"embed"
|
||||
"fmt"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"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 workflow templates. It is the
|
||||
// whole "config vs core" boundary: the workflows are the reusable core, these
|
||||
// fields are what a given repo tunes.
|
||||
type config struct {
|
||||
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
|
||||
PlannerCron string // cron for the planner
|
||||
BuilderCron string // cron for the builder
|
||||
}
|
||||
|
||||
// generated workflow files: template name -> destination (relative to repo root).
|
||||
var workflows = map[string]string{
|
||||
"templates/loop-planner.yml.tmpl": ".github/workflows/loop-planner.yml",
|
||||
"templates/loop-builder.yml.tmpl": ".github/workflows/loop-builder.yml",
|
||||
"templates/loop-triage.yml.tmpl": ".github/workflows/loop-triage.yml",
|
||||
}
|
||||
|
||||
// static (non-templated) docs: template name -> destination.
|
||||
var docs = map[string]string{
|
||||
"templates/NORTH_STAR.md": ".github/loop/NORTH_STAR.md",
|
||||
"templates/PRIORITIES.md": ".github/loop/PRIORITIES.md",
|
||||
}
|
||||
|
||||
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, and triage — gated by CI.
|
||||
|
||||
The loop has three 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
|
||||
|
||||
Direction lives in .github/loop/NORTH_STAR.md — edit it to steer the loop.
|
||||
|
||||
Examples:
|
||||
# Scaffold the loop into the current repo
|
||||
micro loop init
|
||||
|
||||
# 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 and queue into a repo",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "dir", Usage: "Target repo directory", Value: "."},
|
||||
&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.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")
|
||||
cfg := config{
|
||||
DefaultBranch: c.String("branch"),
|
||||
AgentMention: strings.TrimSpace(c.String("agent")),
|
||||
TokenSecret: strings.TrimSpace(c.String("token-secret")),
|
||||
CIWorkflow: c.String("ci-workflow"),
|
||||
PlannerCron: c.String("planner-cron"),
|
||||
BuilderCron: c.String("builder-cron"),
|
||||
}
|
||||
if cfg.DefaultBranch == "" {
|
||||
cfg.DefaultBranch = detectDefaultBranch(dir)
|
||||
}
|
||||
if !strings.HasPrefix(cfg.AgentMention, "@") {
|
||||
cfg.AgentMention = "@" + cfg.AgentMention
|
||||
}
|
||||
|
||||
if err := scaffold(dir, cfg, c.Bool("force")); err != nil {
|
||||
return err
|
||||
}
|
||||
printNextSteps(cfg)
|
||||
return nil
|
||||
}
|
||||
|
||||
// scaffold renders the workflow templates and writes the loop files into dir.
|
||||
// Static docs (NORTH_STAR, PRIORITIES) are never clobbered even with force, so
|
||||
// re-running init can't wipe curated direction or a hand-tuned queue.
|
||||
func scaffold(dir string, cfg config, force bool) error {
|
||||
for tmplName, dest := range workflows {
|
||||
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)
|
||||
}
|
||||
|
||||
for tmplName, dest := range docs {
|
||||
full := filepath.Join(dir, dest)
|
||||
if fileExists(full) {
|
||||
fmt.Printf(" kept %s (already exists)\n", dest)
|
||||
continue
|
||||
}
|
||||
b, err := templatesFS.ReadFile(tmplName)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if err := writeFile(full, b, 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) {
|
||||
for _, dest := range workflows {
|
||||
if !fileExists(filepath.Join(dir, dest)) {
|
||||
missing = append(missing, dest)
|
||||
}
|
||||
}
|
||||
for _, dest := range docs {
|
||||
if !fileExists(filepath.Join(dir, dest)) {
|
||||
missing = append(missing, dest)
|
||||
}
|
||||
}
|
||||
// The loop is only as good as its gate: warn if there's no non-loop
|
||||
// workflow to serve as CI.
|
||||
if !hasCIWorkflow(dir) {
|
||||
warnings = append(warnings, "no non-loop workflow found in .github/workflows — the loop needs a CI gate (build/test/lint) to merge safely")
|
||||
}
|
||||
return warnings, missing
|
||||
}
|
||||
|
||||
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 file(s) missing) — run `micro loop init`", len(missing))
|
||||
}
|
||||
|
||||
fmt.Println(" OK loop workflows and queue are present")
|
||||
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, ".github", "workflows"))
|
||||
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) {
|
||||
fmt.Printf(`
|
||||
Loop scaffolded. 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.
|
||||
|
||||
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:
|
||||
Actions → "Loop: Planner" / "Loop: Builder" → Run workflow.
|
||||
|
||||
Verify anytime with: micro loop verify
|
||||
`, cfg.TokenSecret, cfg.AgentMention, cfg.CIWorkflow, cfg.DefaultBranch)
|
||||
}
|
||||
@@ -1,107 +0,0 @@
|
||||
package loop
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
func TestRenderProducesValidPlaceholderFreeYAML(t *testing.T) {
|
||||
cfg := config{
|
||||
DefaultBranch: "main",
|
||||
AgentMention: "@codex",
|
||||
TokenSecret: "LOOP_TOKEN",
|
||||
CIWorkflow: "CI",
|
||||
PlannerCron: "0 * * * *",
|
||||
BuilderCron: "30 * * * *",
|
||||
}
|
||||
|
||||
for tmplName := range workflows {
|
||||
rendered, err := render(tmplName, cfg)
|
||||
if err != nil {
|
||||
t.Fatalf("render %s: %v", tmplName, err)
|
||||
}
|
||||
s := string(rendered)
|
||||
|
||||
// No unresolved substitution delimiters should remain...
|
||||
if strings.Contains(s, "<<") || strings.Contains(s, ">>") {
|
||||
t.Errorf("%s still contains << >> placeholders after render", tmplName)
|
||||
}
|
||||
// ...but GitHub Actions' own ${{ }} expressions must survive verbatim.
|
||||
if !strings.Contains(s, "${{ secrets.LOOP_TOKEN") {
|
||||
t.Errorf("%s lost its ${{ secrets.LOOP_TOKEN }} expression", tmplName)
|
||||
}
|
||||
// The configured values must be substituted in.
|
||||
if !strings.Contains(s, "@codex") {
|
||||
t.Errorf("%s missing agent mention", tmplName)
|
||||
}
|
||||
// Structural sanity: a workflow needs these top-level keys.
|
||||
for _, key := range []string{"name:", "on:", "jobs:"} {
|
||||
if !strings.Contains(s, key) {
|
||||
t.Errorf("%s missing top-level %q", tmplName, key)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestInitThenVerify(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
// A non-loop workflow so verify's CI-gate check passes.
|
||||
mustWrite(t, filepath.Join(dir, ".github/workflows/ci.yml"), "name: CI\n")
|
||||
|
||||
if err := scaffold(dir, config{
|
||||
DefaultBranch: "main",
|
||||
AgentMention: "@codex",
|
||||
TokenSecret: "LOOP_TOKEN",
|
||||
CIWorkflow: "CI",
|
||||
PlannerCron: "0 * * * *",
|
||||
BuilderCron: "30 * * * *",
|
||||
}, false); err != nil {
|
||||
t.Fatalf("scaffold: %v", err)
|
||||
}
|
||||
|
||||
for _, dest := range workflows {
|
||||
if !fileExists(filepath.Join(dir, dest)) {
|
||||
t.Errorf("expected %s to be written", dest)
|
||||
}
|
||||
}
|
||||
for _, dest := range docs {
|
||||
if !fileExists(filepath.Join(dir, dest)) {
|
||||
t.Errorf("expected %s to be written", dest)
|
||||
}
|
||||
}
|
||||
|
||||
// A second scaffold without --force must fail on an existing workflow.
|
||||
if err := scaffold(dir, config{DefaultBranch: "main", AgentMention: "@codex", TokenSecret: "LOOP_TOKEN", CIWorkflow: "CI", PlannerCron: "0 * * * *", BuilderCron: "30 * * * *"}, false); err == nil {
|
||||
t.Error("expected second scaffold without --force to fail")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyMissingFilesFails(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if _, missing := verifyState(dir); len(missing) == 0 {
|
||||
t.Error("expected missing files in an empty dir")
|
||||
}
|
||||
}
|
||||
|
||||
func TestVerifyWarnsWithoutCIGate(t *testing.T) {
|
||||
dir := t.TempDir()
|
||||
if err := scaffold(dir, config{DefaultBranch: "main", AgentMention: "@codex", TokenSecret: "LOOP_TOKEN", CIWorkflow: "CI", PlannerCron: "0 * * * *", BuilderCron: "30 * * * *"}, false); err != nil {
|
||||
t.Fatalf("scaffold: %v", err)
|
||||
}
|
||||
// Only loop-* workflows exist → no CI gate.
|
||||
if hasCIWorkflow(dir) {
|
||||
t.Error("expected no CI gate when only loop-* workflows are present")
|
||||
}
|
||||
}
|
||||
|
||||
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)
|
||||
}
|
||||
}
|
||||
@@ -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.
|
||||
@@ -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,46 +0,0 @@
|
||||
name: "Loop: Builder"
|
||||
|
||||
# Generated by `micro loop init`. The GENERATOR of the loop: each run it opens a
|
||||
# fresh tracking issue and dispatches the agent to build the top open item from
|
||||
# .github/loop/PRIORITIES.md as a single-concern PR, then enables native
|
||||
# auto-merge so the PR lands once the required CI checks pass. Branch protection
|
||||
# (required checks, 0 approvals) is the gate — there is no merge sweep.
|
||||
#
|
||||
# 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
|
||||
# and only the first PR opens. Gated on << .TokenSecret >> (see loop-planner.yml).
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "<< .BuilderCron >>"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: loop-builder
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Open an increment issue and dispatch the agent
|
||||
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 (see loop-planner.yml)."
|
||||
exit 0
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Loop: build increment #$RUN_NUMBER" \
|
||||
--body "Autonomous build increment. Direction: .github/loop/NORTH_STAR.md. Queue: .github/loop/PRIORITIES.md.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching the builder."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"<< .AgentMention >> 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_NUM\`, \`git push -u origin loop/increment-$ISSUE_NUM\`, \`gh pr create --base << .DefaultBranch >> --title \"<title>\" --body \"<body; include 'Closes #<the item's issue>' so it leaves the queue, and 'Closes #$ISSUE_NUM' for this tracker>\"\`, then \`gh pr merge --squash --auto --delete-branch\` so it lands on green CI. One concern per PR; stay out of breaking public API and brand/positioning copy."
|
||||
@@ -1,48 +0,0 @@
|
||||
name: "Loop: Planner"
|
||||
|
||||
# Generated by `micro loop init`. Part of an autonomous improvement loop:
|
||||
# a PLANNER (this file) keeps a ranked queue, a BUILDER builds the top item,
|
||||
# and CI + a TRIAGE pass are the evaluator. The loop dispatches a coding agent
|
||||
# by @mention on a fresh tracking issue each run.
|
||||
#
|
||||
# Gated on the << .TokenSecret >> secret: the agent ignores @mentions from the
|
||||
# github-actions bot, so the dispatch must post as a real user (a PAT). Until
|
||||
# that secret is set the workflow runs but no-ops. Direction lives in
|
||||
# .github/loop/NORTH_STAR.md; the ranked queue in .github/loop/PRIORITIES.md.
|
||||
|
||||
on:
|
||||
workflow_dispatch: {}
|
||||
schedule:
|
||||
- cron: "<< .PlannerCron >>"
|
||||
|
||||
permissions:
|
||||
issues: write
|
||||
|
||||
concurrency:
|
||||
group: loop-planner
|
||||
cancel-in-progress: false
|
||||
|
||||
jobs:
|
||||
dispatch:
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- name: Open a planning issue and dispatch the agent
|
||||
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."
|
||||
echo "The agent ignores @mentions from the github-actions bot, so a"
|
||||
echo "user PAT is required. Add a << .TokenSecret >> secret to activate."
|
||||
exit 0
|
||||
fi
|
||||
ISSUE_URL=$(gh issue create --repo "$REPO" \
|
||||
--title "Loop: planning review #$RUN_NUMBER" \
|
||||
--body "Autonomous planning pass. Direction: .github/loop/NORTH_STAR.md. Queue: .github/loop/PRIORITIES.md.")
|
||||
ISSUE_NUM="${ISSUE_URL##*/}"
|
||||
echo "Opened issue #$ISSUE_NUM — dispatching the planner."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"<< .AgentMention >> Act as the planner for this repository. (1) Read .github/loop/NORTH_STAR.md for direction, then assess current state — recently merged PRs and open issues — so the queue reflects reality (drop done items, don't re-queue in-flight work). (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 with 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_NUM\`, \`git push -u origin loop/planner-$ISSUE_NUM\`, \`gh pr create --base << .DefaultBranch >> --title \"<title>\" --body \"<summary, Closes #$ISSUE_NUM>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. If the queue is already accurate, just close this issue (\`gh issue close $ISSUE_NUM\`). 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."
|
||||
@@ -1,46 +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, this dispatches the
|
||||
# agent to root-cause the failure and file scoped fix issues back into the
|
||||
# planner's queue — so failures become fixes with no human in the middle, short
|
||||
# of a decision that is genuinely a human's. 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:
|
||||
- name: File a triage issue and dispatch the agent
|
||||
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 (see loop-planner.yml)."
|
||||
exit 0
|
||||
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."
|
||||
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
|
||||
"<< .AgentMention >> Triage the failed CI run at $RUN_URL. 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>\"\`) 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 — do NOT auto-file it as a routine fix. Close this issue (\`gh issue close $ISSUE_NUM\`) when triage is done."
|
||||
@@ -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"
|
||||
|
||||
@@ -324,4 +324,4 @@ Apache 2.0 - See [LICENSE](../../LICENSE) for details.
|
||||
## Support
|
||||
|
||||
- GitHub Discussions: https://github.com/micro/go-micro/discussions
|
||||
- Discord: https://discord.gg/G8Gk5j3uXr
|
||||
- Discord: https://discord.gg/WeMU5AGxD
|
||||
|
||||
@@ -102,4 +102,4 @@ pytest tests/integration/ -v
|
||||
## Questions?
|
||||
|
||||
- GitHub Discussions: https://github.com/micro/go-micro/discussions
|
||||
- Discord: https://discord.gg/G8Gk5j3uXr
|
||||
- Discord: https://discord.gg/WeMU5AGxD
|
||||
|
||||
@@ -370,4 +370,4 @@ Apache 2.0 - See [LICENSE](../../LICENSE) for details.
|
||||
## Support
|
||||
|
||||
- GitHub Discussions: https://github.com/micro/go-micro/discussions
|
||||
- Discord: https://discord.gg/G8Gk5j3uXr
|
||||
- Discord: https://discord.gg/WeMU5AGxD
|
||||
|
||||
@@ -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.")
|
||||
}
|
||||
@@ -10,26 +10,6 @@ and can stop or revert anything at any time.
|
||||
> **services → agents → workflows**. Judge each change against it — work that
|
||||
> doesn't move toward that lifecycle isn't an improvement, however clean.
|
||||
|
||||
## The pipeline (planner → generator → evaluator)
|
||||
|
||||
The development process is an operational instance of the long-running-agent
|
||||
harness pattern ([Anthropic on harness design](https://www.anthropic.com/engineering/harness-design-long-running-apps)) —
|
||||
a planner, a generator, and a *separate* evaluator — distributed across GitHub
|
||||
Actions instead of subagents. Each role is a workflow:
|
||||
|
||||
| Role | Workflow (action name) | What it does |
|
||||
|------|------------------------|--------------|
|
||||
| **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-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).
|
||||
|
||||
## Autonomy
|
||||
|
||||
Full autonomy, **no approval gates**. Each increment: Claude Code picks the work,
|
||||
@@ -92,7 +72,7 @@ Grounded in real signal, never speculative rewrites. Each cycle draws from:
|
||||
tracking issue for each increment and dispatches Codex there. It needs a
|
||||
`CODEX_TRIGGER_TOKEN` repo secret from a user account Codex responds to;
|
||||
without that secret the workflow deliberately no-ops to avoid ignored bot
|
||||
comments. See `.github/workflows/loop-builder.yml` and the mechanics
|
||||
comments. See `.github/workflows/continuous-improvement.yml` and the mechanics
|
||||
below.
|
||||
|
||||
## How the durable loop works (mechanics)
|
||||
@@ -147,20 +127,13 @@ 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.
|
||||
|
||||
- **DevRel — daily** (`.github/workflows/loop-devrel.yml`). Audits the public
|
||||
- **DevRel — daily** (`.github/workflows/devrel-review.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
|
||||
actually merged (Keep-a-Changelog format, user-facing entries only — internal
|
||||
loop/CI churn is skipped), and rolls `[Unreleased]` into a dated version
|
||||
heading whenever a new `v6.MINOR.PATCH` tag has been cut (by `loop-release`).
|
||||
When enough user-facing work has accumulated (roughly weekly, not a near-empty
|
||||
post every day) it also drafts a "what's new" changelog blog post narrating it.
|
||||
**Autonomy boundary:** safe factual-alignment and crispness fixes — *including
|
||||
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.
|
||||
- **Architect — continuous (hourly)** (`.github/workflows/loop-architect.yml`).
|
||||
Star, README crispness, and blog-worthy material. **Autonomy boundary:** safe
|
||||
factual-alignment and crispness fixes auto-merge like any increment;
|
||||
brand/positioning copy and blog drafts are *surfaced in a report* for the
|
||||
human, never auto-merged.
|
||||
- **Architect — continuous (hourly)** (`.github/workflows/architecture-review.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
|
||||
@@ -181,20 +154,6 @@ roadmap-driven by default, not a fresh guess every hour. Cadence is tunable in e
|
||||
workflow's `cron`; the human can reorder `PRIORITIES.md` or its issues at any time
|
||||
to redirect. Codex is serial, so these passes queue behind any in-flight increment.
|
||||
|
||||
## Failure triage (the feedback loop)
|
||||
|
||||
The loop also closes on its own failures. `.github/workflows/loop-triage.yml`
|
||||
fires when the live provider-conformance harness finishes with `conclusion:
|
||||
failure` (scheduled/manual runs only), and dispatches Codex to **triage** the
|
||||
failing run: read the logs, root-cause each distinct failure, **dedupe** against
|
||||
open issues (comment "recurred" rather than filing a duplicate), and file a scoped
|
||||
`codex`/`enhancement` issue for each genuine, self-contained defect — which the
|
||||
increment loop then builds and the next harness run verifies. Transient flakes
|
||||
(live-model latency, provider outages) are ignored; anything needing a breaking or
|
||||
architectural change is escalated as `needs-human` instead of auto-built. This is
|
||||
the hill-climbing layer: CI/harness failures become fixes with no human in the
|
||||
middle, short of a decision that's genuinely the human's.
|
||||
|
||||
## Stop / redirect
|
||||
|
||||
- In-session: `CronDelete <id>` (or end the session).
|
||||
|
||||
@@ -21,13 +21,8 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Make the first-agent on-ramp discoverable from README and website navigation** ([#3640](https://github.com/micro/go-micro/issues/3640)) — the install and first-run smoke contract shipped in #3635, but adoption still depends on a new developer finding the next step without already knowing the guide names. Put the walkable path from install/scaffold to first agent, chat/inspect, and 0→hero in the README/site wayfinding so the verified harness becomes lived DX rather than hidden depth.
|
||||
2. **Make plan/delegate live-provider conformance avoid duplicate task side effects** ([#3626](https://github.com/micro/go-micro/issues/3626)) — atlascloud still has an open Now-phase trust gap where plan/delegate created duplicate launch tasks and missed the delegated notification. This remains the highest open runtime fix because plan/delegate is the bridge from agent reasoning to service side effects; developers cannot trust the services → agents lifecycle if a model can replay tool calls and leave handoff pending.
|
||||
3. **Make universe checkout conformance send exactly one concierge notification** ([#3633](https://github.com/micro/go-micro/issues/3633)) — the newest live-provider scan found the durable checkout/universe path resuming correctly but notifying the buyer twice. Rank it next because durable workflows are the 0→hero proof, and resume idempotency must be boring before deeper observability or future interop work matters.
|
||||
4. **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 still receives the tool surface, without letting protocol depth outrank the on-ramp or side-effect safety.
|
||||
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.
|
||||
1. **CI-verify cross-provider agent conformance** ([#3537](https://github.com/micro/go-micro/issues/3537)) — retry/backoff resilience and durable checkpoint/resume have shipped, so the highest-value remaining Now-phase gap is proving the same services → agents → workflows scenario works across every supported model provider. This keeps the harness honest where provider drift most directly breaks developer trust: model calls, tool calls, skip/pass/fail reporting, and CI-safe execution when credentials are absent.
|
||||
2. **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, and added opt-in tool retries; the next step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, and failures. This keeps `micro runs` useful while making the harness observable in the systems developers already run.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -24,7 +24,6 @@ import (
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/gateway/a2a"
|
||||
"go-micro.dev/v6/internal/harness/harnessutil"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
@@ -96,7 +95,7 @@ func main() {
|
||||
reg := registry.NewMemoryRegistry()
|
||||
st := store.NewMemoryStore()
|
||||
var sawTool, sawRunInfo bool
|
||||
agentOpts := []agent.Option{
|
||||
ag := agent.New(
|
||||
agent.Name("a2a-fallback"),
|
||||
agent.Provider(*provider),
|
||||
agent.APIKey(apiKey),
|
||||
@@ -104,7 +103,7 @@ func main() {
|
||||
agent.WithRegistry(reg),
|
||||
agent.WithStore(st),
|
||||
agent.WithMemory(agent.NewInMemory(8)),
|
||||
agent.ModelCallTimeout(45 * time.Second),
|
||||
agent.ModelCallTimeout(45*time.Second),
|
||||
agent.WithTool("fallback_echo", "Echo the A2A fallback marker.", map[string]any{
|
||||
"value": map[string]any{"type": "string", "description": "value to echo"},
|
||||
}, func(ctx context.Context, input map[string]any) (string, error) {
|
||||
@@ -119,9 +118,7 @@ func main() {
|
||||
}
|
||||
return `{"marker":"a2a-fallback-ok"}`, nil
|
||||
}),
|
||||
}
|
||||
agentOpts = append(agentOpts, harnessutil.AgentOptions(*provider)...)
|
||||
ag := agent.New(agentOpts...)
|
||||
)
|
||||
|
||||
card := a2a.Card("a2a-fallback", "http://example.invalid/a2a-fallback", "", nil)
|
||||
handler := a2a.NewAgentStreamHandler(card, func(ctx context.Context, text string) (string, error) {
|
||||
|
||||
@@ -25,9 +25,10 @@ import (
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/internal/harness/harnessutil"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/service"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
@@ -202,9 +203,8 @@ func main() {
|
||||
fmt.Println("broker connect:", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
cl := harnessutil.Client(*provider, reg)
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
mem := store.NewMemoryStore()
|
||||
liveAgentOpts := harnessutil.AgentOptions(*provider)
|
||||
|
||||
wsSvc := new(WorkspaceService)
|
||||
ws := service.New(service.Name("workspace"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
|
||||
@@ -217,7 +217,7 @@ func main() {
|
||||
go nt.Run()
|
||||
|
||||
// The onboarder agent, registered so the flow can reach it over RPC.
|
||||
onboarderOpts := []agent.Option{
|
||||
onboarder := agent.New(
|
||||
agent.Name("onboarder"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("workspace", "notify"),
|
||||
@@ -225,9 +225,7 @@ func main() {
|
||||
agent.Provider(*provider),
|
||||
agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
}
|
||||
onboarderOpts = append(onboarderOpts, liveAgentOpts...)
|
||||
onboarder := agent.New(onboarderOpts...)
|
||||
)
|
||||
go onboarder.Run()
|
||||
defer onboarder.Stop()
|
||||
|
||||
@@ -240,7 +238,6 @@ func main() {
|
||||
flow.Trigger("events.user.created"),
|
||||
flow.Agent("onboarder"),
|
||||
flow.Prompt("A new user signed up: {{.Data}}. Get them set up."),
|
||||
flow.Timeout(harnessutil.LiveTimeout(*provider)),
|
||||
)
|
||||
if err := f.Register(reg, br, cl); err != nil {
|
||||
fmt.Println("flow register:", err)
|
||||
|
||||
@@ -1,61 +0,0 @@
|
||||
package harnessutil
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
)
|
||||
|
||||
const (
|
||||
// LiveTimeoutEnv overrides the per-call deadline used by live-provider
|
||||
// harness runs. It intentionally does not affect deterministic mock runs.
|
||||
LiveTimeoutEnv = "GO_MICRO_HARNESS_LIVE_TIMEOUT"
|
||||
// DefaultLiveTimeout is generous enough for slow but correct hosted models
|
||||
// while still bounding genuinely stuck live conformance runs.
|
||||
DefaultLiveTimeout = 5 * time.Minute
|
||||
)
|
||||
|
||||
// LiveTimeout returns the harness per-call timeout for live providers. Mock runs
|
||||
// keep their historical fast defaults by returning zero.
|
||||
func LiveTimeout(provider string) time.Duration {
|
||||
if provider == "mock" {
|
||||
return 0
|
||||
}
|
||||
if raw := os.Getenv(LiveTimeoutEnv); raw != "" {
|
||||
d, err := time.ParseDuration(raw)
|
||||
if err != nil {
|
||||
fmt.Fprintf(os.Stderr, "invalid %s=%q; using %s\n", LiveTimeoutEnv, raw, DefaultLiveTimeout)
|
||||
return DefaultLiveTimeout
|
||||
}
|
||||
return d
|
||||
}
|
||||
return DefaultLiveTimeout
|
||||
}
|
||||
|
||||
// Client returns an in-memory-registry client. Live provider harnesses get a
|
||||
// larger request timeout so an otherwise correct agent run is not cut off by the
|
||||
// default 30-second RPC deadline; mock runs are unchanged.
|
||||
func Client(provider string, reg registry.Registry) client.Client {
|
||||
opts := []client.Option{
|
||||
client.Registry(reg),
|
||||
client.Selector(selector.NewSelector(selector.Registry(reg))),
|
||||
}
|
||||
if d := LiveTimeout(provider); d > 0 {
|
||||
opts = append(opts, client.RequestTimeout(d))
|
||||
}
|
||||
return client.NewClient(opts...)
|
||||
}
|
||||
|
||||
// AgentOptions applies the same live-provider timeout to model and tool calls.
|
||||
// The empty result for mock runs preserves their deterministic timing.
|
||||
func AgentOptions(provider string) []agent.Option {
|
||||
if d := LiveTimeout(provider); d > 0 {
|
||||
return []agent.Option{agent.ModelCallTimeout(d), agent.ToolCallTimeout(d)}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -1,33 +0,0 @@
|
||||
package harnessutil
|
||||
|
||||
import (
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
|
||||
func TestLiveTimeoutLeavesMockUnchanged(t *testing.T) {
|
||||
t.Setenv(LiveTimeoutEnv, "2m")
|
||||
if got := LiveTimeout("mock"); got != 0 {
|
||||
t.Fatalf("LiveTimeout(mock) = %s, want 0", got)
|
||||
}
|
||||
if opts := AgentOptions("mock"); len(opts) != 0 {
|
||||
t.Fatalf("AgentOptions(mock) length = %d, want 0", len(opts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveTimeoutUsesDefaultForLiveProviders(t *testing.T) {
|
||||
t.Setenv(LiveTimeoutEnv, "")
|
||||
if got := LiveTimeout("atlascloud"); got != DefaultLiveTimeout {
|
||||
t.Fatalf("LiveTimeout(live) = %s, want %s", got, DefaultLiveTimeout)
|
||||
}
|
||||
if opts := AgentOptions("atlascloud"); len(opts) != 2 {
|
||||
t.Fatalf("AgentOptions(live) length = %d, want 2", len(opts))
|
||||
}
|
||||
}
|
||||
|
||||
func TestLiveTimeoutCanBeOverridden(t *testing.T) {
|
||||
t.Setenv(LiveTimeoutEnv, "90s")
|
||||
if got := LiveTimeout("anthropic"); got != 90*time.Second {
|
||||
t.Fatalf("LiveTimeout override = %s, want 90s", got)
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
@@ -26,9 +26,10 @@ import (
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/internal/harness/harnessutil"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/service"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
@@ -240,11 +241,8 @@ func runPlanDelegate(provider string) error {
|
||||
fmt.Print("Real services, registry, RPC, agent loop, store, delegation.\n\n")
|
||||
|
||||
reg := registry.NewMemoryRegistry()
|
||||
cl := harnessutil.Client(provider, reg)
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
mem := store.NewMemoryStore()
|
||||
liveAgentOpts := harnessutil.AgentOptions(provider)
|
||||
commsCheckpoint := flow.StoreCheckpoint(mem, "agent-comms")
|
||||
conductorCheckpoint := flow.StoreCheckpoint(mem, "agent-conductor")
|
||||
|
||||
// Real services.
|
||||
taskSvc := new(TaskService)
|
||||
@@ -262,32 +260,26 @@ func runPlanDelegate(provider string) error {
|
||||
go notify.Run()
|
||||
|
||||
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
|
||||
commsOpts := []agent.Option{
|
||||
comms := agent.New(
|
||||
agent.Name("comms"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You handle outbound notifications. Use the notify service."),
|
||||
agent.Provider(provider), agent.APIKey(apiKey),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
|
||||
agent.WithCheckpoint(commsCheckpoint),
|
||||
}
|
||||
commsOpts = append(commsOpts, liveAgentOpts...)
|
||||
comms := agent.New(commsOpts...)
|
||||
)
|
||||
go comms.Run()
|
||||
defer comms.Stop()
|
||||
|
||||
// Real conductor agent (owns task), registered so the flow can reach it over RPC.
|
||||
conductorOpts := []agent.Option{
|
||||
conductor := agent.New(
|
||||
agent.Name("conductor"),
|
||||
agent.Address("127.0.0.1:0"),
|
||||
agent.Services("task"),
|
||||
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),
|
||||
}
|
||||
conductorOpts = append(conductorOpts, liveAgentOpts...)
|
||||
conductor := agent.New(conductorOpts...)
|
||||
)
|
||||
go conductor.Run()
|
||||
defer conductor.Stop()
|
||||
|
||||
@@ -311,7 +303,6 @@ func runPlanDelegate(provider string) error {
|
||||
f := flow.New("zero-to-hero",
|
||||
flow.Agent("conductor"),
|
||||
flow.Prompt("Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: {{.Data}}"),
|
||||
flow.Timeout(harnessutil.LiveTimeout(provider)),
|
||||
)
|
||||
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
|
||||
return fmt.Errorf("flow register: %w", err)
|
||||
|
||||
@@ -32,17 +32,17 @@ import (
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"strings"
|
||||
"sync"
|
||||
"sync/atomic"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
"go-micro.dev/v6/broker"
|
||||
"go-micro.dev/v6/client"
|
||||
"go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/gateway/a2a"
|
||||
"go-micro.dev/v6/internal/harness/harnessutil"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/selector"
|
||||
"go-micro.dev/v6/service"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
@@ -108,63 +108,17 @@ type SendResponse struct {
|
||||
Sent bool `json:"sent"`
|
||||
}
|
||||
|
||||
type Notify struct {
|
||||
mu sync.Mutex
|
||||
sent int64
|
||||
seen map[string]struct{}
|
||||
}
|
||||
type Notify struct{ sent int64 }
|
||||
|
||||
// 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 {
|
||||
key := req.To + "\x00" + req.Message
|
||||
s.mu.Lock()
|
||||
if s.seen == nil {
|
||||
s.seen = make(map[string]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)
|
||||
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s %q\n", req.To, req.Message)
|
||||
rsp.Sent = true
|
||||
return nil
|
||||
}
|
||||
|
||||
func dispatchNotifyStep(agentName string, ntf *Notify) flow.StepFunc {
|
||||
dispatch := flow.Dispatch(agentName)
|
||||
return func(ctx context.Context, in flow.State) (flow.State, error) {
|
||||
before := atomic.LoadInt64(&ntf.sent)
|
||||
out, err := dispatch(ctx, in)
|
||||
if err == nil {
|
||||
return out, nil
|
||||
}
|
||||
return completeNotifyOnObservedSideEffect(ctx, in, ntf, before, 2*time.Second, err)
|
||||
}
|
||||
}
|
||||
|
||||
func completeNotifyOnObservedSideEffect(ctx context.Context, in flow.State, ntf *Notify, before int64, wait time.Duration, dispatchErr error) (flow.State, error) {
|
||||
deadline := time.Now().Add(wait)
|
||||
for time.Now().Before(deadline) {
|
||||
if atomic.LoadInt64(&ntf.sent) > before {
|
||||
in.Data = []byte("Buyer notified.")
|
||||
return in, nil
|
||||
}
|
||||
select {
|
||||
case <-ctx.Done():
|
||||
return in, dispatchErr
|
||||
case <-time.After(25 * time.Millisecond):
|
||||
}
|
||||
}
|
||||
return in, dispatchErr
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// mock LLM — the only fake. The concierge agent uses it to decide to notify.
|
||||
// ---------------------------------------------------------------------------
|
||||
@@ -275,9 +229,8 @@ func runUniverse(provider string) int {
|
||||
fmt.Println("broker connect:", err)
|
||||
return 2
|
||||
}
|
||||
cl := harnessutil.Client(provider, reg)
|
||||
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
|
||||
st := store.NewMemoryStore()
|
||||
liveAgentOpts := harnessutil.AgentOptions(provider)
|
||||
|
||||
// Services.
|
||||
inv, pay, ord, ntf := new(Inventory), new(Payment), new(Orders), new(Notify)
|
||||
@@ -290,7 +243,7 @@ func runUniverse(provider string) int {
|
||||
// The concierge agent: guardrails on, plus a tool-execution wrapper
|
||||
// that counts calls — to prove the wrapper seam runs end-to-end.
|
||||
var wrapped int64
|
||||
conciergeOpts := []agent.Option{
|
||||
concierge := agent.New(
|
||||
agent.Name("concierge"),
|
||||
agent.Services("notify"),
|
||||
agent.Prompt("You notify buyers when their order is confirmed."),
|
||||
@@ -304,9 +257,7 @@ func runUniverse(provider string) int {
|
||||
}
|
||||
}),
|
||||
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(st),
|
||||
}
|
||||
conciergeOpts = append(conciergeOpts, liveAgentOpts...)
|
||||
concierge := agent.New(conciergeOpts...)
|
||||
)
|
||||
go concierge.Run()
|
||||
defer concierge.Stop()
|
||||
|
||||
@@ -317,12 +268,11 @@ func runUniverse(provider string) int {
|
||||
checkout := flow.New("checkout",
|
||||
flow.Trigger("events.order.placed"),
|
||||
flow.WithCheckpoint(flow.StoreCheckpoint(st, "checkout")),
|
||||
flow.Timeout(harnessutil.LiveTimeout(provider)),
|
||||
flow.Steps(
|
||||
flow.Step{Name: "reserve", Run: flow.Call("inventory", "Inventory.Reserve")},
|
||||
flow.Step{Name: "charge", Run: flow.Call("payment", "Payment.Charge")},
|
||||
flow.Step{Name: "confirm", Run: flow.Call("orders", "Orders.Confirm")},
|
||||
flow.Step{Name: "notify", Run: dispatchNotifyStep("concierge", ntf)},
|
||||
flow.Step{Name: "notify", Run: flow.Dispatch("concierge")},
|
||||
),
|
||||
)
|
||||
if err := checkout.Register(reg, br, cl); err != nil {
|
||||
@@ -378,7 +328,7 @@ func runUniverse(provider string) int {
|
||||
check(atomic.LoadInt64(&inv.reserves) == 1, "inventory still reserved exactly once (completed step not replayed)")
|
||||
check(atomic.LoadInt64(&pay.attempts) == 2, "payment attempted twice (failed once, then charged)")
|
||||
check(atomic.LoadInt64(&ord.confirms) == 1, "order confirmed after resume")
|
||||
check(atomic.LoadInt64(&ntf.sent) == 1, "buyer notified exactly once by the concierge agent")
|
||||
check(atomic.LoadInt64(&ntf.sent) >= 1, "buyer notified by the concierge agent")
|
||||
check(atomic.LoadInt64(&wrapped) >= 1, "agent tool-execution wrapper observed the call")
|
||||
|
||||
if pend, _ := checkout.Pending(ctx); true {
|
||||
|
||||
@@ -1,13 +1,7 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/flow"
|
||||
)
|
||||
|
||||
// TestUniverseHarnessContract makes the 0→hero harness part of the ordinary
|
||||
@@ -24,47 +18,3 @@ func TestUniverseHarnessContract(t *testing.T) {
|
||||
t.Fatalf("universe harness exited with code %d", code)
|
||||
}
|
||||
}
|
||||
|
||||
func TestNotifyStepCompletesAfterObservedSideEffectTimeout(t *testing.T) {
|
||||
ntf := new(Notify)
|
||||
before := atomic.LoadInt64(&ntf.sent)
|
||||
go func() {
|
||||
time.Sleep(30 * time.Millisecond)
|
||||
var rsp SendResponse
|
||||
if err := ntf.Send(context.Background(), &SendRequest{
|
||||
To: "buyer@acme.com",
|
||||
Message: "Your order is confirmed.",
|
||||
}, &rsp); err != nil {
|
||||
t.Errorf("send notification: %v", err)
|
||||
}
|
||||
}()
|
||||
|
||||
out, err := completeNotifyOnObservedSideEffect(
|
||||
context.Background(),
|
||||
flow.State{Data: []byte(`{"order":"order-1"}`)},
|
||||
ntf,
|
||||
before,
|
||||
time.Second,
|
||||
errors.New("client observed timeout"),
|
||||
)
|
||||
if err != nil {
|
||||
t.Fatalf("notify completion returned error: %v", err)
|
||||
}
|
||||
if got := out.String(); got != "Buyer notified." {
|
||||
t.Fatalf("result = %q, want Buyer notified.", got)
|
||||
}
|
||||
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
|
||||
t.Fatalf("notifications sent = %d, want 1", got)
|
||||
}
|
||||
|
||||
var rsp SendResponse
|
||||
if err := ntf.Send(context.Background(), &SendRequest{
|
||||
To: "buyer@acme.com",
|
||||
Message: "Your order is confirmed.",
|
||||
}, &rsp); err != nil {
|
||||
t.Fatalf("duplicate send: %v", err)
|
||||
}
|
||||
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
|
||||
t.Fatalf("notifications sent after duplicate = %d, want 1", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -4,17 +4,14 @@ This directory owns the no-secret reference scenario for the Go Micro
|
||||
services → agents → workflows lifecycle. It is intentionally small and
|
||||
scripted so CI can run it on every push without external services or model keys.
|
||||
|
||||
`run.sh` verifies five boundaries together:
|
||||
`run.sh` verifies four boundaries together:
|
||||
|
||||
1. **First agent** — `micro new`, `micro agent preflight`, `micro run`,
|
||||
`micro chat`, and `micro inspect agent <name>` remain available as the
|
||||
documented first-agent walkthrough path.
|
||||
2. **Run** — `micro run` remains available as the local development entry point.
|
||||
3. **Chat** — `micro chat` remains available as the interactive agent entry point.
|
||||
4. **Inspect** — `micro inspect agent <name>` and `micro inspect flow <name>`
|
||||
1. **Run** — `micro run` remains available as the local development entry point.
|
||||
2. **Chat** — `micro chat` remains available as the interactive agent entry point.
|
||||
3. **Inspect** — `micro inspect agent <name>` and `micro inspect flow <name>`
|
||||
remain available as the local run-history inspection step, with `micro flow
|
||||
runs` preserving durable workflow history inspection.
|
||||
5. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
4. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
deployment-boundary checkpoint. The dry run resolves configured deploy targets
|
||||
and services and prints the remote build/copy/systemd/health plan without
|
||||
building binaries, opening SSH connections, running `rsync`, or touching
|
||||
@@ -27,17 +24,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).
|
||||
|
||||
@@ -14,10 +14,8 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
|
||||
for _, want := range []string{
|
||||
"make harness",
|
||||
"go test ./cmd/micro/cli/new -run TestZeroToOne -count=1",
|
||||
"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",
|
||||
@@ -40,39 +38,6 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestGuidesNavigationLeadsWithDoing(t *testing.T) {
|
||||
root := filepath.Clean(filepath.Join("..", "..", ".."))
|
||||
nav := readFile(t, filepath.Join(root, "internal", "website", "_data", "navigation.yml"))
|
||||
|
||||
orderedGuides := []string{
|
||||
"/docs/guides/your-first-agent.html",
|
||||
"/docs/guides/zero-to-hero.html",
|
||||
"/docs/guides/plan-delegate.html",
|
||||
"/docs/guides/agent-guardrails.html",
|
||||
"/docs/guides/agents-and-workflows.html",
|
||||
"/docs/guides/agent-patterns.html",
|
||||
"/docs/guides/agent-harness.html",
|
||||
"/docs/guides/agent-loops.html",
|
||||
}
|
||||
|
||||
last := -1
|
||||
for _, guide := range orderedGuides {
|
||||
idx := strings.Index(nav, guide)
|
||||
if idx == -1 {
|
||||
t.Fatalf("website navigation does not expose %s", guide)
|
||||
}
|
||||
if idx < last {
|
||||
t.Fatalf("website navigation should lead with hands-on guides; %s appeared out of order", guide)
|
||||
}
|
||||
last = idx
|
||||
|
||||
doc := strings.TrimPrefix(strings.TrimSuffix(guide, ".html"), "/docs/") + ".md"
|
||||
if _, err := os.Stat(filepath.Join(root, "internal", "website", "docs", doc)); err != nil {
|
||||
t.Fatalf("navigation links to missing guide %s: %v", guide, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func readFile(t *testing.T, name string) string {
|
||||
t.Helper()
|
||||
data, err := os.ReadFile(name)
|
||||
|
||||
@@ -6,12 +6,9 @@ cd "$ROOT"
|
||||
|
||||
# Keep the developer inner-loop boundaries executable and discoverable in CI
|
||||
# without secrets or long-running daemons.
|
||||
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroCLIBoundaries' -count=1
|
||||
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
|
||||
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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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,30 +32,28 @@ examples:
|
||||
- title: Real-World Examples
|
||||
url: /docs/examples/realworld/
|
||||
guides:
|
||||
- title: Debugging your agent
|
||||
url: /docs/guides/debugging-agents.html
|
||||
- title: 0→hero Reference
|
||||
url: /docs/guides/zero-to-hero.html
|
||||
- title: The Agent Harness
|
||||
url: /docs/guides/agent-harness.html
|
||||
- title: Agents and Workflows
|
||||
url: /docs/guides/agents-and-workflows.html
|
||||
- title: Agent Loops
|
||||
url: /docs/guides/agent-loops.html
|
||||
- title: Plan & Delegate
|
||||
url: /docs/guides/plan-delegate.html
|
||||
- title: Agent Guardrails
|
||||
url: /docs/guides/agent-guardrails.html
|
||||
- title: Agents and Workflows
|
||||
url: /docs/guides/agents-and-workflows.html
|
||||
- title: Agent Integration Patterns
|
||||
url: /docs/guides/agent-patterns.html
|
||||
- title: The Agent Harness
|
||||
url: /docs/guides/agent-harness.html
|
||||
- title: Agent Loops
|
||||
url: /docs/guides/agent-loops.html
|
||||
- title: Payments (x402)
|
||||
url: /docs/guides/x402-payments.html
|
||||
- title: Agent2Agent (A2A)
|
||||
url: /docs/guides/a2a-protocol.html
|
||||
- title: Atlas Cloud Integration
|
||||
url: /docs/guides/atlascloud-integration.html
|
||||
- title: AI Provider Guide
|
||||
url: /docs/guides/ai-provider-guide.html
|
||||
- title: Provider Conformance
|
||||
url: /docs/guides/provider-conformance.html
|
||||
- title: Atlas Cloud Integration
|
||||
url: /docs/guides/atlascloud-integration.html
|
||||
- title: Payments (x402)
|
||||
url: /docs/guides/x402-payments.html
|
||||
- title: Comparison
|
||||
url: /docs/guides/comparison.html
|
||||
- title: Migration Guides
|
||||
@@ -78,9 +72,7 @@ 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
|
||||
- /docs/mcp.html
|
||||
- /docs/architecture.html
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<a href="/docs/">Docs</a> ·
|
||||
<a href="/blog/">Blog</a> ·
|
||||
<a href="https://github.com/micro/go-micro">GitHub</a> ·
|
||||
<a href="https://discord.gg/G8Gk5j3uXr">Discord</a> ·
|
||||
<a href="/support">Support</a>
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
<a href="/docs/">Docs</a>
|
||||
<a href="/blog/">Blog</a>
|
||||
<a href="/support"{% if page.nav_active == 'support' %} class="active"{% endif %}>Support</a>
|
||||
<a href="https://discord.gg/G8Gk5j3uXr">Discord</a>
|
||||
<a href="https://github.com/micro/go-micro">GitHub</a>
|
||||
|
||||
@@ -177,4 +177,4 @@ The future of microservices isn't fewer services. It's making them so easy to cr
|
||||
|
||||
---
|
||||
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*
|
||||
|
||||
@@ -102,4 +102,4 @@ micro chat --provider anthropic
|
||||
|
||||
---
|
||||
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*
|
||||
|
||||
@@ -144,4 +144,4 @@ The agent for your services answers. Not because you wired it up. Because the sy
|
||||
|
||||
---
|
||||
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*
|
||||
|
||||
@@ -180,4 +180,4 @@ The agent implementation lives under `go-micro.dev/v6/agent`; most users create
|
||||
|
||||
---
|
||||
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*
|
||||
|
||||
@@ -165,4 +165,4 @@ Read the [Plan & Delegate guide](/docs/guides/plan-delegate) for the full refere
|
||||
|
||||
---
|
||||
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*
|
||||
|
||||
@@ -90,4 +90,4 @@ curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
|
||||
---
|
||||
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
|
||||
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/WeMU5AGxD), or read the [docs](https://go-micro.dev/docs).*
|
||||
|
||||
@@ -58,4 +58,4 @@ A small end-to-end harness now boots a real world (services, a durable flow that
|
||||
|
||||
The upshot is that building an agent in Go Micro looks like building a service, because underneath it is one. The infrastructure for a system of agents is, for the most part, the infrastructure for a system of services, and that is the decade of work already in the framework. Services remain the foundation; agents are what we are building on top of them.
|
||||
|
||||
Thanks to Anthropic for the sponsorship. Join us in a new [Discord](https://discord.gg/G8Gk5j3uXr) to discuss the future of the project.
|
||||
Thanks to Anthropic for the sponsorship. Join us in a new [Discord](https://discord.gg/WeMU5AGxD) to discuss the future of the project.
|
||||
|
||||
@@ -49,4 +49,4 @@ V6 is the beginning of something real. It's secure by default, leads with agents
|
||||
|
||||
Projects dont come back from the dead like this. AI brought it back from the dead. Agents made services more relevant. And Claude Code made it possible for me to ship code again.
|
||||
|
||||
Thanks to Anthropic for the grant and the sponsorship. Join me in the [Discord](https://discord.gg/G8Gk5j3uXr) to discuss further.
|
||||
Thanks to Anthropic for the grant and the sponsorship. Join me in the [Discord](https://discord.gg/WeMU5AGxD) to discuss further.
|
||||
|
||||
@@ -4,44 +4,14 @@ layout: default
|
||||
|
||||
# Learn by Example
|
||||
|
||||
Runnable examples are the fastest way to move from reading the guides to changing
|
||||
one thing. Start with the path that matches where you are in the services →
|
||||
agents → workflows lifecycle.
|
||||
A collection of small, focused examples demonstrating common patterns with Go Micro.
|
||||
|
||||
## Start here
|
||||
|
||||
| Goal | Runnable example | Why it is useful |
|
||||
| --- | --- | --- |
|
||||
| 0→1 service | [`examples/hello-world`](https://github.com/micro/go-micro/tree/master/examples/hello-world) | Smallest RPC service with a client call and health checks. |
|
||||
| First service-backed agent | [`examples/agent-demo`](https://github.com/micro/go-micro/tree/master/examples/agent-demo) | Multi-service project/task/team app with agent playground integration. |
|
||||
| 0→hero lifecycle | [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) | No-secret support-desk story: typed services, an agent, an event-driven flow, and a guardrail. |
|
||||
| Planning and delegation | [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate) | Two agents collaborate through `plan` and `delegate` over normal Go Micro RPC. |
|
||||
| Durable workflows | [`examples/flow-durable`](https://github.com/micro/go-micro/tree/master/examples/flow-durable) | Ordered, checkpointed flow steps resume without duplicating completed side effects. |
|
||||
| AI-callable services | [`examples/mcp`](https://github.com/micro/go-micro/tree/master/examples/mcp) | MCP examples that expose service endpoints as model tools. |
|
||||
|
||||
## Guide-to-example map
|
||||
|
||||
- [Getting Started](../getting-started.html) → run
|
||||
[`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
to see the full lifecycle before generating your own service.
|
||||
- [Your First Agent](../guides/your-first-agent.html) → run
|
||||
[`examples/agent-demo`](https://github.com/micro/go-micro/tree/master/examples/agent-demo)
|
||||
or [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
when you want a complete service-backed agent to inspect.
|
||||
- [0→hero Reference](../guides/zero-to-hero.html) → run
|
||||
[`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
for the human-readable scenario, then `make harness` for the full CI contract.
|
||||
- [Plan & Delegate](../guides/plan-delegate.html) → run
|
||||
[`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate).
|
||||
- [Agents and Workflows](../guides/agents-and-workflows.html) → run
|
||||
[`examples/flow-durable`](https://github.com/micro/go-micro/tree/master/examples/flow-durable)
|
||||
and [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support).
|
||||
|
||||
## Repository examples
|
||||
|
||||
See the repository [examples index](https://github.com/micro/go-micro/tree/master/examples)
|
||||
for the complete runnable list, including deployment, auth, gRPC interop, MCP,
|
||||
agent, and flow examples.
|
||||
- [Hello Service](hello-service.md)
|
||||
- [RPC Client](rpc-client.md)
|
||||
- [Pub/Sub with NATS Broker](pubsub-nats.md)
|
||||
- [Service Discovery with Consul](registry-consul.md)
|
||||
- [State with Postgres Store](store-postgres.md)
|
||||
- [NATS Transport](transport-nats.md)
|
||||
|
||||
## More
|
||||
|
||||
|
||||
@@ -19,14 +19,6 @@ Go Micro has three core abstractions:
|
||||
- **Go 1.24+** for development. The `curl` install below gives you the `micro` binary without Go, but `micro run` compiles your services, so you'll want Go installed to build them.
|
||||
- An **LLM provider key** (Anthropic, OpenAI, Gemini, …) *only* for the AI features — `micro run --prompt`, `micro chat`, and agents. Plain services need no key. Set it before running, e.g. `export ANTHROPIC_API_KEY=sk-ant-...`.
|
||||
|
||||
Before your first provider-backed agent run, check the local path with:
|
||||
|
||||
```bash
|
||||
micro agent preflight
|
||||
```
|
||||
|
||||
The preflight is read-only: it verifies Go, the `micro` binary, provider-key setup, and whether the default `micro run` gateway port is free, without calling an LLM provider.
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
@@ -34,21 +26,13 @@ The preflight is read-only: it verifies Go, the `micro` binary, provider-key set
|
||||
curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
|
||||
# Or with Go
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
go install go-micro.dev/v6/cmd/micro@v6
|
||||
```
|
||||
|
||||
> Use `@v6` (not `@latest`). It selects the newest `v6.x.x` release. Plain `@latest` can currently resolve to a stale pre-rename tag through the public module proxy and fail with a "version constraints conflict"; `@v6` avoids that. To pin an exact version use e.g. `@v6.2.0` (see [releases](https://github.com/micro/go-micro/releases)).
|
||||
|
||||
## Quick Start: Generate from a Prompt
|
||||
|
||||
Prefer to start from a runnable reference? Clone the repository and run the maintained support-desk lifecycle example first:
|
||||
|
||||
```bash
|
||||
git clone https://github.com/micro/go-micro.git
|
||||
cd go-micro
|
||||
go run ./examples/support
|
||||
```
|
||||
|
||||
That example is the no-secret 0→hero path: services expose ticket/customer/notification tools, an agent handles the work, and an event-driven flow triggers the agent. See [Learn by Example](examples/) when you want more runnable starting points.
|
||||
|
||||
Describe what you need. The AI designs services, writes handlers, compiles, and starts them:
|
||||
|
||||
```bash
|
||||
@@ -86,10 +70,6 @@ Created project Launch and added task 'Write docs' to it.
|
||||
|
||||
The console discovers services from the registry and orchestrates across them via the agent. Use `micro run -d` for detached mode without the console, or `micro chat` as a standalone command.
|
||||
|
||||
If the agent surprises you while iterating, use the [Debugging your agent](guides/debugging-agents.html) guide to inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs.
|
||||
|
||||
When you are ready to prove the whole path end to end, follow the [0→hero reference path](guides/zero-to-hero.html). It is the canonical handoff from this quick start: scaffold a service, run it locally, chat with an agent, inspect durable agent/flow history, and finish with `micro deploy --dry-run` using the same commands exercised by `make harness`.
|
||||
|
||||
## Quick Start: Write a Service
|
||||
|
||||
Create and run a service manually:
|
||||
@@ -156,8 +136,6 @@ micro new gateway --template api
|
||||
|
||||
## Building Agents
|
||||
|
||||
For a complete service-backed walkthrough, start with [Your First Agent](guides/your-first-agent.html). If you want to run before you write, use [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) for the full services → agents → workflows lifecycle or [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate) for the smallest multi-agent planning/delegation path.
|
||||
|
||||
An Agent is an intelligent layer that manages one or more services:
|
||||
|
||||
```go
|
||||
@@ -241,8 +219,6 @@ The flow discovers all services as tools and lets the LLM decide which RPCs to c
|
||||
|
||||
## Next Steps
|
||||
|
||||
- [Learn by Example](examples/) — runnable examples mapped to services, agents, and workflows
|
||||
- [0→hero Reference](guides/zero-to-hero.html) — the maintained no-secret lifecycle contract
|
||||
- [AI Integration](ai-integration.html) — how services, agents, MCP, and LLMs fit together
|
||||
- [Agent Design](https://github.com/micro/go-micro/blob/master/internal/docs/AGENT_DESIGN.md) — the full agent interface specification
|
||||
- [MCP & AI Agents](mcp.html) — MCP gateway, tool discovery, and auth
|
||||
|
||||
@@ -17,7 +17,7 @@ A **task management service** with full CRUD operations that:
|
||||
## Prerequisites
|
||||
|
||||
```bash
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
go install go-micro.dev/v6/cmd/micro@v6
|
||||
```
|
||||
|
||||
## Step 1: Create the Service
|
||||
|
||||
@@ -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 |
|
||||
|
||||
|
||||
@@ -423,5 +423,5 @@ micro server --address :9000
|
||||
## Need Help?
|
||||
|
||||
- **Issues**: [github.com/micro/go-micro/issues](https://github.com/micro/go-micro/issues)
|
||||
- **Discord**: [discord.gg/G8Gk5j3uXr](https://discord.gg/G8Gk5j3uXr)
|
||||
- **Discord**: [discord.gg/WeMU5AGxD](https://discord.gg/WeMU5AGxD)
|
||||
- **Docs**: [go-micro.dev/docs](https://go-micro.dev/docs)
|
||||
|
||||
@@ -1,235 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
title: Debugging your agent
|
||||
---
|
||||
|
||||
# Debugging your agent
|
||||
|
||||
Use this guide when an agent surprises you: it answered without using a service,
|
||||
called the wrong endpoint, looped, lost memory, refused a tool, or behaved
|
||||
differently when a flow handed work to it. The local inner loop is:
|
||||
|
||||
```sh
|
||||
micro run # start services, agents, gateway, dashboard
|
||||
micro chat # reproduce one turn
|
||||
micro inspect ... # read the recorded run or workflow history
|
||||
```
|
||||
|
||||
Debug the lifecycle in the same order Go Micro runs it: first prove the service is
|
||||
registered and callable, then inspect the agent run that chose tools, then inspect
|
||||
any workflow that handed off to the agent.
|
||||
|
||||
## 1. Reproduce one small turn
|
||||
|
||||
Start from the application directory and keep the prompt narrow enough that you
|
||||
can tell which tool should have run:
|
||||
|
||||
```sh
|
||||
micro run
|
||||
micro chat --prompt "Create a ticket for Pat, then list open tickets."
|
||||
```
|
||||
|
||||
For a live provider, make the provider choice explicit so a later retry uses the
|
||||
same model boundary:
|
||||
|
||||
```sh
|
||||
MICRO_AI_PROVIDER=anthropic \
|
||||
ANTHROPIC_API_KEY="$ANTHROPIC_API_KEY" \
|
||||
micro chat --prompt "Create a ticket for Pat, then list open tickets."
|
||||
```
|
||||
|
||||
If the provider supports streaming, turn it on while you reproduce the issue:
|
||||
|
||||
```sh
|
||||
micro chat --provider anthropic --stream
|
||||
```
|
||||
|
||||
Streaming shows the final answer as it arrives. Tool execution still goes through
|
||||
the same agent run and is visible through inspection after the turn completes.
|
||||
|
||||
## 2. Prove the service side before blaming the model
|
||||
|
||||
Agents only call tools that the runtime can discover and describe. Check the
|
||||
service boundary first:
|
||||
|
||||
```sh
|
||||
micro services
|
||||
micro call ticket TicketService.List '{}'
|
||||
```
|
||||
|
||||
If the service is missing, restart the service under `micro run` and verify it is
|
||||
using the same registry as the agent. If the direct `micro call` fails, fix the
|
||||
handler, request shape, or auth error there before debugging prompts.
|
||||
|
||||
When the agent calls the wrong tool or sends the wrong fields, improve the tool
|
||||
description at the service source:
|
||||
|
||||
```go
|
||||
// Create opens a customer support ticket and returns its stable ticket ID.
|
||||
// @example {"customer":"Pat","subject":"Cannot log in"}
|
||||
func (s *TicketService) Create(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
|
||||
```
|
||||
|
||||
Endpoint comments, request field names, `description` tags, and `@example` blocks
|
||||
are the model's map of your service. A vague handler comment often looks like a
|
||||
reasoning failure from the outside.
|
||||
|
||||
## 3. Inspect agent run history
|
||||
|
||||
After a chat turn, list recent runs for that agent:
|
||||
|
||||
```sh
|
||||
micro inspect agent support
|
||||
```
|
||||
|
||||
The output shows the run id, status, number of recorded events, the last event,
|
||||
errors, and a short trace id when tracing is configured. Narrow the list while you
|
||||
iterate:
|
||||
|
||||
```sh
|
||||
micro inspect agent support --limit 5
|
||||
micro inspect agent support --status timeout
|
||||
micro inspect agent support --trace abc123
|
||||
micro inspect agent support --json
|
||||
```
|
||||
|
||||
Useful statuses include `done`, `refused`, `timeout`, `rate_limited`, `canceled`,
|
||||
and `error`. Use `--json` when you want exact timestamps, trace/span ids, and error
|
||||
kinds for a bug report.
|
||||
|
||||
Run timelines are stored in the agent's state store under that agent's scoped
|
||||
state (`agent/<name>/runs/...`). The persisted timeline is recorded even without
|
||||
an OpenTelemetry exporter, so `micro inspect agent` remains useful in local
|
||||
no-secret development.
|
||||
|
||||
## 4. See tool calls as they happen
|
||||
|
||||
When you are embedding an agent in Go and need live tool visibility, use the
|
||||
streaming API instead of waiting for the final answer:
|
||||
|
||||
```go
|
||||
stream, err := agent.StreamAsk(ctx, ag, "Create a ticket for Pat")
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
for {
|
||||
ev, err := stream.Recv()
|
||||
if err != nil {
|
||||
break
|
||||
}
|
||||
switch ev.Type {
|
||||
case agent.StreamEventToolStart:
|
||||
log.Printf("tool start: %s %#v", ev.ToolCall.Name, ev.ToolCall.Input)
|
||||
case agent.StreamEventToolEnd:
|
||||
log.Printf("tool end: %s %#v", ev.ToolCall.Name, ev.Result)
|
||||
case agent.StreamEventToken:
|
||||
fmt.Print(ev.Token)
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
For custom audit logging, wrap the tool execution boundary. Wrappers observe every
|
||||
call and result, including guardrail refusals:
|
||||
|
||||
```go
|
||||
wrapped := micro.AgentWrapTool(func(next ai.ToolHandler) ai.ToolHandler {
|
||||
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
|
||||
if run, ok := ai.RunInfoFrom(ctx); ok {
|
||||
log.Printf("run=%s agent=%s tool=%s", run.RunID, run.Agent, call.Name)
|
||||
}
|
||||
res := next(ctx, call)
|
||||
if res.Refused != "" {
|
||||
log.Printf("tool refused: %s reason=%s", call.Name, res.Refused)
|
||||
}
|
||||
return res
|
||||
}
|
||||
})
|
||||
|
||||
ag := micro.NewAgent("support", wrapped)
|
||||
```
|
||||
|
||||
Use this when you need request/response payloads in your own logs. By default,
|
||||
Go Micro records safe run metadata; raw prompt input is not persisted unless the
|
||||
agent is configured with `agent.TraceInputs(true)`.
|
||||
|
||||
## 5. Inspect memory and plans
|
||||
|
||||
Default agent memory is store-backed and scoped to the agent name. A restarted
|
||||
agent with the same `micro.WithStore(...)` and name reloads conversation history
|
||||
from the `history` key in `agent/<name>` state. If you pass `micro.WithMemory(...)`,
|
||||
you own that backend; if you pass `agent.NewInMemory(...)`, memory disappears on
|
||||
restart.
|
||||
|
||||
The built-in `plan` tool also saves the current plan to the same scoped agent
|
||||
state, so a later turn can pick up the saved plan. When memory does not persist,
|
||||
check that all of these are stable across restarts:
|
||||
|
||||
- the agent name (`micro.NewAgent("support", ...)`),
|
||||
- the configured store backend (`micro.WithStore(...)` or the process default),
|
||||
- whether a custom in-memory `Memory` implementation replaced the default,
|
||||
- whether compaction/retrieval limits are intentionally hiding older turns from
|
||||
the active model context.
|
||||
|
||||
## 6. Inspect workflow handoffs
|
||||
|
||||
If a flow triggered the agent, inspect the flow too. The flow history tells you
|
||||
which durable stage dispatched to the agent and whether a run is still pending:
|
||||
|
||||
```sh
|
||||
micro inspect flow intake
|
||||
micro inspect flow intake --pending
|
||||
micro inspect flow intake --stage notify
|
||||
micro inspect flow intake --json
|
||||
```
|
||||
|
||||
The older flow-specific command remains available for listing runs:
|
||||
|
||||
```sh
|
||||
micro flow runs intake
|
||||
```
|
||||
|
||||
Use the flow run id and the agent run id together when debugging handoffs: the
|
||||
flow explains why work started and where it checkpointed; the agent run explains
|
||||
which model/tool steps happened after the handoff.
|
||||
|
||||
## 7. Add traces when metadata is not enough
|
||||
|
||||
For local CLI debugging, `micro inspect` is the fastest path. For production or
|
||||
multi-service debugging, configure an OpenTelemetry tracer provider on the agent:
|
||||
|
||||
```go
|
||||
ag := micro.NewAgent("support",
|
||||
micro.AgentTraceProvider(tp),
|
||||
)
|
||||
```
|
||||
|
||||
Trace ids flow into the recorded run summaries, so you can pivot between
|
||||
`micro inspect agent support --trace <prefix>` and your trace backend. Keep
|
||||
`agent.TraceInputs(true)` off unless your observability backend is approved to
|
||||
store prompt content.
|
||||
|
||||
## Troubleshooting table
|
||||
|
||||
| Symptom | What to inspect | Common fix |
|
||||
| --- | --- | --- |
|
||||
| Agent answers without calling a service | `micro services`, direct `micro call`, then `micro inspect agent <name>` | Register the service, include it in `micro.AgentServices(...)`, or improve endpoint comments and examples. |
|
||||
| Agent loops or burns steps | `micro inspect agent <name> --status error` and wrapper logs | Add or lower `micro.AgentMaxSteps(...)` / `micro.AgentLoopLimit(...)`; move predictable work into a flow. |
|
||||
| Tool is refused before it runs | Wrapper logs, `ToolResult.Refused`, `micro inspect agent <name> --status refused` | Update `micro.AgentApproveTool(...)` policy or prompt the user for explicit approval before retrying. |
|
||||
| Memory is missing after restart | Agent name, store backend, `WithMemory`, compaction/retrieval settings | Use the default store-backed memory with a persistent store, or persist your custom memory backend. |
|
||||
| Flow handoff appears stuck | `micro inspect flow <flow> --pending`, then `micro inspect agent <agent>` | Resume or fail the pending flow run; confirm the dispatched agent completed or timed out. |
|
||||
| Provider failed or timed out | `micro inspect agent <name> --status timeout` / `--status rate_limited` | Retry with the same provider/model, raise deadlines where appropriate, or enable provider retries for transient errors. |
|
||||
| Tool call appears as assistant text | Agent run history and provider conformance checks | Keep provider packages current; Go Micro normalizes provider-emitted text tool calls, and conformance tests guard this behavior. |
|
||||
|
||||
## What to include in a bug report
|
||||
|
||||
When you cannot explain the run locally, include:
|
||||
|
||||
```sh
|
||||
micro inspect agent <agent> --limit 5 --json
|
||||
micro inspect flow <flow> --limit 5 --json
|
||||
micro services
|
||||
micro call <service> <Handler.Method> '{}'
|
||||
```
|
||||
|
||||
Redact secrets and user data. If you enabled `agent.TraceInputs(true)`, inspect the
|
||||
JSON before sharing it because prompts may be present.
|
||||
@@ -84,7 +84,7 @@ message Response {
|
||||
|
||||
```bash
|
||||
# Install protoc-gen-micro
|
||||
go install go-micro.dev/v6/cmd/protoc-gen-micro@latest
|
||||
go install go-micro.dev/v6/cmd/protoc-gen-micro@v6
|
||||
|
||||
# Generate Go code
|
||||
protoc --proto_path=. \
|
||||
@@ -93,6 +93,8 @@ protoc --proto_path=. \
|
||||
proto/helloworld.proto
|
||||
```
|
||||
|
||||
> **Note:** Use a specific version instead of `@latest` to avoid module path conflicts. See [releases](https://github.com/micro/go-micro/releases) for the latest version.
|
||||
|
||||
### Server Implementation
|
||||
|
||||
```go
|
||||
|
||||
@@ -90,7 +90,7 @@ Update your proto generation:
|
||||
|
||||
```bash
|
||||
# Install protoc-gen-micro
|
||||
go install go-micro.dev/v6/cmd/protoc-gen-micro@latest
|
||||
go install go-micro.dev/v6/cmd/protoc-gen-micro@v6
|
||||
|
||||
# Generate both gRPC and Go Micro code
|
||||
protoc --proto_path=. \
|
||||
@@ -100,6 +100,7 @@ protoc --proto_path=. \
|
||||
proto/hello.proto
|
||||
```
|
||||
|
||||
> **Note:** Use a specific version instead of `@latest` to avoid module path conflicts. See [releases](https://github.com/micro/go-micro/releases) for the latest version.
|
||||
|
||||
This generates:
|
||||
- `hello.pb.go` - Protocol Buffers types
|
||||
|
||||
@@ -32,7 +32,7 @@ go mod tidy
|
||||
Update the CLI too:
|
||||
|
||||
```bash
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
go install go-micro.dev/v6/cmd/micro@v6
|
||||
```
|
||||
|
||||
## 2. TLS is verified by default
|
||||
|
||||
@@ -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,227 +0,0 @@
|
||||
---
|
||||
layout: default
|
||||
---
|
||||
|
||||
# Your First Agent
|
||||
|
||||
This walkthrough builds the smallest useful Go Micro agent path: one service
|
||||
with typed endpoints, one agent scoped to that service, and one CLI conversation
|
||||
that proves the agent can use the service as a tool. It is the 0→1 version of
|
||||
the services → agents → workflows lifecycle: build capability first, add
|
||||
intelligence on top, then keep a clear path toward flows when the work needs to
|
||||
run on events or schedules.
|
||||
|
||||
## Runnable reference first
|
||||
|
||||
If you want to run the lifecycle before copying code, start with the maintained support-desk example from the repository root:
|
||||
|
||||
```sh
|
||||
go run ./examples/support
|
||||
```
|
||||
|
||||
It uses a deterministic mock model by default, so it needs no provider key, and it exercises the same shape this guide teaches: services become tools, an agent uses them, and a flow can trigger the work. Use this guide when you are ready to build the smaller 0→1 version yourself.
|
||||
|
||||
## What you'll build
|
||||
|
||||
A tiny task assistant:
|
||||
|
||||
1. A `task` service exposes `Create` and `List` endpoints.
|
||||
2. An `assistant` agent is scoped to the `task` service.
|
||||
3. `micro run` starts both in the local harness.
|
||||
4. `micro chat` asks the agent to create and list tasks.
|
||||
|
||||
The same service endpoints are normal RPC methods, dashboard/API actions, MCP
|
||||
tools, and agent tools. You do not write a second integration layer for the
|
||||
agent.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
- Go 1.24 or newer.
|
||||
- The `micro` CLI installed.
|
||||
- An LLM provider key for live agent calls. For example:
|
||||
|
||||
```sh
|
||||
export ANTHROPIC_API_KEY=sk-ant-...
|
||||
```
|
||||
|
||||
Plain service calls work without a model key; the key is only needed when the
|
||||
agent reasons over tools.
|
||||
|
||||
Run the read-only first-agent preflight before starting the walkthrough. The same CLI boundary is covered by CI with `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1`, so the documented scaffold → run → chat → inspect path stays visible in the local harness:
|
||||
|
||||
```sh
|
||||
micro agent preflight
|
||||
```
|
||||
|
||||
It checks Go, the `micro` binary, provider-key setup, and the default local gateway port without contacting a provider.
|
||||
|
||||
## 1. Create a workspace
|
||||
|
||||
```sh
|
||||
mkdir first-agent
|
||||
cd first-agent
|
||||
go mod init example.com/first-agent
|
||||
go get go-micro.dev/v6@v6
|
||||
```
|
||||
|
||||
Add `main.go`:
|
||||
|
||||
```go
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"os"
|
||||
"sync"
|
||||
|
||||
micro "go-micro.dev/v6"
|
||||
)
|
||||
|
||||
type CreateRequest struct {
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type CreateResponse struct {
|
||||
ID string `json:"id"`
|
||||
Title string `json:"title"`
|
||||
}
|
||||
|
||||
type ListRequest struct{}
|
||||
|
||||
type ListResponse struct {
|
||||
Tasks []CreateResponse `json:"tasks"`
|
||||
}
|
||||
|
||||
type TaskService struct {
|
||||
mu sync.Mutex
|
||||
next int
|
||||
tasks []CreateResponse
|
||||
}
|
||||
|
||||
// Create adds a task to the list.
|
||||
// @example {"title":"Write first agent guide"}
|
||||
func (t *TaskService) Create(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
t.next++
|
||||
*rsp = CreateResponse{ID: fmt.Sprintf("task-%d", t.next), Title: req.Title}
|
||||
t.tasks = append(t.tasks, *rsp)
|
||||
return nil
|
||||
}
|
||||
|
||||
// List returns all known tasks.
|
||||
// @example {}
|
||||
func (t *TaskService) List(ctx context.Context, req *ListRequest, rsp *ListResponse) error {
|
||||
t.mu.Lock()
|
||||
defer t.mu.Unlock()
|
||||
|
||||
rsp.Tasks = append([]CreateResponse(nil), t.tasks...)
|
||||
return nil
|
||||
}
|
||||
|
||||
func main() {
|
||||
service := micro.NewService("task")
|
||||
service.Handle(new(TaskService))
|
||||
|
||||
agent := micro.NewAgent("assistant",
|
||||
micro.AgentServices("task"),
|
||||
micro.AgentPrompt("You help manage tasks. Use the task service before answering."),
|
||||
micro.AgentProvider("anthropic"),
|
||||
micro.AgentAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
|
||||
)
|
||||
|
||||
go agent.Run()
|
||||
service.Run()
|
||||
}
|
||||
```
|
||||
|
||||
> Why the comments matter: endpoint comments and `@example` tags become tool
|
||||
> descriptions, so the agent has enough context to choose `task.Create` and
|
||||
> `task.List` correctly.
|
||||
|
||||
## 2. Run the service and agent
|
||||
|
||||
From the same directory:
|
||||
|
||||
```sh
|
||||
micro run
|
||||
```
|
||||
|
||||
The local harness starts the service, gateway, dashboard, MCP tool surface, and
|
||||
agent playground. You can also verify the service directly before involving the
|
||||
agent:
|
||||
|
||||
```sh
|
||||
micro call task TaskService.Create '{"title":"Ship the walkthrough"}'
|
||||
micro call task TaskService.List '{}'
|
||||
```
|
||||
|
||||
## 3. Chat with the agent
|
||||
|
||||
In another terminal, ask the agent to use the service:
|
||||
|
||||
```sh
|
||||
micro chat assistant
|
||||
```
|
||||
|
||||
Try:
|
||||
|
||||
```text
|
||||
Create a task called "Review the first-agent walkthrough", then show me all tasks.
|
||||
```
|
||||
|
||||
A healthy run shows the agent calling the task service and then summarizing the
|
||||
result. If the model refuses to call tools, tighten the prompt so it explicitly
|
||||
uses the `task` service before answering.
|
||||
|
||||
## 4. Know what just happened
|
||||
|
||||
- The service registered typed RPC endpoints.
|
||||
- Go Micro derived tool descriptions from the endpoint names, comments, request
|
||||
fields, and examples.
|
||||
- The agent registered as another service with an `Agent.Chat` endpoint.
|
||||
- `micro chat` sent your message to the agent.
|
||||
- The agent selected the scoped `task` tools, called them over the same runtime,
|
||||
and stored conversation history in memory.
|
||||
|
||||
That is the core lifecycle: services provide capability, agents use the
|
||||
capability, and the same runtime can later put the interaction behind a flow.
|
||||
|
||||
## 5. Make it a workflow when the path is event-driven
|
||||
|
||||
Once the prompt should run because something happened rather than because a
|
||||
human typed a message, move the handoff into a flow:
|
||||
|
||||
```go
|
||||
flow := micro.NewFlow("task-triage",
|
||||
micro.FlowTrigger("tasks.created"),
|
||||
micro.FlowPrompt("Review this new task and decide the next action: {{.Data}}"),
|
||||
micro.FlowProvider("anthropic"),
|
||||
micro.FlowAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
|
||||
)
|
||||
```
|
||||
|
||||
Use flows for deterministic triggers and long-running orchestration; keep the
|
||||
agent for judgment, tool use, and handoffs when the path is not known up front.
|
||||
|
||||
## Troubleshooting
|
||||
|
||||
| Symptom | Check |
|
||||
| --- | --- |
|
||||
| The agent says it cannot access tasks. | Confirm the agent was created with `micro.AgentServices("task")` and that `micro agent list` shows `assistant`. |
|
||||
| Tool calls use the wrong fields. | Add or improve doc comments and `@example` tags on the service methods. |
|
||||
| Plain service calls work but chat fails. | Check that your provider key is exported in the shell that runs `micro run`. |
|
||||
| You need a no-secret reference path. | Run `make harness` from the Go Micro repository; it exercises the services → agents → workflows lifecycle with a mock provider. |
|
||||
|
||||
## Next steps
|
||||
|
||||
- Read the [0→hero reference path](zero-to-hero.html) for the CI-verified
|
||||
lifecycle contract.
|
||||
- Run [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) for the no-secret 0→hero support-desk lifecycle.
|
||||
- Run [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate)
|
||||
to see planning and delegation across agents.
|
||||
- Read [Debugging your agent](debugging-agents.html) when a chat turn does not call the tool you expected, loops, refuses a call, loses memory, or fails after a flow handoff.
|
||||
- Read [Agents and Workflows](agents-and-workflows.html) when you are ready to
|
||||
compose agents behind durable flows.
|
||||
@@ -18,23 +18,11 @@ cloud credentials?"
|
||||
| Boundary | Contract | CI check |
|
||||
| --- | --- | --- |
|
||||
| Scaffold | `micro new` generates a runnable service with and without MCP support. | `go test ./cmd/micro/cli/new -run TestZeroToOne -count=1` |
|
||||
| First agent | `micro new`, `micro agent preflight`, `micro run`, `micro chat`, and `micro inspect agent` stay available for the documented first-agent walkthrough. | `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1` |
|
||||
| Run | `micro run` remains the local development entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
|
||||
| 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` |
|
||||
|
||||
## Run the runnable example
|
||||
|
||||
From the repository root, start with the support-desk example when you want to see the full lifecycle in one terminal:
|
||||
|
||||
```sh
|
||||
go run ./examples/support
|
||||
```
|
||||
|
||||
It starts typed services, a support agent, an event-driven intake flow, and an approval gate with a deterministic mock model. Change one service method, agent prompt, or guardrail decision and run it again to learn the system by modifying a working path.
|
||||
| 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 whole no-secret path
|
||||
|
||||
@@ -54,22 +42,13 @@ 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
|
||||
|
||||
# First-agent walkthrough boundary: scaffold, preflight, run, chat, inspect.
|
||||
go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1
|
||||
|
||||
# CLI inner-loop commands: run, chat, inspect, flow runs, deploy --dry-run.
|
||||
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
|
||||
|
||||
@@ -82,11 +61,6 @@ make provider-conformance-mock
|
||||
|
||||
## Reference scenarios
|
||||
|
||||
- [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
|
||||
is the runnable support-desk story: customers, tickets, notify, a support
|
||||
agent, an intake flow, and an approval gate in one no-secret example.
|
||||
- [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate)
|
||||
is the smallest runnable planning/delegation example for multiple agents.
|
||||
- [`internal/harness/plan-delegate`](https://github.com/micro/go-micro/tree/master/internal/harness/plan-delegate)
|
||||
is the compact 0→hero scenario: real task and notify services, a conductor
|
||||
agent, a comms agent, plan persistence, delegation, and a workflow handoff.
|
||||
|
||||
@@ -16,15 +16,13 @@ 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.
|
||||
|
||||
Otherwise continue to read the docs for more information about the framework.
|
||||
To get started follow the getting started guide.
|
||||
Otherwise continue to read the docs for more information
|
||||
about the framework.
|
||||
|
||||
## Contents
|
||||
|
||||
- [Getting Started](getting-started.html)
|
||||
- [0→hero Reference](guides/zero-to-hero.html) - Walk scaffold → run → chat → inspect → deploy dry-run with CI-backed commands
|
||||
- [Your First Agent](guides/your-first-agent.html) - Build a service-backed agent end to end
|
||||
- [MCP & AI Agents](mcp.html) - Turn services into AI-callable tools with the Model Context Protocol
|
||||
- [CLI & Gateway Guide](guides/cli-gateway.html) - Development vs Production modes
|
||||
- [Quick Start](quickstart.html)
|
||||
@@ -46,8 +44,6 @@ Otherwise continue to read the docs for more information about the framework.
|
||||
|
||||
## AI & Agents
|
||||
|
||||
- [0→hero Reference](guides/zero-to-hero.html) - Walk scaffold → run → chat → inspect → deploy dry-run with CI-backed commands
|
||||
- [Your First Agent](guides/your-first-agent.html) - Build a service-backed agent end to end
|
||||
- [Building AI-Native Services](guides/ai-native-services.html) - End-to-end tutorial for MCP-enabled services
|
||||
- [MCP Security Guide](guides/mcp-security.html) - Auth, scopes, rate limiting, and audit logging
|
||||
- [Tool Description Best Practices](guides/tool-descriptions.html) - Writing docs that make agents effective
|
||||
|
||||
@@ -13,9 +13,11 @@ curl -fsSL https://go-micro.dev/install.sh | sh
|
||||
Or, if you have Go and prefer to build from source:
|
||||
|
||||
```bash
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
go install go-micro.dev/v6/cmd/micro@v6
|
||||
```
|
||||
|
||||
> **Note:** Use `@v6` (not `@latest`) — it resolves to the newest `v6.x.x` release. Plain `@latest` can currently resolve to a stale pre-rename tag through the public module proxy and fail with a "version constraints conflict"; `@v6` avoids that. To pin an exact version use e.g. `@v6.2.0` (see [releases](https://github.com/micro/go-micro/releases)).
|
||||
|
||||
## Create Your First Service
|
||||
|
||||
```bash
|
||||
@@ -93,7 +95,7 @@ publisher.Publish(ctx, &UserCreatedEvent{
|
||||
|
||||
## Get Help
|
||||
|
||||
- **[Discord Community](https://discord.gg/G8Gk5j3uXr)** - Chat with other users
|
||||
- **[Discord Community](https://discord.gg/WeMU5AGxD)** - Chat with other users
|
||||
- **[GitHub Issues](https://github.com/micro/go-micro/issues)** - Report bugs or request features
|
||||
- **[Documentation](https://go-micro.dev/docs/)** - Complete docs
|
||||
|
||||
|
||||
@@ -58,4 +58,4 @@ The framework is the product. It's funded by **sponsorship** from the people and
|
||||
|
||||
## Feedback
|
||||
|
||||
Open an issue or start a discussion on [GitHub](https://github.com/micro/go-micro), or join the [Discord](https://discord.gg/G8Gk5j3uXr).
|
||||
Open an issue or start a discussion on [GitHub](https://github.com/micro/go-micro), or join the [Discord](https://discord.gg/WeMU5AGxD).
|
||||
|
||||
@@ -28,9 +28,11 @@ For local development, use [`micro run`](guides/micro-run.html) instead.
|
||||
Install the CLI which includes the server command:
|
||||
|
||||
```bash
|
||||
go install go-micro.dev/v6/cmd/micro@latest
|
||||
go install go-micro.dev/v6/cmd/micro@v6
|
||||
```
|
||||
|
||||
> **Note:** Use a specific version instead of `@latest` to avoid module path conflicts. See [releases](https://github.com/micro/go-micro/releases) for the latest version.
|
||||
|
||||
## Run
|
||||
|
||||
Start the server:
|
||||
|
||||
@@ -271,7 +271,7 @@
|
||||
</div>
|
||||
<p class="subtitle" style="margin-top: 2.25rem;">Want to support Go Micro and put your logo here? Or running it in production and need a hand?</p>
|
||||
<div class="hero-buttons">
|
||||
<a href="https://discord.gg/G8Gk5j3uXr" class="btn btn-secondary">Become a sponsor</a>
|
||||
<a href="https://discord.gg/WeMU5AGxD" class="btn btn-secondary">Become a sponsor</a>
|
||||
<a href="/support" class="btn btn-primary">Commercial support</a>
|
||||
</div>
|
||||
</section>
|
||||
@@ -283,7 +283,6 @@
|
||||
<div class="hero-buttons">
|
||||
<a href="/docs/getting-started.html" class="btn btn-primary">Get Started</a>
|
||||
<a href="/docs/" class="btn btn-secondary">Read the Docs</a>
|
||||
<a href="https://discord.gg/G8Gk5j3uXr" class="btn btn-secondary">Join Discord</a>
|
||||
<a href="/blog/" class="btn btn-secondary">Blog</a>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
Reference in New Issue
Block a user