Compare commits

..

65 Commits

Author SHA1 Message Date
Codex 21db935e23 docs(priorities): refresh architect queue
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
2026-07-02 09:10:33 +00:00
Asim Aslam a24eaad1c9 docs: roll changelog for v6.3.12 (#3646)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 09:16:05 +01:00
Asim Aslam 357fdf2777 docs: surface first agent on-ramp (#3644)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 08:52:09 +01:00
Asim Aslam a66fb4ff2c docs(priorities): refresh architect adoption queue (#3641)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 08:16:31 +01:00
Asim Aslam 8e3ba68d58 loop-release: fix 403 on tag push (checkout token clobbered the PAT) (#3638)
goreleaser / goreleaser (push) Waiting to run
* docs: complete Ollama provider surface (capability matrix, README, example fixes)

Follow-up cleanup after merging the Ollama provider (#3636):

- Add the `ollama` row to the AI provider capability matrix in the provider
  guide, and blank-import `ai/ollama` in provider_capabilities_test.go so the
  matrix stays enforced against the registry (the provider registers a stream
  but wasn't imported in that test, so its row went unchecked).
- README: bump "7 LLM providers" → 8 and list Ollama (local + cloud); add its
  default model (`llama3.2`) to the model table.
- Fix a fictional model name shipped in the example and package doc:
  `gemma4:31b-cloud` → `gpt-oss:120b`. gemma4 doesn't exist, and the `-cloud`
  suffix is for cloud models proxied through a local Ollama, not the direct
  ollama.com/v1 endpoint the example uses.
- Record the provider and the new agent.BaseURL/micro.AgentBaseURL option in
  the CHANGELOG [Unreleased] section.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

* loop-release: don't let checkout's persisted GITHUB_TOKEN clobber the PAT push

The daily release job computed the next tag correctly but the tag push 403'd:
"Permission to micro/go-micro.git denied to github-actions[bot]" (run
28554612450). Cause: actions/checkout persists the default GITHUB_TOKEN as an
http.extraheader Authorization credential for github.com, which git sends on
ALL requests to that host — including our manual
`git push https://x-access-token:${PAT}@github.com/...`. The persisted header
overrides the URL-embedded PAT, so the push authenticates as
github-actions[bot], which can't push tags (the job only grants
contents: read).

Set persist-credentials: false so no extraheader is written and the PAT in the
push URL is the only credential used.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 07:51:31 +01:00
Asim Aslam 6e04f1afb5 docs: complete Ollama provider surface (capability matrix, README, example fixes) (#3637)
Follow-up cleanup after merging the Ollama provider (#3636):

- Add the `ollama` row to the AI provider capability matrix in the provider
  guide, and blank-import `ai/ollama` in provider_capabilities_test.go so the
  matrix stays enforced against the registry (the provider registers a stream
  but wasn't imported in that test, so its row went unchecked).
- README: bump "7 LLM providers" → 8 and list Ollama (local + cloud); add its
  default model (`llama3.2`) to the model table.
- Fix a fictional model name shipped in the example and package doc:
  `gemma4:31b-cloud` → `gpt-oss:120b`. gemma4 doesn't exist, and the `-cloud`
  suffix is for cloud models proxied through a local Ollama, not the direct
  ollama.com/v1 endpoint the example uses.
- Record the provider and the new agent.BaseURL/micro.AgentBaseURL option in
  the CHANGELOG [Unreleased] section.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-02 07:44:58 +01:00
YongSoo Park 110cb44d41 feat: add Ollama provider with local and cloud support (#3636)
Add a dedicated Ollama AI provider (ai/ollama/) that auto-detects
local vs cloud mode based on the base URL:

- Local Ollama: native /api/chat endpoint with NDJSON streaming
- Ollama Cloud: OpenAI-compatible /v1/chat/completions with SSE streaming

Both modes support tool calls with a multi-round execution loop.

Add agent.BaseURL option so agents can point at non-default LLM
endpoints (e.g. local Ollama, proxies). Wire it through micro.AgentBaseURL
at the top level.

Include a complete example (examples/agent-ollama/) demonstrating a
knowledge-base service with auto-discovered tools, a custom time tool,
streaming, and env-var configuration for local vs cloud.

Closes #3632
2026-07-02 07:37:36 +01:00
Asim Aslam f06e7467ce ci: smoke test installer first-run CLI (#3635)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 07:00:45 +01:00
Asim Aslam 412491568f docs(priorities): refresh architect adoption queue (#3631)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 06:28:17 +01:00
Asim Aslam d2e1520a14 Verify zero-to-hero reference app in harness (#3628)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 04:56:41 +01:00
Asim Aslam 0a18ac6c15 docs(priorities): refresh architect queue (#3624)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 04:15:41 +01:00
Asim Aslam 45a23a3417 test first-agent walkthrough boundaries (#3621)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 03:31:30 +01:00
Asim Aslam ae239b0102 docs(priorities): refresh architect queue (#3619)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 02:53:21 +01:00
Asim Aslam 3a2d21f1ac agent: dedupe plan delegate tool side effects (#3616)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 02:35:18 +01:00
Asim Aslam 40a559e8db Harden universe notify finalization (#3612)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 01:08:12 +01:00
Asim Aslam 28320fc1d4 cli: add first-agent preflight diagnostics (#3608)
Co-authored-by: Codex <codex@openai.com>
2026-07-02 00:02:53 +01:00
Asim Aslam cbc8fc62c7 docs(priorities): refresh architect adoption queue (#3605)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 23:39:48 +01:00
Asim Aslam 1f93fa8b1b docs: expose zero-to-hero on-ramp (#3602)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 23:09:17 +01:00
Asim Aslam c6f9940ab2 docs(priorities): refresh architect adoption queue (#3599)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 22:42:02 +01:00
Asim Aslam 9c66455d2a docs: add agent debugging guide (#3596)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 22:13:48 +01:00
Asim Aslam 2215065a2e docs(priorities): refresh architect queue (#3593)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 21:45:21 +01:00
Asim Aslam f8b8b90a3b docs: lead guides nav with hands-on path (#3591)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 21:21:39 +01:00
Asim Aslam 1bcf0e1ae9 docs(priorities): drop completed examples task (#3587)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 20:41:31 +01:00
Asim Aslam b49f5072b1 loop: have DevRel maintain CHANGELOG.md and draft a changelog blog post (#3584)
The DevRel pass now keeps the changelog living instead of letting it drift:
each daily run reconciles a Keep-a-Changelog `[Unreleased]` section against the
PRs that actually merged (user-facing entries only; internal loop/CI churn
skipped) and rolls it into a dated version heading whenever loop-release cuts a
new v6.MINOR.PATCH tag. When enough user-facing work has accumulated (roughly a
week's worth, not a near-empty post every day) it also drafts a "what's new"
changelog blog post narrating what shipped.

Autonomy boundary preserved: CHANGELOG.md upkeep is a safe factual change and
rides the auto-merged DevRel PR; the changelog blog post is opened as its own
PR but left for the human to review/merge, since blog voice stays with the human.

Also fix the CHANGELOG preamble: it claimed calendar versions (YYYY.MM) while
tags are semver (v6.MINOR.PATCH). Correct it, add an `[Unreleased]` section
seeded from real recent work, and note the historical 2026.0x headings.


Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 20:29:53 +01:00
Asim Aslam 63ebe6ab9c docs: surface runnable lifecycle examples (#3585)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 20:25:33 +01:00
Asim Aslam f192c4947c docs(priorities): drop completed install task (#3581)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 19:46:45 +01:00
Asim Aslam 7fd749b475 docs: lead v6 installs with latest (#3579)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 19:24:10 +01:00
Asim Aslam dfe9e436e8 docs(priorities): remove completed first-agent task (#3576)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 18:42:38 +01:00
Asim Aslam 72902ee6fb docs: add your first agent walkthrough (#3574)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 18:27:36 +01:00
Asim Aslam 819403e940 docs(priorities): prioritize adoption on-ramp (#3571)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 17:58:27 +01:00
Asim Aslam 58284c6230 harness: extend live provider deadlines (#3569)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 17:33:29 +01:00
Asim Aslam 66ce68a93f community: add Discord to nav/footer/README/landing; fix stale invite code (#3567)
There was no working, prominent Discord link. Update the stale invite code
(WeMU5AGxD → G8Gk5j3uXr) everywhere it appeared (README, docs, blog, landing,
SECURITY, issue templates, contrib), and add the link prominently: the site
nav and footer includes (so it shows on every landing/docs/blog page), a
Discord badge + a Community line in the README, and a "Join Discord" button on
the landing.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 17:01:29 +01:00
Asim Aslam 4bd181fc21 loop: point the architect at adoption + add a daily release action (#3566)
The framework's depth is strong but the on-ramp is the adoption gap, and the
architect queue had filled entirely with internal hardening. Steer the
architect to weight the developer on-ramp/DX (first-agent tutorial,
discoverable examples, docs wayfinding, install friction, debugging) at least
as highly as internal work — a developer succeeding on their first agent
matters more than another conformance/observability increment. Adoption issues
filed: #3561-#3565.

Also add loop-release.yml: a daily patch release that tags v6.MINOR.PATCH+1
when master has new commits (pushed with the PAT so goreleaser fires), so the
installable framework tracks the loop's daily improvements instead of lapsing.
Minor/major bumps stay with the human.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 16:38:28 +01:00
Asim Aslam 3143767d78 docs(priorities): refresh architect queue (#3557)
goreleaser / goreleaser (push) Waiting to run
Co-authored-by: Codex <codex@openai.com>
2026-07-01 16:05:37 +01:00
Asim Aslam 666fc4b794 ci+docs: name the loop workflows by role (planner/generator/evaluator) (#3556)
Rename the autonomous-loop workflows so the Actions list maps to the
long-running-agent harness pattern (planner → generator → evaluator):
  architecture-review.yml   -> loop-architect.yml   "Loop: Architect (Planner)"
  continuous-improvement.yml -> loop-builder.yml     "Loop: Builder (Generator)"
  devrel-review.yml          -> loop-devrel.yml      "Loop: DevRel"
  harness-triage.yml         -> loop-triage.yml      "Loop: Triage (Evaluator feedback)"
harness.yml stays the shared Evaluator/CI gate (triage still matches it by the
"Harness (E2E)" name). Document the pipeline + role mapping in
CONTINUOUS_IMPROVEMENT.md, and point to it from CONTRIBUTING so the development
process is discoverable. No behavior change — schedules, gates, and required
checks are unaffected.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 15:59:46 +01:00
Asim Aslam 7af73b06d7 ci: auto-triage harness failures into fix issues (hill-climbing loop) (#3549)
Close the loop's feedback path: when the live provider-conformance harness
fails, harness-triage.yml dispatches Codex to triage the failing run — read
logs, root-cause, dedupe against open issues, and file scoped codex/enhancement
issues that the hourly increment loop then fixes and the next harness run
verifies. Transient flakes are ignored; breaking/architectural fixes are
escalated as needs-human rather than auto-built. No human in the middle short
of a genuine judgment call. Documented in CONTINUOUS_IMPROVEMENT.md.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 15:51:55 +01:00
Asim Aslam 0811234f3e agent: execute text tool call fallbacks (#3554)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 15:27:14 +01:00
Asim Aslam b64a7d248d docs(priorities): refresh architect queue (#3551)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 14:55:43 +01:00
Asim Aslam e7b08d914f docs(priorities): refresh architect queue (#3545)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 14:03:04 +01:00
Asim Aslam 3a9455f28c harness: label provider conformance phases (#3542)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 12:46:11 +01:00
Asim Aslam 05e53ec38c ci: run the live provider-conformance harness hourly, not daily (#3540)
Match the real-model conformance cadence to the dev/loop velocity so live
regressions and provider drift surface within the hour instead of up to 24h.
The mock harness already runs on every push/PR; this only changes the live
(credentialed) schedule.

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 12:35:36 +01:00
Asim Aslam dbdd0e1d6f atlascloud: run provider conformance on a stronger (env-selectable) model (#3538)
* atlascloud: env-selectable chat model; run conformance on a stronger model

The daily provider-conformance harness fails 4/5 harnesses on Atlas Cloud —
its default chat model answers agent/tool-use conformance prompts
conversationally instead of performing the task. Atlas is currently the only
provider with a key configured, so the whole live run is red.

Make the Atlas Cloud provider honor an ATLASCLOUD_MODEL env override (falling
back to the existing default), and set it in the harness workflow to a
stronger tool-use model (Qwen3, overridable via an Actions variable). No
change to the default for normal use.

* atlascloud: use minimaxai/minimax-m3 for conformance model

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-07-01 12:17:12 +01:00
Asim Aslam 8146e29f7e docs(priorities): refresh architect queue (#3539)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 12:10:17 +01:00
Asim Aslam c110774dc7 Add opt-in retries for agent tool calls (#3535)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 10:57:33 +01:00
Asim Aslam c0a5775fb5 docs(priorities): refresh architect queue (#3533)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 10:12:45 +01:00
Asim Aslam f844a23bb2 docs: align public AI harness facts (#3531)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 09:40:28 +01:00
Asim Aslam 04e8759d41 test agent checkpoint resume after restart (#3529)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 09:06:46 +01:00
Asim Aslam 78725135aa docs(priorities): refresh architect queue (#3527)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 08:33:01 +01:00
Asim Aslam 2ff64ff0b2 Document canonical 0-to-hero reference path (#3522)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 07:12:17 +01:00
Asim Aslam 7a8e7cd9ae docs(priorities): advance queue to 0-to-hero reference (#3520)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 06:37:15 +01:00
Asim Aslam b58eed1698 Add retrieval-backed agent memory (#3518)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 04:59:16 +01:00
Asim Aslam c92c9cc244 docs(priorities): refresh architect queue (#3516)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 04:06:59 +01:00
Asim Aslam a2bf43e9ef test: broaden stream provider conformance (#3512)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 03:37:26 +01:00
Asim Aslam 49bac7e4a8 trace scheduled flow dispatch metadata (#3510)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 02:42:31 +01:00
Asim Aslam 5aac7e3ca0 docs(priorities): refresh architect queue after scheduling (#3507)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 01:51:21 +01:00
Asim Aslam d86585bf5c Add scheduled flow agent harness (#3505)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 01:02:29 +01:00
Asim Aslam 88e2b58711 docs(priorities): refresh architect queue (#3503)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 00:38:51 +01:00
Asim Aslam d259383645 Harden agent terminal failure statuses (#3499)
Co-authored-by: Codex <codex@openai.com>
2026-07-01 00:03:21 +01:00
Asim Aslam 2d7ee300a4 docs(priorities): refresh architect queue after conformance (#3497)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 23:40:19 +01:00
Asim Aslam 57fa4e3b7a Add mock provider conformance target (#3495)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 23:09:19 +01:00
Asim Aslam 0f1917f26b docs(priorities): refresh architect queue after verification (#3493)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 22:40:05 +01:00
Asim Aslam 6e9c5e87e9 Add flow step verification loop (#3489)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 22:18:01 +01:00
Asim Aslam d8bb892425 docs(priorities): refresh architect queue after a2a continuity (#3487)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 21:37:56 +01:00
Asim Aslam 064a112c6b a2a: expose resubscribe and input-required support (#3484)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 21:23:43 +01:00
Asim Aslam 24d103c658 docs(priorities): refresh architect queue after memory (#3482)
Co-authored-by: Codex <codex@openai.com>
2026-06-30 20:47:39 +01:00
102 changed files with 4523 additions and 259 deletions
+1 -1
View File
@@ -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/WeMU5AGxD)
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
+1 -1
View File
@@ -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/WeMU5AGxD)
- [Discord Community](https://discord.gg/G8Gk5j3uXr)
-47
View File
@@ -1,47 +0,0 @@
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."
+6 -1
View File
@@ -12,7 +12,7 @@ on:
pull_request:
branches: ["**"]
schedule:
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
- cron: "17 * * * *" # hourly, so real-model conformance keeps pace with the dev/loop velocity
workflow_dispatch:
inputs:
providers:
@@ -67,6 +67,11 @@ jobs:
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
# Atlas Cloud's default chat model was failing the agent/tool-use
# conformance harnesses; run it against a stronger tool-use model.
# Override with an Actions variable ATLASCLOUD_MODEL if the exact
# catalog id differs (Atlas uses org/model ids).
ATLASCLOUD_MODEL: ${{ vars.ATLASCLOUD_MODEL || 'minimaxai/minimax-m3' }}
run: |
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,mistral,together,atlascloud' }}"
HARNESSES="${{ github.event.inputs.harnesses || 'agent,universe,agent-flow,plan-delegate,a2a-stream-fallback' }}"
@@ -1,4 +1,4 @@
name: Architecture Review
name: "Loop: Architect (Planner)"
# Continuous high-altitude oversight of the whole framework and harness — the
# "founder lens" of the autonomous loop (internal/docs/CONTINUOUS_IMPROVEMENT.md).
@@ -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? 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? 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)."
@@ -1,4 +1,4 @@
name: Continuous Improvement
name: "Loop: Builder (Generator)"
# Durable backbone for the autonomous improvement loop
# (see internal/docs/CONTINUOUS_IMPROVEMENT.md).
+61
View File
@@ -0,0 +1,61 @@
name: "Loop: DevRel"
# Daily higher-altitude coherence pass over the PUBLIC surface — README,
# website (landing + docs), and blog — part of the autonomous loop
# (internal/docs/CONTINUOUS_IMPROVEMENT.md). The hourly increment loop ships
# code; this keeps the story coherent: docs/website aligned, README crisp, and
# a steady supply of things worth blogging about.
#
# It also keeps the CHANGELOG living: each run reconciles the `[Unreleased]`
# section of CHANGELOG.md against what actually merged (rolling it into a dated
# version heading whenever a new tag was cut), and — when enough has shipped —
# drafts a "what's new" changelog blog post narrating it.
#
# Like the increment loop it opens a fresh issue and dispatches Codex via
# CODEX_TRIGGER_TOKEN (Codex ignores Actions-bot comments). Autonomy boundary:
# SAFE factual-alignment and crispness fixes — including CHANGELOG.md upkeep —
# auto-merge; brand/positioning copy and the changelog blog post are opened as a
# PR but left for the human to review/merge (blog voice stays with the human).
on:
workflow_dispatch: {}
schedule:
- cron: "0 7 * * *" # daily, 07:00 UTC (tunable)
permissions:
issues: write
concurrency:
group: devrel-review
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open a DevRel review issue and dispatch Codex
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "DevRel coherence review #$RUN_NUMBER" \
--body "Daily DevRel / coherence pass over README, website (landing + docs), and the blog, plus CHANGELOG.md upkeep and a changelog blog post. North Star: internal/docs/THESIS.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching Codex (DevRel)."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Act as DevRel for go-micro. Do FOUR things this run.
COHERENCE AUDIT. Audit the PUBLIC surface — \`README.md\`, \`internal/website/\` (landing \`index.html\` + \`docs/\`), and the blog under \`internal/website/blog/\` — for coherence with the North Star in internal/docs/THESIS.md (an agent harness and service framework; the services → agents → workflows lifecycle). Look for: (1) places where README / website / docs contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recent merged PRs); (2) whether the README is crisp and leads with the harness positioning; (3) one to three genuinely blog-worthy items from recently shipped work.
CHANGELOG UPKEEP (this is a SAFE factual task — it goes in the auto-merged PR). Keep CHANGELOG.md living, in [Keep a Changelog](https://keepachangelog.com/) format with the newest content at the top under \`## [Unreleased]\`. (a) Enumerate PRs merged to master since the last CHANGELOG update — \`gh pr list --repo $REPO --state merged --base master --limit 60 --json number,title,mergedAt,labels\` — and compare against what CHANGELOG.md already lists. (b) For each genuinely user-facing change not yet recorded (new capability, behavior/API change, notable fix — SKIP purely internal loop/CI/priorities-refresh churn), add a concise entry under the right \`### Added\` / \`### Changed\` / \`### Fixed\` / \`### Documentation\` subheading of \`## [Unreleased]\`, phrased for a user (what it does, which package), not a commit subject. (c) If a new \`v6.MINOR.PATCH\` tag has been cut since the last run (\`git fetch --tags --force\`; compare the newest \`v6.*\` tag to the versions already in CHANGELOG.md), RENAME the current \`## [Unreleased]\` heading to \`## [MINOR.PATCH] - <Month YYYY>\` for that tag and open a fresh empty \`## [Unreleased]\` above it. Keep it accurate — do not invent entries; if nothing user-facing merged, leave [Unreleased] as-is.
CHANGELOG BLOG POST (blog voice — open a PR but do NOT auto-merge; leave it for the human). If — and only if — enough user-facing work has accumulated since the last changelog post to be worth reading (a meaningful batch, roughly a week's worth; do NOT post an almost-empty update every day), draft a short 'What's new in Go Micro' post that narrates what shipped in plain language (grouped by theme, linking the docs/examples, closing with install/upgrade). Create it as the next-numbered file in \`internal/website/blog/\` (find the highest N, use N+1), mirroring the frontmatter (layout/title/permalink/description) and the post-nav 'previous post' link of the latest existing post, and add an entry at the TOP of \`internal/website/blog/index.html\`. Base it strictly on the CHANGELOG — no speculation.
THEN do all of these: (A) post a concise findings report as a comment on this issue (#$ISSUE_NUM) — what is aligned, what drifted, what you fixed, the CHANGELOG entries you added, whether you drafted a changelog post (and why / why not), and any other blog ideas. (B) Open ONE auto-merging PR for the SAFE factual work only — coherence/crispness fixes AND the CHANGELOG.md update (NOT brand/marketing/positioning rewrites, NOT the blog post): \`git switch -c codex/devrel-$ISSUE_NUM\`, \`git push -u origin codex/devrel-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, including 'Closes #$ISSUE_NUM'>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. (C) If you drafted a changelog blog post, open it as a SEPARATE PR on its own branch (\`codex/devrel-blog-$ISSUE_NUM\`) with a title prefixed 'blog:' and do NOT enable auto-merge — leave it open for the human to review and merge. Do the same (separate, non-auto-merged PR or just a report note) for any brand/positioning copy. Do not use the make_pr tool (it is a no-op stub). If you touch code, verify go build/test/golangci-lint. Stay out of breaking public-API changes."
+84
View File
@@ -0,0 +1,84 @@
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."
+59
View File
@@ -0,0 +1,59 @@
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\`)."
+1
View File
@@ -62,6 +62,7 @@ examples/mcp/hello/hello
/plan-delegate
/agent-plan-delegate
/micro-mcp-gateway
/agent-ollama
# Local Jekyll / Bundler artifacts
internal/website/.bundle/
+32 -2
View File
@@ -2,8 +2,38 @@
All notable changes to Go Micro are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/). Go Micro uses
calendar-based versions (YYYY.MM) for the AI-native era.
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]
---
## [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`)
---
+11
View File
@@ -6,6 +6,17 @@ 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
+15 -1
View File
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
.PHONY: test test-race test-coverage harness provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
.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
# Default target
help:
@@ -19,6 +19,8 @@ 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"
@echo " make install-tools - Install development tools"
@@ -47,9 +49,21 @@ 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.
provider-conformance-mock:
go run ./internal/harness/provider-conformance -providers mock
# Run the same harnesses against every configured live provider. Providers
+35 -8
View File
@@ -1,7 +1,9 @@
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/go-micro/go-micro)](https://goreportcard.com/report/github.com/go-micro/go-micro)
# Go Micro [![Go.Dev reference](https://img.shields.io/badge/go.dev-reference-007d9c?logo=go&logoColor=white&style=flat-square)](https://pkg.go.dev/go-micro.dev/v6?tab=doc) [![Go Report Card](https://goreportcard.com/badge/github.com/go-micro/go-micro)](https://goreportcard.com/report/github.com/go-micro/go-micro) [![Discord](https://img.shields.io/badge/Discord-join-5865F2?logo=discord&logoColor=white&style=flat-square)](https://discord.gg/G8Gk5j3uXr)
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.
@@ -14,7 +16,7 @@ Go Micro gives you the harness as Go code. Build an agent and it gets a model, m
&nbsp;&nbsp;
<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/WeMU5AGxD) — reach out on Discord.
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/G8Gk5j3uXr) — reach out on Discord.
## Commercial Support
@@ -23,6 +25,7 @@ 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)
@@ -44,7 +47,7 @@ Install the CLI:
curl -fsSL https://go-micro.dev/install.sh | sh
# Or with Go
go install go-micro.dev/v6/cmd/micro@v6
go install go-micro.dev/v6/cmd/micro@latest
```
### Fastest start — no API key
@@ -64,14 +67,35 @@ curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
-H 'Content-Type: application/json' -d '{"name":"World"}'
```
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,
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),
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:
@@ -306,7 +330,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, …) |
| 7 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud |
| 8 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, Ollama (local + cloud) |
| Interactive console | `micro run` includes a chat console for talking to services |
| Service generation | `micro run --prompt` — describe a system, get running services |
@@ -401,8 +425,9 @@ Swap providers with a single import — same interface everywhere:
| Google Gemini | `gemini-2.5-flash` |
| Groq | `llama-3.3-70b-versatile` |
| Mistral | `mistral-large-latest` |
| Together AI | `Llama-3.3-70B-Instruct-Turbo` |
| Atlas Cloud | `llama-3.3-70b` |
| 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))
@@ -423,6 +448,8 @@ 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)
- [Plan & Delegate](internal/website/docs/guides/plan-delegate.md)
+1 -1
View File
@@ -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/WeMU5AGxD). Include tests, run `make test` and
[Discord](https://discord.gg/G8Gk5j3uXr). Include tests, run `make test` and
`make lint`.
## Version support
+1 -1
View File
@@ -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/WeMU5AGxD
- Join Discord: https://discord.gg/G8Gk5j3uXr
- Email: support@go-micro.dev
+25 -3
View File
@@ -34,6 +34,7 @@ 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"
)
@@ -149,6 +150,9 @@ 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
@@ -179,6 +183,8 @@ func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
a.mem = NewInMemory(a.opts.HistoryLimit)
case a.opts.MemoryCompaction.MaxMessages > 0:
a.mem = NewCompactingMemoryWithOptions(a.stateStore(), "history", a.opts.MemoryCompaction)
case a.opts.MemoryRetrievalLimit > 0:
a.mem = NewRetrievalMemory(a.stateStore(), "history", a.opts.MemoryRetrievalLimit)
default:
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
}
@@ -298,12 +304,15 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
Backoff: a.opts.ModelRetryBackoff,
})
if err != nil {
run.Status = "failed"
run.Steps[0].Status = "failed"
run.Steps[0].Error = err.Error()
run.Status = agentRunFailureStatus(err)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = run.Status
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
return nil, err
}
@@ -324,6 +333,19 @@ 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)
}
+131 -1
View File
@@ -5,6 +5,7 @@ import (
"encoding/json"
"fmt"
"strings"
"time"
"go-micro.dev/v6/ai"
codecBytes "go-micro.dev/v6/codec/bytes"
@@ -121,6 +122,7 @@ func (a *agentImpl) toolHandler() ai.ToolHandler {
// so the result runs plan → step → loop → approve → checkpoint → base.
h := a.baseHandler()
h = a.toolTimeoutWrap(h)
h = a.toolRetryWrap(h)
h = a.checkpointToolWrap(h)
h = a.approveWrap(h)
h = a.loopWrap(h)
@@ -164,6 +166,99 @@ func (a *agentImpl) toolTimeoutWrap(next ai.ToolHandler) ai.ToolHandler {
}
}
// toolRetryWrap retries transient tool failures with bounded backoff. It is
// opt-in because tools can have side effects; guardrail refusals and caller
// cancellation are never retried.
func (a *agentImpl) toolRetryWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
maxAttempts := a.opts.ToolMaxAttempts
if maxAttempts <= 0 {
maxAttempts = 1
}
var res ai.ToolResult
for attempt := 1; attempt <= maxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return errResult(call.ID, err.Error())
}
res = next(ctx, call)
if !retryableToolResult(res) || attempt == maxAttempts || ctx.Err() != nil {
return annotateToolAttempts(res, attempt)
}
t := time.NewTimer(toolRetryBackoff(attempt, a.opts.ToolRetryBackoff))
select {
case <-ctx.Done():
if !t.Stop() {
<-t.C
}
return errResult(call.ID, ctx.Err().Error())
case <-t.C:
}
}
return annotateToolAttempts(res, maxAttempts)
}
}
func retryableToolResult(res ai.ToolResult) bool {
if res.Refused != "" {
return false
}
msg := toolErrorMessage(res)
if msg == "" {
return false
}
return ai.IsTransientError(fmt.Errorf("%s", msg))
}
func toolErrorMessage(res ai.ToolResult) string {
if m, ok := res.Value.(map[string]string); ok {
return m["error"]
}
if m, ok := res.Value.(map[string]any); ok {
if v, ok := m["error"].(string); ok {
return v
}
}
var decoded map[string]string
if err := json.Unmarshal([]byte(res.Content), &decoded); err == nil {
return decoded["error"]
}
return ""
}
func annotateToolAttempts(res ai.ToolResult, attempts int) ai.ToolResult {
if attempts <= 1 {
return res
}
res.Attempts = attempts
if m, ok := res.Value.(map[string]string); ok {
cp := map[string]any{}
for k, v := range m {
cp[k] = v
}
cp["attempts"] = attempts
res.Value = cp
if b, err := json.Marshal(cp); err == nil {
res.Content = string(b)
}
}
return res
}
func toolRetryBackoff(attempt int, base time.Duration) time.Duration {
if base <= 0 {
base = 200 * time.Millisecond
}
if shift := attempt - 1; shift > 0 {
base <<= shift
}
if base > 30*time.Second {
return 30 * time.Second
}
return base
}
// baseHandler executes a tool call: a developer custom tool, the built-in
// delegate, or an RPC to the service. It is the innermost handler.
func (a *agentImpl) baseHandler() ai.ToolHandler {
@@ -195,7 +290,11 @@ func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
if call.Name == toolPlan {
return a.handlePlan(call)
}
return next(ctx, call)
res := next(ctx, call)
if res.Refused == "" && toolErrorMessage(res) == "" {
a.completeNextPlanStep()
}
return res
}
}
@@ -273,6 +372,36 @@ 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)
@@ -338,6 +467,7 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.Too
ModelCallTimeout(a.opts.ModelTimeout),
ModelRetry(a.opts.ModelMaxAttempts, a.opts.ModelRetryBackoff),
ToolCallTimeout(a.opts.ToolTimeout),
ToolRetry(a.opts.ToolMaxAttempts, a.opts.ToolRetryBackoff),
TraceProvider(a.opts.TraceProvider),
)
// Record lineage so the sub-agent's tool calls carry this run as parent.
+14 -1
View File
@@ -171,13 +171,26 @@ func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
func terminalAgentRunStatus(status string) bool {
switch status {
case "done", "canceled", "expired":
case "done", "canceled", "timeout", "rate_limited", "expired":
return true
default:
return false
}
}
func agentRunFailureStatus(err error) string {
switch ai.ClassifyError(err) {
case ai.ErrorKindCanceled:
return "canceled"
case ai.ErrorKindTimeout:
return "timeout"
case ai.ErrorKindRateLimited:
return "rate_limited"
default:
return "failed"
}
}
func (a *agentImpl) checkpointToolWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.Checkpoint == nil || a.currentRun == nil {
+108
View File
@@ -102,6 +102,114 @@ 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")
toolRuns := 0
modelCalls := 0
failFirst := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
modelCalls++
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.provision", Input: map[string]any{"service": "api"}})
if res.Content != "provisioned" {
t.Fatalf("tool result = %q, want provisioned", res.Content)
}
}
if failFirst {
failFirst = false
return nil, errors.New("process stopped after tool checkpoint")
}
return &ai.Response{Reply: "resumed after restart"}, nil
}
defer func() { fakeGen = nil }()
newAgent := func() *agentImpl {
return newTestAgent(Name("restart-resume-agent"), WithCheckpoint(cp),
WithTool("external.provision", "provision service once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "provisioned", nil
}))
}
first := newAgent()
_, err := first.Ask(ctx, "provision api")
if err == nil {
t.Fatal("Ask succeeded, want simulated process stop")
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
}
runs, err := Pending(ctx, first)
if err != nil {
t.Fatalf("Pending before restart: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending before restart returned %d runs, want 1", len(runs))
}
restarted := newAgent()
resp, err := Resume(ctx, restarted, runs[0].ID)
if err != nil {
t.Fatalf("Resume after restart: %v", err)
}
if resp.Reply != "resumed after restart" || resp.RunID != runs[0].ID {
t.Fatalf("response = %#v, want resumed reply on original run id", resp)
}
if toolRuns != 1 {
t.Fatalf("tool executions after restart resume = %d, want checkpointed tool not replayed", toolRuns)
}
if modelCalls != 2 {
t.Fatalf("model calls = %d, want initial call plus resumed call", modelCalls)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
}
if loaded.Status != "done" || loaded.ParentID != runs[0].ParentID {
t.Fatalf("loaded run status/parent = %s/%s, want done/%s", loaded.Status, loaded.ParentID, runs[0].ParentID)
}
}
func TestResumeFailedCheckpointDoesNotDuplicateCompactedMemory(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
+47
View File
@@ -171,3 +171,50 @@ 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)
}
}
+25 -6
View File
@@ -56,6 +56,16 @@ func NewMemory(s store.Store, key string, limit int) Memory {
return m
}
// NewRetrievalMemory returns store-backed memory that keeps a bounded active
// conversation and archives every turn for retrieval. It is useful when callers
// want relevant durable recall without summary compaction in the active context.
// A nil store or empty key keeps only the active in-process buffer.
func NewRetrievalMemory(s store.Store, key string, activeLimit int) Memory {
m := &storeMemory{store: s, key: key, hist: ai.NewHistory(activeLimit), retrieveAll: true}
m.load()
return m
}
// NewCompactingMemory returns store-backed memory with explicit compaction and
// retrieval controls. It keeps all messages in the backing store, compacts older
// turns into a deterministic summary when the conversation exceeds maxMessages,
@@ -100,16 +110,20 @@ func NewInMemory(limit int) Memory {
// storeMemory is the default Memory: an ai.History buffer optionally
// persisted to a store.
type storeMemory struct {
mu sync.Mutex
store store.Store
key string
hist *ai.History
compaction MemoryCompaction
archive []ai.Message
mu sync.Mutex
store store.Store
key string
hist *ai.History
compaction MemoryCompaction
archive []ai.Message
retrieveAll bool
}
func (m *storeMemory) Add(role, content string) {
m.mu.Lock()
if m.retrieveAll {
m.archive = append(m.archive, ai.Message{Role: role, Content: content})
}
m.hist.Add(role, content)
m.mu.Unlock()
m.compact()
@@ -133,6 +147,8 @@ func (m *storeMemory) Clear() {
// Recall returns archived messages whose content contains words from query.
// It is deterministic and provider-neutral: no embeddings or model calls are
// required, but semantic/vector stores can replace Memory for richer retrieval.
// When created with NewRetrievalMemory the archive contains every persisted
// turn; when created with NewCompactingMemory it contains compacted older turns.
func (m *storeMemory) Recall(query string, limit int) []ai.Message {
m.mu.Lock()
defer m.mu.Unlock()
@@ -186,6 +202,9 @@ func (m *storeMemory) load() {
}
m.mu.Lock()
m.archive = state.Archive
if m.retrieveAll && len(m.archive) == 0 {
m.archive = append(m.archive, state.Messages...)
}
for _, msg := range state.Messages {
m.hist.Add(msg.Role, msg.Content)
}
+43
View File
@@ -64,6 +64,49 @@ func TestWithMemoryUsed(t *testing.T) {
}
}
func TestRetrievalMemoryArchivesAllTurnsAndRanksRelevant(t *testing.T) {
st := store.NewMemoryStore()
m := NewRetrievalMemory(st, "agent/retrieval/history", 2)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta owner is lee")
m.Add("assistant", "tracked")
m.Add("user", "alpha owner is sam")
if got := len(m.Messages()); got != 2 {
t.Fatalf("active messages = %d, want bounded history of 2", got)
}
recall, ok := m.(MemoryRecall)
if !ok {
t.Fatal("retrieval memory should support recall")
}
recalled := recall.Recall("alpha budget", 2)
if len(recalled) == 0 {
t.Fatal("expected relevant recalled turns")
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("top recall = %q, want archived alpha budget turn", got)
}
}
func TestRetrievalMemoryPersistsArchiveAcrossReload(t *testing.T) {
st := store.NewMemoryStore()
m := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
reloaded := NewRetrievalMemory(st, "agent/retrieval/reload", 1)
recalled := reloaded.(MemoryRecall).Recall("alpha budget", 1)
if len(recalled) != 1 {
t.Fatalf("recalled %d messages, want 1", len(recalled))
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("reloaded recall = %q, want alpha budget", got)
}
}
func TestCompactingMemoryRecallRanksSpecificMatches(t *testing.T) {
m := NewCompactingMemory(store.NewMemoryStore(), "agent/rank/history", 3, 1).(MemoryRecall)
writer := m.(Memory)
+40
View File
@@ -40,6 +40,7 @@ type Options struct {
Provider string
Model string
APIKey string
BaseURL string
Address string
Registry registry.Registry
Client client.Client
@@ -60,10 +61,19 @@ type Options struct {
// applied before custom tools, delegate, and service RPC calls so context
// deadlines propagate consistently through the agent loop.
ToolTimeout time.Duration
// ToolMaxAttempts bounds tool execution attempts including the first call.
// Default 1; retries are opt-in because tools can have side effects.
ToolMaxAttempts int
// ToolRetryBackoff is the base delay between transient tool failures.
ToolRetryBackoff time.Duration
// Memory is the agent's conversation memory. Nil = the default
// store-backed memory (durable across restarts).
Memory Memory
// MemoryRetrievalLimit enables retrieval-backed default memory without
// compaction. The active conversation stays bounded to this many messages
// while every turn is archived for deterministic recall.
MemoryRetrievalLimit int
// MemoryCompaction enables deterministic compaction/retrieval on the
// default store-backed memory. Custom Memory implementations can expose
// retrieval by implementing MemoryRecall.
@@ -117,6 +127,8 @@ func newOptions(opts ...Option) Options {
ModelMaxAttempts: 1, // retries opt-in via ModelRetry (see field doc)
ModelRetryBackoff: 100 * time.Millisecond,
ToolTimeout: 30 * time.Second,
ToolMaxAttempts: 1,
ToolRetryBackoff: 100 * time.Millisecond,
// On by default and lenient: identical repeated calls are a
// no-progress loop, never useful. Set LoopLimit(0) to disable.
LoopLimit: 3,
@@ -157,6 +169,12 @@ 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.
@@ -225,6 +243,16 @@ func ModelRetry(maxAttempts int, backoff time.Duration) Option {
}
}
// ToolRetry sets the tool retry budget and backoff for transient failures.
// Attempts include the first call. Retries are opt-in because tools may have
// side effects; keep handlers idempotent before enabling this.
func ToolRetry(maxAttempts int, backoff time.Duration) Option {
return func(o *Options) {
o.ToolMaxAttempts = maxAttempts
o.ToolRetryBackoff = backoff
}
}
// WithA2A makes Run serve the agent over the A2A protocol on addr (e.g.
// ":4000"), so other agents can reach it directly by URL without a
// separate gateway. The agent stays a normal go-micro service as well;
@@ -240,6 +268,18 @@ func WithMemory(m Memory) Option {
return func(o *Options) { o.Memory = m }
}
// RetrievalMemory enables deterministic, store-backed retrieval memory for
// the default agent memory without compaction. Active context is capped at
// activeLimit messages while every turn is archived in the store for Recall.
func RetrievalMemory(activeLimit int) Option {
return func(o *Options) {
o.MemoryRetrievalLimit = activeLimit
if o.MemoryRecallLimit == 0 {
o.MemoryRecallLimit = 5
}
}
}
// CompactMemory enables deterministic, store-backed memory compaction for the
// default agent memory. Older turns are summarized once active context exceeds
// maxMessages, keepRecent newest turns remain verbatim, and recalled archived
+30 -5
View File
@@ -41,6 +41,10 @@ const (
AttrErrorKind = "agent.error.kind"
AttrCheckpointStatus = "agent.checkpoint.status"
AttrCheckpointStage = "agent.checkpoint.stage"
AttrFlowName = "agent.flow.name"
AttrFlowStep = "agent.flow.step"
AttrDispatch = "agent.dispatch"
AttrTrigger = "agent.trigger"
)
type RunEvent struct {
@@ -124,8 +128,12 @@ func (a *agentImpl) startRun(ctx context.Context, message string) (context.Conte
}
}
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
attribute.String(AttrRunID, info.RunID), attribute.String(AttrParentRunID, info.ParentID), attribute.String(AttrAgentName, info.Agent)))
attrs := appendRunInfoAttributes([]attribute.KeyValue{
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
}, info)
ctx, span := a.tracer().Start(ctx, spanNameRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...))
a.recordSpanEvent(span, runEvent)
return ctx, func(err error) {
latency := time.Since(start).Milliseconds()
@@ -171,16 +179,17 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
return resp, err
}
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(
attrs := appendRunInfoAttributes([]attribute.KeyValue{
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrProvider, provider),
attribute.String(AttrModel, model),
))
}, info)
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(attrs...))
resp, err := m.Model.Generate(ctx, req, opts...)
dur := time.Since(start).Milliseconds()
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
attrs = []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
if info.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, info.Attempt))
}
@@ -360,6 +369,22 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
return attrs
}
func appendRunInfoAttributes(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
if info.Flow != "" {
attrs = append(attrs, attribute.String(AttrFlowName, info.Flow))
}
if info.Step != "" {
attrs = append(attrs, attribute.String(AttrFlowStep, info.Step))
}
if info.Dispatch != "" {
attrs = append(attrs, attribute.String(AttrDispatch, info.Dispatch))
}
if info.Trigger != "" {
attrs = append(attrs, attribute.String(AttrTrigger, info.Trigger))
}
return attrs
}
func (a *agentImpl) recordRunEvent(e RunEvent) {
if e.RunID == "" {
return
+100
View File
@@ -8,6 +8,8 @@ import (
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestAskCancellationAbortsPromptly(t *testing.T) {
@@ -129,3 +131,101 @@ func TestToolCallTimeoutPropagatesDeadlineToCustomTool(t *testing.T) {
t.Fatalf("tool call took %s, want bounded timeout", elapsed)
}
}
func TestAskCheckpointRecordsTerminalOperationalFailureStatus(t *testing.T) {
tests := []struct {
name string
err error
want string
}{
{name: "canceled", err: context.Canceled, want: "canceled"},
{name: "timeout", err: context.DeadlineExceeded, want: "timeout"},
{name: "rate limited", err: testStatusError{code: 429}, want: "rate_limited"},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "terminal-"+strings.ReplaceAll(tt.name, " ", "-"))
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return nil, tt.err
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("terminal-"+strings.ReplaceAll(tt.name, " ", "-")), WithCheckpoint(cp))
_, err := a.Ask(context.Background(), "fail safely")
if err == nil {
t.Fatal("Ask succeeded, want failure")
}
runs, err := cp.List(context.Background())
if err != nil {
t.Fatalf("List: %v", err)
}
if len(runs) != 1 {
t.Fatalf("checkpointed runs = %d, want 1", len(runs))
}
if runs[0].Status != tt.want {
t.Fatalf("run status = %q, want %q", runs[0].Status, tt.want)
}
if len(runs[0].Steps) == 0 || runs[0].Steps[0].Status != tt.want {
t.Fatalf("step status = %#v, want %q", runs[0].Steps, tt.want)
}
if pending, err := Pending(context.Background(), a); err != nil || len(pending) != 0 {
t.Fatalf("Pending = %#v, %v; want no terminal run", pending, err)
}
})
}
}
type testStatusError struct {
code int
}
func (e testStatusError) Error() string { return "provider status error" }
func (e testStatusError) StatusCode() int { return e.code }
func TestToolRetryRetriesTransientToolErrorsThenSucceeds(t *testing.T) {
attempts := 0
a := newTestAgent(
Name("tool-retry-success"),
ToolRetry(3, time.Millisecond),
WithTool("flaky", "flaky tool", nil, func(context.Context, map[string]any) (string, error) {
attempts++
if attempts < 3 {
return "", context.DeadlineExceeded
}
return "ok", nil
}),
)
content := toolContent(a.toolHandler(), "flaky", nil)
if content != "ok" {
t.Fatalf("tool result = %q, want ok", content)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestToolRetryDoesNotRetryGuardrailRefusals(t *testing.T) {
attempts := 0
a := newTestAgent(
Name("tool-retry-refusal"),
MaxSteps(1),
ToolRetry(3, time.Millisecond),
WithTool("counted", "counted tool", nil, func(context.Context, map[string]any) (string, error) {
attempts++
return "ok", nil
}),
)
h := a.toolHandler()
_ = toolContent(h, "counted", nil)
content := toolContent(h, "counted", nil)
if !strings.Contains(content, "step limit reached") {
t.Fatalf("tool result = %q, want step-limit refusal", content)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want only the allowed tool call to execute", attempts)
}
}
+141
View File
@@ -0,0 +1,141 @@
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
}
+9 -1
View File
@@ -27,6 +27,7 @@ import (
"fmt"
"io"
"net/http"
"os"
"strings"
"time"
@@ -56,7 +57,14 @@ func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "deepseek-ai/DeepSeek-V3-0324"
// Allow the chat model to be selected via the ATLASCLOUD_MODEL env var
// (e.g. to run CI conformance against a stronger tool-use model) without
// a code change; fall back to a sensible default otherwise.
if m := os.Getenv("ATLASCLOUD_MODEL"); m != "" {
options.Model = m
} else {
options.Model = "deepseek-ai/DeepSeek-V3-0324"
}
}
if options.BaseURL == "" {
options.BaseURL = "https://api.atlascloud.ai"
+14 -10
View File
@@ -93,9 +93,10 @@ func (c ToolCall) Scan(v any) error {
// ToolResult represents the result of a tool execution
type ToolResult struct {
ID string // Tool call ID (for correlation)
Value any // Structured result (optional)
Content string // Tool execution result (JSON string), shown to the model
ID string // Tool call ID (for correlation)
Value any // Structured result (optional)
Content string // Tool execution result (JSON string), shown to the model
Attempts int `json:"attempts,omitempty"` // Tool execution attempts, set when retried.
// Refused names the reason a guardrail blocked the call before it ran
// ("max_steps", "loop", "approval"); empty when the call executed. A
// tool wrapper can switch on it to build reliability tooling — react to
@@ -121,13 +122,16 @@ const (
// tell which provider attempt produced the call and whether it is part of a
// retry budget. They are zero when no model-attempt context is known.
type RunInfo struct {
RunID string // correlation id for this agent or flow run
ParentID string // the run that delegated to this one, if any
Agent string // the agent's name
Flow string // the flow's name, when the call is part of a workflow
Step string // the flow step currently executing, when known
Attempt int // current model Generate attempt, starting at 1 when known
MaxAttempts int // configured model Generate attempt budget when known
RunID string // correlation id for this agent or flow run
ParentID string // the run that delegated to this one, if any
Agent string // the agent's name
Flow string // the flow's name, when the call is part of a workflow
Step string // the flow step currently executing, when known
Attempt int // current model Generate attempt, starting at 1 when known
MaxAttempts int // configured model Generate attempt budget when known
VerificationFeedback string // feedback from the previous failed verifier attempt, when retrying a flow step
Dispatch string // how the run was dispatched (direct, broker, schedule, resume) when known
Trigger string // external trigger or schedule label that started the run, when known
}
type runInfoKey struct{}
+729
View File
@@ -0,0 +1,729 @@
// Package ollama implements the Ollama model provider.
//
// Ollama runs open-weight models locally (or via Ollama Cloud). This
// provider supports two API styles:
//
// - Native (/api/chat): local Ollama servers (default, http://localhost:11434)
// - OpenAI-compatible (/v1/chat/completions): Ollama Cloud (https://ollama.com/v1)
//
// The provider auto-detects which style to use based on the base URL.
// Set OLLAMA_BASE_URL to point at your server (local or cloud).
//
// Usage (local):
//
// import _ "go-micro.dev/v6/ai/ollama"
//
// m := ai.New("ollama",
// ai.WithBaseURL("http://localhost:11434"),
// ai.WithModel("llama3.2"),
// )
//
// Usage (Ollama Cloud):
//
// m := ai.New("ollama",
// ai.WithBaseURL("https://ollama.com/v1"),
// ai.WithAPIKey("your-key"),
// ai.WithModel("gpt-oss:120b"),
// )
package ollama
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
)
func init() {
ai.Register("ollama", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("ollama")
}
// Provider implements the ai.Model interface for Ollama.
type Provider struct {
opts ai.Options
// cloudOverride forces cloud mode for testing. When true, the provider
// uses the OpenAI-compatible endpoint regardless of the base URL.
cloudOverride bool
}
// NewProvider creates a new Ollama provider.
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "llama3.2"
}
if options.BaseURL == "" {
options.BaseURL = "http://localhost:11434"
}
return &Provider{opts: options}
}
// Init initializes the provider with options.
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
// Options returns the provider options.
func (p *Provider) Options() ai.Options { return p.opts }
// String returns the provider name.
func (p *Provider) String() string { return "ollama" }
// isCloud returns true when the base URL points at Ollama Cloud (ollama.com),
// which uses the OpenAI-compatible /v1/chat/completions endpoint instead of
// the native /api/chat.
func (p *Provider) isCloud() bool {
if p.cloudOverride {
return true
}
return strings.Contains(p.opts.BaseURL, "ollama.com")
}
// chatPath returns the API endpoint path for chat completions.
func (p *Provider) chatPath() string {
if p.isCloud() {
return "/v1/chat/completions"
}
return "/api/chat"
}
// streamPath returns the API endpoint path for streaming chat.
// Ollama Cloud uses the same /v1/chat/completions with stream:true.
// Local Ollama uses /api/chat with stream:true.
func (p *Provider) streamPath() string {
return p.chatPath()
}
// Generate generates a response from the Ollama model.
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
if p.isCloud() {
return p.generateOpenAI(ctx, req)
}
return p.generateNative(ctx, req)
}
// Stream generates a streaming response.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
if p.isCloud() {
return p.streamOpenAI(ctx, req)
}
return p.streamNative(ctx, req)
}
// ---------------------------------------------------------------------------
// OpenAI-compatible mode (Ollama Cloud: ollama.com/v1)
// ---------------------------------------------------------------------------
func (p *Provider) generateOpenAI(ctx context.Context, req *ai.Request) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
},
})
}
messages := buildOpenAIMessages(req)
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": false,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
resp, rawMsg, err := p.callOpenAI(ctx, apiReq)
if err != nil {
return nil, err
}
// No tool calls or no handler — return as-is.
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
// Tool execution loop.
convMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMsg.content,
"tool_calls": rawMsg.toolCalls,
})
pendingCalls := resp.ToolCalls
for round := 0; round < 10; round++ {
for i := range pendingCalls {
result := p.opts.ToolHandler(ctx, pendingCalls[i])
pendingCalls[i].Result = result.Content
convMessages = append(convMessages, map[string]any{
"role": "tool",
"tool_call_id": pendingCalls[i].ID,
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": convMessages,
"stream": false,
}
if len(tools) > 0 {
followUpReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
followUpReq["max_tokens"] = p.opts.MaxTokens
}
followUpResp, followUpRaw, err := p.callOpenAI(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
convMessages = append(convMessages, map[string]any{
"role": "assistant",
"content": followUpRaw.content,
"tool_calls": followUpRaw.toolCalls,
})
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
func (p *Provider) callOpenAI(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.chatPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Usage struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
Choices []struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, nil, fmt.Errorf("no response from API")
}
choice := chatResp.Choices[0]
response := &ai.Response{
Reply: choice.Message.Content,
Usage: ai.Usage{
InputTokens: chatResp.Usage.PromptTokens,
OutputTokens: chatResp.Usage.CompletionTokens,
TotalTokens: chatResp.Usage.TotalTokens,
},
}
var rawToolCalls []map[string]any
for _, tc := range choice.Message.ToolCalls {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
rawToolCalls = append(rawToolCalls, map[string]any{
"id": tc.ID,
"type": "function",
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
raw := &rawChatMessage{
content: choice.Message.Content,
toolCalls: rawToolCalls,
}
return response, raw, nil
}
func (p *Provider) streamOpenAI(ctx context.Context, req *ai.Request) (ai.Stream, error) {
messages := buildOpenAIMessages(req)
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
"stream_options": map[string]any{"include_usage": true},
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.streamPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Accept", "text/event-stream")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &sseStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// buildOpenAIMessages converts an ai.Request into the OpenAI chat message format.
func buildOpenAIMessages(req *ai.Request) []map[string]any {
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
return messages
}
// sseStream reads OpenAI-style server-sent events (used by Ollama Cloud).
type sseStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *sseStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" || strings.HasPrefix(line, ":") {
continue
}
if !strings.HasPrefix(line, "data:") {
continue
}
data := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if data == "[DONE]" {
return nil, io.EOF
}
var chunk struct {
Choices []struct {
Delta struct {
Content string `json:"content"`
} `json:"delta"`
} `json:"choices"`
Usage *struct {
PromptTokens int `json:"prompt_tokens"`
CompletionTokens int `json:"completion_tokens"`
TotalTokens int `json:"total_tokens"`
} `json:"usage"`
}
if err := json.Unmarshal([]byte(data), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if len(chunk.Choices) > 0 && chunk.Choices[0].Delta.Content != "" {
return &ai.Response{Reply: chunk.Choices[0].Delta.Content}, nil
}
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *sseStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// ---------------------------------------------------------------------------
// Native mode (local Ollama: localhost:11434/api/chat)
// ---------------------------------------------------------------------------
func (p *Provider) generateNative(ctx context.Context, req *ai.Request) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
},
})
}
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": false,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
resp, rawMsg, err := p.callNative(ctx, apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
convMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMsg.content,
})
if len(rawMsg.toolCalls) > 0 {
convMessages[len(convMessages)-1]["tool_calls"] = rawMsg.toolCalls
}
pendingCalls := resp.ToolCalls
for round := 0; round < 10; round++ {
for i := range pendingCalls {
result := p.opts.ToolHandler(ctx, pendingCalls[i])
pendingCalls[i].Result = result.Content
convMessages = append(convMessages, map[string]any{
"role": "tool",
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": convMessages,
"stream": false,
}
if len(tools) > 0 {
followUpReq["tools"] = tools
}
if p.opts.MaxTokens > 0 {
followUpReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
followUpResp, followUpRaw, err := p.callNative(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
convMessages = append(convMessages, map[string]any{
"role": "assistant",
"content": followUpRaw.content,
})
if len(followUpRaw.toolCalls) > 0 {
convMessages[len(convMessages)-1]["tool_calls"] = followUpRaw.toolCalls
}
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
}
func (p *Provider) callNative(ctx context.Context, req map[string]any) (*ai.Response, *rawChatMessage, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.chatPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Message struct {
Role string `json:"role"`
Content string `json:"content"`
ToolCalls []struct {
Function struct {
Name string `json:"name"`
Arguments any `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
Done bool `json:"done"`
PromptEvalCount int `json:"prompt_eval_count"`
EvalCount int `json:"eval_count"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
response := &ai.Response{
Reply: chatResp.Message.Content,
Usage: ai.Usage{
InputTokens: chatResp.PromptEvalCount,
OutputTokens: chatResp.EvalCount,
TotalTokens: chatResp.PromptEvalCount + chatResp.EvalCount,
},
}
var rawToolCalls []map[string]any
for _, tc := range chatResp.Message.ToolCalls {
var input map[string]any
switch v := tc.Function.Arguments.(type) {
case string:
if err := json.Unmarshal([]byte(v), &input); err != nil {
input = map[string]any{}
}
case map[string]any:
input = v
default:
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
Name: tc.Function.Name,
Input: input,
})
rawToolCalls = append(rawToolCalls, map[string]any{
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
raw := &rawChatMessage{
content: chatResp.Message.Content,
toolCalls: rawToolCalls,
}
return response, raw, nil
}
func (p *Provider) streamNative(ctx context.Context, req *ai.Request) (ai.Stream, error) {
messages := []map[string]any{}
if req.SystemPrompt != "" {
messages = append(messages, map[string]any{"role": "system", "content": req.SystemPrompt})
}
for _, m := range req.Messages {
messages = append(messages, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
messages = append(messages, map[string]any{"role": "user", "content": req.Prompt})
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
"stream": true,
}
if p.opts.MaxTokens > 0 {
apiReq["options"] = map[string]any{"num_predict": p.opts.MaxTokens}
}
reqBody, err := json.Marshal(apiReq)
if err != nil {
return nil, fmt.Errorf("failed to marshal stream request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + p.streamPath()
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create stream request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
if p.opts.APIKey != "" {
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
}
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, fmt.Errorf("stream API request failed: %w", err)
}
if httpResp.StatusCode != http.StatusOK {
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
return nil, fmt.Errorf("stream API error (%s): %s", httpResp.Status, string(respBody))
}
return &ndjsonStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
// ndjsonStream reads newline-delimited JSON (used by local Ollama).
type ndjsonStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *ndjsonStream) Recv() (*ai.Response, error) {
for s.scanner.Scan() {
line := strings.TrimSpace(s.scanner.Text())
if line == "" {
continue
}
var chunk struct {
Message struct {
Content string `json:"content"`
} `json:"message"`
Done bool `json:"done"`
}
if err := json.Unmarshal([]byte(line), &chunk); err != nil {
return nil, fmt.Errorf("failed to parse stream chunk: %w", err)
}
if chunk.Done {
return nil, io.EOF
}
if chunk.Message.Content != "" {
return &ai.Response{Reply: chunk.Message.Content}, nil
}
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *ndjsonStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// rawChatMessage holds the raw assistant content and tool calls for
// follow-up messages.
type rawChatMessage struct {
content string
toolCalls []map[string]any
}
+333
View File
@@ -0,0 +1,333 @@
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())
}
}
+104
View File
@@ -7,6 +7,7 @@ import (
"io"
"net/http"
"net/http/httptest"
"os"
"reflect"
"strings"
"testing"
@@ -150,6 +151,109 @@ func TestStreamProvidersCloseCancelsInFlightRequest(t *testing.T) {
}
}
func TestStreamProvidersPropagateProviderErrors(t *testing.T) {
for _, provider := range conformingStreamProviders(t) {
provider := provider
t.Run(provider, func(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, "upstream quota exhausted", http.StatusTooManyRequests)
}))
defer ts.Close()
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err == nil {
_ = stream.Close()
t.Fatal("Stream returned nil error for provider failure")
}
if !strings.Contains(err.Error(), "429") || !strings.Contains(err.Error(), "upstream quota exhausted") {
t.Fatalf("Stream error = %v, want provider status and body", err)
}
if strings.Contains(err.Error(), "test-key") {
t.Fatal("provider error leaked API key")
}
})
}
}
func TestStreamProvidersHonorCanceledContextBeforeRequest(t *testing.T) {
for _, provider := range conformingStreamProviders(t) {
provider := provider
t.Run(provider, func(t *testing.T) {
var sawRequest bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
sawRequest = true
http.Error(w, "unexpected request", http.StatusInternalServerError)
}))
defer ts.Close()
ctx, cancel := context.WithCancel(context.Background())
cancel()
stream, err := ai.New(provider, ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL)).Stream(ctx, &ai.Request{Prompt: "Hello"})
if err == nil {
_ = stream.Close()
t.Fatal("Stream returned nil error for canceled context")
}
if !errors.Is(err, context.Canceled) {
t.Fatalf("Stream error = %v, want context.Canceled", err)
}
if sawRequest {
t.Fatal("provider sent request after context was already canceled")
}
})
}
}
func TestConfiguredProviderStreamsSkipWithoutCredentials(t *testing.T) {
for _, tc := range []struct {
provider string
keyEnv string
modelEnv string
}{
{provider: "openai", keyEnv: "OPENAI_API_KEY", modelEnv: "OPENAI_MODEL"},
{provider: "groq", keyEnv: "GROQ_API_KEY", modelEnv: "GROQ_MODEL"},
{provider: "mistral", keyEnv: "MISTRAL_API_KEY", modelEnv: "MISTRAL_MODEL"},
{provider: "together", keyEnv: "TOGETHER_API_KEY", modelEnv: "TOGETHER_MODEL"},
{provider: "atlascloud", keyEnv: "ATLASCLOUD_API_KEY", modelEnv: "ATLASCLOUD_MODEL"},
} {
tc := tc
t.Run(tc.provider, func(t *testing.T) {
key := os.Getenv(tc.keyEnv)
if key == "" {
t.Skipf("%s not set; skipping configured provider stream check", tc.keyEnv)
}
opts := []ai.Option{ai.WithAPIKey(key)}
if model := os.Getenv(tc.modelEnv); model != "" {
opts = append(opts, ai.WithModel(model))
}
stream, err := ai.New(tc.provider, opts...).Stream(context.Background(), &ai.Request{Prompt: "Reply with exactly: ok"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
deadline := time.After(30 * time.Second)
for {
select {
case <-deadline:
t.Fatal("timed out waiting for provider stream chunk")
default:
}
chunk, err := stream.Recv()
if err != nil {
if errors.Is(err, io.EOF) {
t.Fatal("provider stream ended without content")
}
t.Fatalf("Recv returned error: %v", err)
}
if chunk.Reply != "" {
return
}
}
})
}
}
func TestUnsupportedProvidersReturnStreamingUnsupportedAndStayUnregistered(t *testing.T) {
for _, provider := range []string{"anthropic", "gemini"} {
provider := provider
+8
View File
@@ -36,6 +36,14 @@ 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",
+140
View File
@@ -0,0 +1,140 @@
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
}
+78
View File
@@ -0,0 +1,78 @@
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)
}
}
+51
View File
@@ -0,0 +1,51 @@
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 -1
View File
@@ -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/WeMU5AGxD
- Discord: https://discord.gg/G8Gk5j3uXr
+1 -1
View File
@@ -102,4 +102,4 @@ pytest tests/integration/ -v
## Questions?
- GitHub Discussions: https://github.com/micro/go-micro/discussions
- Discord: https://discord.gg/WeMU5AGxD
- Discord: https://discord.gg/G8Gk5j3uXr
+1 -1
View File
@@ -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/WeMU5AGxD
- Discord: https://discord.gg/G8Gk5j3uXr
+246
View File
@@ -0,0 +1,246 @@
// 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.")
}
+63
View File
@@ -7,6 +7,7 @@ import (
"go-micro.dev/v6/client"
codecbytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/store"
)
// fakeClient embeds the default client (so NewRequest works) and
@@ -65,3 +66,65 @@ func TestExecuteDispatchesToAgent(t *testing.T) {
t.Errorf("rendered prompt = %q, want %q", results[0].Prompt, "welcome bob")
}
}
// A caller-owned schedule can trigger an agent workflow without a human chat
// prompt and still leave the normal flow run metadata behind for inspection.
func TestScheduledAgentRunHarnessContract(t *testing.T) {
ctx := context.Background()
cp := StoreCheckpoint(store.NewMemoryStore(), "scheduled-contract")
f := New("scheduled-contract",
Trigger("schedule.daily"),
WithCheckpoint(cp),
Steps(Step{Name: "summarize", Run: Dispatch("ops-agent")}),
)
var parentID string
f.client = &fakeClient{
Client: client.DefaultClient,
callFn: func(req client.Request, rsp interface{}) error {
if req.Service() != "ops-agent" || req.Endpoint() != "Agent.Chat" {
t.Fatalf("dispatched to %s.%s, want ops-agent.Agent.Chat", req.Service(), req.Endpoint())
}
reqFrame := req.Body().(*codecbytes.Frame)
var body map[string]string
if err := json.Unmarshal(reqFrame.Data, &body); err != nil {
t.Fatalf("request body: %v", err)
}
parentID = body["parent_id"]
if body["message"] != "run unattended daily ops review" {
t.Fatalf("message = %q, want scheduled payload", body["message"])
}
frame := rsp.(*codecbytes.Frame)
frame.Data = []byte(`{"reply":"review queued","agent":"ops-agent","parent_id":"` + parentID + `"}`)
return nil
},
}
if err := Scheduled(f, "run unattended daily ops review").Tick(ctx); err != nil {
t.Fatalf("scheduled tick: %v", err)
}
if parentID == "" {
t.Fatal("dispatch did not receive the scheduled flow run id as parent_id")
}
runs, err := cp.List(ctx)
if err != nil {
t.Fatalf("list scheduled runs: %v", err)
}
if len(runs) != 1 {
t.Fatalf("got %d runs, want 1", len(runs))
}
run := runs[0]
if run.ID != parentID {
t.Fatalf("run ID = %q, parent_id = %q", run.ID, parentID)
}
if run.Flow != "scheduled-contract" || run.Status != "done" {
t.Fatalf("run = %+v, want scheduled-contract done", run)
}
if got := run.State.String(); got != "review queued" {
t.Fatalf("run result = %q, want agent reply", got)
}
if len(run.Steps) != 1 || run.Steps[0].Name != "summarize" || run.Steps[0].Status != "done" {
t.Fatalf("steps = %+v, want summarize done", run.Steps)
}
}
+6 -2
View File
@@ -142,7 +142,8 @@ func (f *Flow) Register(reg registry.Registry, br broker.Broker, cl client.Clien
if f.opts.TriggerTopic != "" {
sub, err := br.Subscribe(f.opts.TriggerTopic, func(p broker.Event) error {
data := string(p.Message().Body)
if err := f.Execute(context.Background(), data); err != nil {
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{Dispatch: "broker", Trigger: f.opts.TriggerTopic})
if err := f.Execute(ctx, data); err != nil {
f.log.Logf(logger.ErrorLevel, "Flow %s failed: %v", f.name, err)
}
return nil
@@ -223,7 +224,10 @@ func (f *Flow) Execute(ctx context.Context, data string) error {
}
runID := uuid.New().String()
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Flow: f.name})
info, _ := ai.RunInfoFrom(ctx)
info.RunID = runID
info.Flow = f.name
ctx = ai.WithRunInfo(ctx, info)
start := time.Now()
+43 -15
View File
@@ -16,14 +16,18 @@ const (
spanNameFlowRun = "flow.run"
spanNameFlowStep = "flow.step"
AttrFlowRunID = "flow.run.id"
AttrFlowParentID = "flow.run.parent_id"
AttrFlowName = "flow.name"
AttrFlowStepName = "flow.step.name"
AttrFlowStatus = "flow.status"
AttrFlowAttempts = "flow.step.attempts"
AttrFlowLatencyMS = "flow.latency_ms"
AttrFlowErrorKind = "flow.error.kind"
AttrFlowRunID = "flow.run.id"
AttrFlowParentID = "flow.run.parent_id"
AttrFlowName = "flow.name"
AttrFlowStepName = "flow.step.name"
AttrFlowStatus = "flow.status"
AttrFlowAttempts = "flow.step.attempts"
AttrFlowLatencyMS = "flow.latency_ms"
AttrFlowErrorKind = "flow.error.kind"
AttrFlowVerificationStatus = "flow.verification.status"
AttrFlowVerificationNote = "flow.verification.note"
AttrFlowDispatch = "flow.dispatch"
AttrFlowTrigger = "flow.trigger"
)
func (f *Flow) tracer() trace.Tracer {
@@ -34,12 +38,15 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
if f.opts.TraceProvider == nil {
return ctx, func(Run, error) {}
}
ctx, span := f.tracer().Start(ctx, spanNameFlowRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(
info, _ := ai.RunInfoFrom(ctx)
attrs := []attribute.KeyValue{
attribute.String(AttrFlowRunID, run.ID),
attribute.String(AttrFlowParentID, run.ParentID),
attribute.String(AttrFlowName, f.name),
attribute.String(AttrFlowStatus, run.Status),
))
}
attrs = appendRunInfoDispatch(attrs, info)
ctx, span := f.tracer().Start(ctx, spanNameFlowRun, trace.WithSpanKind(trace.SpanKindInternal), trace.WithAttributes(attrs...))
start := time.Now()
return ctx, func(done Run, err error) {
span.SetAttributes(
@@ -57,23 +64,34 @@ func (f *Flow) startRunSpan(ctx context.Context, run Run) (context.Context, func
}
}
func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int, error) {
func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int, Verification, error) {
if f.opts.TraceProvider == nil {
return f.runStep(ctx, step, in)
}
info, _ := ai.RunInfoFrom(ctx)
ctx, span := f.tracer().Start(ctx, spanNameFlowStep, trace.WithAttributes(
attrs := []attribute.KeyValue{
attribute.String(AttrFlowRunID, info.RunID),
attribute.String(AttrFlowParentID, info.ParentID),
attribute.String(AttrFlowName, f.name),
attribute.String(AttrFlowStepName, step.Name),
))
}
attrs = appendRunInfoDispatch(attrs, info)
ctx, span := f.tracer().Start(ctx, spanNameFlowStep, trace.WithAttributes(attrs...))
start := time.Now()
out, attempts, err := f.runStep(ctx, step, in)
out, attempts, verification, err := f.runStep(ctx, step, in)
span.SetAttributes(
attribute.Int(AttrFlowAttempts, attempts),
attribute.Int64(AttrFlowLatencyMS, time.Since(start).Milliseconds()),
)
if verification.Passed {
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "passed"))
}
if verification.Feedback != "" {
span.SetAttributes(attribute.String(AttrFlowVerificationNote, verification.Feedback))
if !verification.Passed {
span.SetAttributes(attribute.String(AttrFlowVerificationStatus, "failed"))
}
}
if err != nil {
span.RecordError(err)
span.SetAttributes(attribute.String(AttrFlowErrorKind, string(ai.ClassifyError(err))))
@@ -82,5 +100,15 @@ func (f *Flow) runStepSpan(ctx context.Context, step Step, in State) (State, int
span.SetStatus(codes.Ok, "")
}
span.End()
return out, attempts, err
return out, attempts, verification, err
}
func appendRunInfoDispatch(attrs []attribute.KeyValue, info ai.RunInfo) []attribute.KeyValue {
if info.Dispatch != "" {
attrs = append(attrs, attribute.String(AttrFlowDispatch, info.Dispatch))
}
if info.Trigger != "" {
attrs = append(attrs, attribute.String(AttrFlowTrigger, info.Trigger))
}
return attrs
}
+26
View File
@@ -74,3 +74,29 @@ func flowSpanAttributes(attrs []attribute.KeyValue) map[string]string {
func withTestRunInfo(ctx context.Context, runID string) context.Context {
return ai.WithRunInfo(ctx, ai.RunInfo{RunID: runID, Agent: "planner"})
}
func TestScheduledFlowOpenTelemetryDispatchAttributes(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
step := Step{Name: "summarize", Run: func(ctx context.Context, in State) (State, error) {
in.Data = []byte("queued")
return in, nil
}}
f := New("scheduled-observed", Trigger("schedule.daily"), WithCheckpoint(StoreCheckpoint(store.NewMemoryStore(), "scheduled-observed")), TraceProvider(tp), Steps(step))
if err := Scheduled(f, "daily ops review").Tick(context.Background()); err != nil {
t.Fatal(err)
}
for _, span := range exp.GetSpans().Snapshots() {
if span.Name() != spanNameFlowRun {
continue
}
attrs := flowSpanAttributes(span.Attributes())
if attrs[AttrFlowDispatch] != "schedule" || attrs[AttrFlowTrigger] != "schedule.daily" {
t.Fatalf("scheduled run span dispatch attributes = %#v", attrs)
}
return
}
t.Fatal("flow run span not emitted")
}
+60
View File
@@ -0,0 +1,60 @@
package flow
import (
"context"
"time"
"go-micro.dev/v6/ai"
)
// Schedule binds a flow to a recurring work item without introducing a
// scheduler service. It is a small harness contract: callers own the clock,
// Go Micro owns turning each tick into the same inspectable flow run used for
// broker events and direct Execute calls.
type Schedule struct {
flow *Flow
data string
}
// Scheduled returns a deterministic scheduled-run harness for this flow.
// Tests and event loops can call Tick directly; production processes can wire
// the same contract to time.Ticker through RunEvery. Each tick calls Execute, so
// checkpointed run history, parent/run metadata, cancellation, and inspection
// stay on the normal flow surfaces.
func Scheduled(f *Flow, data string) Schedule {
return Schedule{flow: f, data: data}
}
// Tick starts one scheduled run immediately and returns when that run finishes.
func (s Schedule) Tick(ctx context.Context) error {
if ctx == nil {
ctx = context.Background()
}
info, _ := ai.RunInfoFrom(ctx)
info.Dispatch = "schedule"
if info.Trigger == "" {
info.Trigger = s.flow.opts.TriggerTopic
}
if info.Trigger == "" {
info.Trigger = "schedule"
}
return s.flow.Execute(ai.WithRunInfo(ctx, info), s.data)
}
// RunEvery drives scheduled runs from a ticker until ctx is canceled. It does
// not persist schedule definitions or host a scheduler; it only adapts a caller
// owned cadence to Tick.
func (s Schedule) RunEvery(ctx context.Context, interval time.Duration) error {
ticker := time.NewTicker(interval)
defer ticker.Stop()
for {
select {
case <-ctx.Done():
return ctx.Err()
case <-ticker.C:
if err := s.Tick(ctx); err != nil {
return err
}
}
}
}
+83 -22
View File
@@ -52,23 +52,53 @@ func (s State) String() string { return string(s.Data) }
// returns the next state.
type StepFunc func(ctx context.Context, in State) (State, error)
// Step is one unit of a flow — a named action with an optional retry
// override. There is one Step kind; the action is the Run func, and the
// Call/LLM/Agent helpers produce the common ones.
// Verifier grades a step output before the flow advances. Returning
// Passed=false converts the grade into a retryable VerificationError, so
// the existing step retry/supervision path can feed Feedback into the next
// attempt through ai.RunInfo.VerificationFeedback.
type Verifier func(ctx context.Context, out State) (Verification, error)
// Verification is the verifier's deterministic grade for one step attempt.
type Verification struct {
Passed bool
Feedback string
}
// VerificationError reports a failed grade. It is returned from runStep so
// existing retry, checkpoint, and trace paths handle verifier failures the
// same way they handle step execution failures.
type VerificationError struct {
Step string
Feedback string
}
func (e *VerificationError) Error() string {
if e.Feedback == "" {
return fmt.Sprintf("flow: verification failed for step %q", e.Step)
}
return fmt.Sprintf("flow: verification failed for step %q: %s", e.Step, e.Feedback)
}
// Step is one unit of a flow — a named action with optional retry and
// verification hooks. There is one Step kind; the action is the Run func,
// and the Call/LLM/Agent helpers produce the common ones.
type Step struct {
Name string
Run StepFunc
Retry int // per-step override of the flow's retry (0 = use the flow default)
Name string
Run StepFunc
Retry int // per-step override of the flow's retry (0 = use the flow default)
Verify Verifier // optional grade; failed grades retry the step with feedback in RunInfo
}
// StepRecord is the recorded outcome of one step within a run.
type StepRecord struct {
Name string `json:"name"`
Status string `json:"status"` // pending | in_progress | done | failed
Attempts int `json:"attempts"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
Name string `json:"name"`
Status string `json:"status"` // pending | in_progress | done | failed
Attempts int `json:"attempts"`
Result string `json:"result,omitempty"`
Error string `json:"error,omitempty"`
ErrorKind string `json:"error_kind,omitempty"`
VerificationStatus string `json:"verification_status,omitempty"` // passed | failed
VerificationNote string `json:"verification_note,omitempty"`
}
// Run is the persisted record of one flow execution — what a Checkpoint
@@ -403,7 +433,12 @@ func (f *Flow) Pending(ctx context.Context) ([]Run, error) {
func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
steps := f.opts.Steps
ctx = withDeps(ctx, &runDeps{client: f.client, model: f.model, tools: f.toolSet})
ctx = ai.WithRunInfo(ctx, ai.RunInfo{RunID: run.ID, ParentID: run.ParentID, Agent: f.name, Flow: f.name})
info, _ := ai.RunInfoFrom(ctx)
info.RunID = run.ID
info.ParentID = run.ParentID
info.Agent = f.name
info.Flow = f.name
ctx = ai.WithRunInfo(ctx, info)
ctx, finishSpan := f.startRunSpan(ctx, run)
var spanErr error
defer func() { finishSpan(run, spanErr) }()
@@ -426,8 +461,9 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
return run, err
}
out, attempts, err := f.runStepSpan(ctx, step, run.State)
out, attempts, verification, err := f.runStepSpan(ctx, step, run.State)
run.Steps[i].Attempts = attempts
applyVerificationRecord(&run.Steps[i], verification)
if err != nil {
spanErr = err
run.Steps[i].Status = "failed"
@@ -476,40 +512,65 @@ func (f *Flow) runFrom(ctx context.Context, run Run) (Run, error) {
// runStep runs one step, retrying on error up to the resolved retry count.
// A step with no Run function is a configuration error, and a canceled run
// stops retrying immediately rather than burning the rest of its budget.
func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, error) {
func (f *Flow) runStep(ctx context.Context, step Step, in State) (State, int, Verification, error) {
if step.Run == nil {
return in, 0, fmt.Errorf("flow: step %q has no Run function", step.Name)
return in, 0, Verification{}, fmt.Errorf("flow: step %q has no Run function", step.Name)
}
retries := f.opts.Retry
if step.Retry > 0 {
retries = step.Retry
}
var lastErr error
var lastVerification Verification
var feedback string
for attempt := 1; attempt <= retries+1; attempt++ {
// Stop the moment the run's context is canceled or its deadline
// passes — a canceled run shouldn't keep retrying, and the context
// error is surfaced so callers can detect cancellation upstream.
if err := ctx.Err(); err != nil {
return in, attempt - 1, err
return in, attempt - 1, lastVerification, err
}
attemptCtx := ctx
if info, ok := ai.RunInfoFrom(ctx); ok {
info.Step = step.Name
ctx = ai.WithRunInfo(ctx, info)
info.VerificationFeedback = feedback
attemptCtx = ai.WithRunInfo(ctx, info)
}
out, err := step.Run(attemptCtx, in)
if err == nil && step.Verify != nil {
lastVerification, err = step.Verify(attemptCtx, out)
if err == nil && !lastVerification.Passed {
err = &VerificationError{Step: step.Name, Feedback: lastVerification.Feedback}
}
}
out, err := step.Run(ctx, in)
if err == nil {
return out, attempt, nil
return out, attempt, lastVerification, nil
}
lastErr = err
if verr, ok := err.(*VerificationError); ok {
feedback = verr.Feedback
}
if attempt <= retries && f.opts.RetryBackoff > 0 {
select {
case <-time.After(f.opts.RetryBackoff):
case <-ctx.Done():
return in, attempt, ctx.Err()
return in, attempt, lastVerification, ctx.Err()
}
}
}
return in, retries + 1, lastErr
return in, retries + 1, lastVerification, lastErr
}
func applyVerificationRecord(record *StepRecord, verification Verification) {
if verification.Passed {
record.VerificationStatus = "passed"
}
if verification.Feedback != "" {
record.VerificationNote = truncate(verification.Feedback, 200)
if !verification.Passed {
record.VerificationStatus = "failed"
}
}
}
func (f *Flow) save(ctx context.Context, run Run) error {
+100
View File
@@ -0,0 +1,100 @@
package flow
import (
"context"
"errors"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
)
func TestFlowStepVerificationRetriesWithFeedback(t *testing.T) {
var attempts int
var feedback []string
step := Step{
Name: "draft",
Retry: 1,
Run: func(ctx context.Context, in State) (State, error) {
attempts++
info, ok := ai.RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from verified step")
}
feedback = append(feedback, info.VerificationFeedback)
if info.VerificationFeedback == "add evidence" {
in.Data = []byte("answer with evidence")
} else {
in.Data = []byte("answer")
}
return in, nil
},
Verify: func(ctx context.Context, out State) (Verification, error) {
if out.String() == "answer with evidence" {
return Verification{Passed: true, Feedback: "meets rubric"}, nil
}
return Verification{Feedback: "add evidence"}, nil
},
}
cp := StoreCheckpoint(store.NewMemoryStore(), "verified")
f := New("verified", WithCheckpoint(cp), Steps(step))
if err := f.Execute(context.Background(), "question"); err != nil {
t.Fatal(err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
if len(feedback) != 2 || feedback[0] != "" || feedback[1] != "add evidence" {
t.Fatalf("feedback = %#v, want empty then verifier feedback", feedback)
}
runs, err := cp.List(context.Background())
if err != nil {
t.Fatal(err)
}
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
stepRecord := runs[0].Steps[0]
if stepRecord.Status != "done" || stepRecord.Attempts != 2 || stepRecord.VerificationStatus != "passed" || stepRecord.VerificationNote != "meets rubric" {
t.Fatalf("step record = %#v", stepRecord)
}
}
func TestFlowStepVerificationFailureIsCheckpointed(t *testing.T) {
step := Step{
Name: "grade",
Run: func(ctx context.Context, in State) (State, error) {
in.Data = []byte("bad")
return in, nil
},
Verify: func(ctx context.Context, out State) (Verification, error) {
return Verification{Feedback: "missing citation"}, nil
},
}
cp := StoreCheckpoint(store.NewMemoryStore(), "verified-fail")
f := New("verified-fail", WithCheckpoint(cp), Steps(step))
err := f.Execute(context.Background(), "question")
if err == nil {
t.Fatal("Execute succeeded, want verification failure")
}
var verr *VerificationError
if !errors.As(err, &verr) {
t.Fatalf("error = %T %v, want VerificationError", err, err)
}
if verr.Feedback != "missing citation" {
t.Fatalf("feedback = %q, want missing citation", verr.Feedback)
}
runs, listErr := cp.List(context.Background())
if listErr != nil {
t.Fatal(listErr)
}
if len(runs) != 1 {
t.Fatalf("runs = %d, want 1", len(runs))
}
stepRecord := runs[0].Steps[0]
if runs[0].Status != "failed" || stepRecord.VerificationStatus != "failed" || stepRecord.VerificationNote != "missing citation" {
t.Fatalf("run = %#v step = %#v", runs[0], stepRecord)
}
}
+3 -1
View File
@@ -180,6 +180,8 @@ type Provider struct {
type Capabilities struct {
Streaming bool `json:"streaming"`
PushNotifications bool `json:"pushNotifications"`
TaskResubscribe bool `json:"taskResubscribe"`
InputRequired bool `json:"inputRequired"`
}
// Skill is a capability advertised on the Agent Card.
@@ -349,7 +351,7 @@ func Card(name, url, description string, services []string) AgentCard {
URL: url,
Version: "1.0.0",
ProtocolVersion: protocolVersion,
Capabilities: Capabilities{Streaming: true, PushNotifications: true},
Capabilities: Capabilities{Streaming: true, PushNotifications: true, TaskResubscribe: true, InputRequired: true},
DefaultInputModes: []string{"text/plain"},
DefaultOutputModes: []string{"text/plain"},
Skills: skills,
+3
View File
@@ -91,6 +91,9 @@ func TestAgentCardFromRegistry(t *testing.T) {
if card.ProtocolVersion == "" {
t.Errorf("card missing protocolVersion: %+v", card)
}
if !card.Capabilities.TaskResubscribe || !card.Capabilities.InputRequired {
t.Errorf("card capabilities = %+v, want task resubscribe and input-required advertised", card.Capabilities)
}
if got := skillIDs(card.Skills); strings.Join(got, ",") != "task,project" {
t.Errorf("skill IDs = %v, want [task project]", got)
}
+74 -1
View File
@@ -1,11 +1,13 @@
package a2a
import (
"bufio"
"bytes"
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
@@ -135,6 +137,77 @@ func (c *Client) SendMessage(ctx context.Context, message Message) (*Task, error
return &task, nil
}
// Resubscribe reconnects to a retained or active task stream and returns task
// snapshots as the remote agent emits updates. The returned channel is closed
// when the task reaches a terminal state or ctx is canceled.
func (c *Client) Resubscribe(ctx context.Context, taskID string) (<-chan Task, <-chan error) {
tasks := make(chan Task, 8)
errs := make(chan error, 1)
go func() {
defer close(tasks)
defer close(errs)
body, _ := json.Marshal(map[string]any{
"jsonrpc": "2.0",
"id": uuid.New().String(),
"method": "tasks/resubscribe",
"params": getParams{ID: taskID},
})
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.url, bytes.NewReader(body))
if err != nil {
errs <- err
return
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "text/event-stream")
resp, err := c.http.Do(req)
if err != nil {
errs <- err
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
errs <- fmt.Errorf("tasks/resubscribe: status %d", resp.StatusCode)
return
}
scanner := bufio.NewScanner(resp.Body)
for scanner.Scan() {
line := scanner.Text()
if !strings.HasPrefix(line, "data:") {
continue
}
payload := strings.TrimSpace(strings.TrimPrefix(line, "data:"))
if payload == "" {
continue
}
var out struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
}
if err := json.Unmarshal([]byte(payload), &out); err != nil {
errs <- err
return
}
if out.Error != nil {
errs <- fmt.Errorf("a2a tasks/resubscribe: %s (%d)", out.Error.Message, out.Error.Code)
return
}
select {
case <-ctx.Done():
errs <- ctx.Err()
return
case tasks <- out.Result:
}
if terminal(out.Result.Status.State) {
return
}
}
if err := scanner.Err(); err != nil {
errs <- err
}
}()
return tasks, errs
}
// SetPushNotificationConfig asks the remote agent to POST updates for taskID to cfg.URL.
func (c *Client) SetPushNotificationConfig(ctx context.Context, taskID string, cfg PushNotificationConfig) error {
_, err := c.call(ctx, "tasks/pushNotificationConfig/set", pushConfigParams{
@@ -193,7 +266,7 @@ func (c *Client) call(ctx context.Context, method string, params any) (json.RawM
func terminal(state string) bool {
switch state {
case "completed", "failed", "canceled", "rejected":
case "completed", "failed", "canceled", "rejected", "input-required":
return true
}
return false
+56
View File
@@ -3,8 +3,10 @@ package a2a
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"strings"
"testing"
"time"
)
@@ -110,3 +112,57 @@ func TestClientContinuesTaskAndConfiguresPush(t *testing.T) {
t.Fatal("timed out waiting for push update")
}
}
func TestClientResubscribeStreamsRetainedAndLiveTask(t *testing.T) {
d := newDispatcher()
initial := &Task{ID: "task-1", ContextID: "ctx-1", Kind: "task", Status: TaskStatus{State: stateWorking, Timestamp: time.Now().UTC().Format(time.RFC3339)}}
d.store(initial)
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
d.serve(w, r, func(context.Context, string) (string, error) { return "", nil })
}))
defer ts.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
tasks, errs := NewClient(ts.URL).Resubscribe(ctx, initial.ID)
first := <-tasks
if first.ID != initial.ID || first.Status.State != stateWorking {
t.Fatalf("first resubscribe task = %+v, want retained working task", first)
}
final := &Task{ID: initial.ID, ContextID: initial.ContextID, Kind: "task", Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)}, Artifacts: []Artifact{textArtifact("done")}}
d.store(final)
second := <-tasks
if second.ID != final.ID || second.Status.State != stateCompleted || textOf(second.Artifacts[0].Parts) != "done" {
t.Fatalf("second resubscribe task = %+v, want live completed task", second)
}
if _, ok := <-tasks; ok {
t.Fatal("resubscribe task channel stayed open after terminal update")
}
select {
case err := <-errs:
if err != nil {
t.Fatalf("resubscribe error = %v", err)
}
default:
}
}
func TestClientSendMessageReturnsInputRequiredTask(t *testing.T) {
card := Card("solo", "http://localhost:4000", "", []string{"task"})
h := NewAgentHandler(card, func(context.Context, string) (string, error) {
return "", errors.New("input-required: provide approval code")
})
ts := httptest.NewServer(h)
defer ts.Close()
ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second)
defer cancel()
task, err := NewClient(ts.URL).SendMessage(ctx, Message{Parts: []Part{{Kind: "text", Text: "approve?"}}})
if err != nil {
t.Fatalf("SendMessage: %v", err)
}
if task.Status.State != stateInputRequired || !strings.Contains(textOf(task.Artifacts[0].Parts), "provide approval code") {
t.Fatalf("task = %+v, want input-required handoff", task)
}
}
+12
View File
@@ -29,6 +29,18 @@ consistent across providers.
The companion `TestAgentProviderConformanceFakeError` keeps provider error
propagation covered locally without relying on external credentials.
## Local no-secret conformance
Use `make provider-conformance-mock` to run the same provider conformance harness
through the deterministic mock provider. That target requires no API keys and is
what `make harness` delegates to after the 0→1 and 0→hero scenarios, so every PR
continues to exercise the provider-facing agent/tool contract without spending
live model credits.
Use `make provider-conformance` when you want the live-provider sweep: providers
without keys are skipped, and configured providers must satisfy the same harness
contract.
## Scheduled CI
The daily/manual `Harness (E2E)` workflow runs the same matrix with
+48 -7
View File
@@ -10,6 +10,26 @@ 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,
@@ -72,7 +92,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/continuous-improvement.yml` and the mechanics
comments. See `.github/workflows/loop-builder.yml` and the mechanics
below.
## How the durable loop works (mechanics)
@@ -127,13 +147,20 @@ 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/devrel-review.yml`). Audits the public
- **DevRel — daily** (`.github/workflows/loop-devrel.yml`). Audits the public
surface (README, website landing + docs, blog) for coherence with the North
Star, README crispness, and blog-worthy material. **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`).
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`).
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
@@ -154,6 +181,20 @@ 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).
+7 -3
View File
@@ -21,9 +21,13 @@ changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Support A2A task resubscribe and input-required handoffs** ([#3474](https://github.com/micro/go-micro/issues/3474)) — the earlier Now/Next hardening seams are closed for this pass: cross-provider dispatch, failure classification, checkpoint/resume trace coverage, RunInfo spans, end-to-end A2A streaming, and memory summarization/retrieval hooks have shipped. The highest-value remaining roadmap gap is live-operation continuity over A2A: remote agents must be able to reconnect to task streams and handle explicit `input-required` pauses so Go Micro agents remain dependable neighbours over open protocols.
2. **Add a CI-verifiable agent verification loop** ([#3481](https://github.com/micro/go-micro/issues/3481)) — blog/32 makes the next strategic frontier explicit: scheduled, looping, work-performing agents need a verification/grader loop around the agent loop. This ranks just behind the open A2A continuity issue because it is an internal operational-harness primitive rather than a named roadmap item, but it is the next cohesive step after RunInfo spans, failure semantics, and memory: grade outputs, route feedback through existing retry/supervision paths, and make dependable agent work observable without drifting into a graph DSL.
1. **Connect the website quickstart next steps to the first-agent on-ramp** ([#3648](https://github.com/micro/go-micro/issues/3648)) — #3644 shipped the README/docs-nav discovery work from #3640, but the standalone website quickstart still drops new developers from a service smoke test into generic next steps. Keep adoption at the top by making the default 0→1 page hand off directly to Your First Agent, debugging/inspect, and 0→hero so the services → agents → workflows path stays walkable.
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.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
+6 -3
View File
@@ -24,6 +24,7 @@ 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"
)
@@ -95,7 +96,7 @@ func main() {
reg := registry.NewMemoryRegistry()
st := store.NewMemoryStore()
var sawTool, sawRunInfo bool
ag := agent.New(
agentOpts := []agent.Option{
agent.Name("a2a-fallback"),
agent.Provider(*provider),
agent.APIKey(apiKey),
@@ -103,7 +104,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) {
@@ -118,7 +119,9 @@ 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) {
+8 -5
View File
@@ -25,10 +25,9 @@ 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"
)
@@ -203,8 +202,9 @@ func main() {
fmt.Println("broker connect:", err)
os.Exit(1)
}
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
cl := harnessutil.Client(*provider, 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.
onboarder := agent.New(
onboarderOpts := []agent.Option{
agent.Name("onboarder"),
agent.Address("127.0.0.1:0"),
agent.Services("workspace", "notify"),
@@ -225,7 +225,9 @@ 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()
@@ -238,6 +240,7 @@ 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)
@@ -0,0 +1,61 @@
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
}
@@ -0,0 +1,33 @@
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)
}
}
+61
View File
@@ -0,0 +1,61 @@
#!/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"
+16 -7
View File
@@ -26,10 +26,9 @@ 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"
)
@@ -241,8 +240,11 @@ func runPlanDelegate(provider string) error {
fmt.Print("Real services, registry, RPC, agent loop, store, delegation.\n\n")
reg := registry.NewMemoryRegistry()
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
cl := harnessutil.Client(provider, 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)
@@ -260,26 +262,32 @@ func runPlanDelegate(provider string) error {
go notify.Run()
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
comms := agent.New(
commsOpts := []agent.Option{
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.
conductor := agent.New(
conductorOpts := []agent.Option{
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()
@@ -303,6 +311,7 @@ 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)
@@ -20,7 +20,11 @@ agent test and the harnesses in `internal/harness`:
preserving run metadata.
The command also emits the registered provider capability matrix so the run shows
which providers advertise model, image, video, and streaming support.
which providers advertise model, image, video, and streaming support. Console output
and summary artifacts label each harness with the phase it is proving (for example,
model call + tool call, workflow event + tool call, or streaming fallback + tool
call), so provider failures identify the failed lifecycle phase instead of only the
provider name.
## Local usage
+28 -7
View File
@@ -36,6 +36,14 @@ import (
const defaultHarnesses = "agent,universe,agent-flow,plan-delegate,a2a-stream-fallback"
var harnessPhases = map[string]string{
"agent": "model call + tool call",
"universe": "service discovery + tool call",
"agent-flow": "workflow event + tool call",
"plan-delegate": "plan persistence + delegation + tool call",
"a2a-stream-fallback": "streaming fallback + tool call",
}
var providerEnv = map[string]string{
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
@@ -92,15 +100,16 @@ func main() {
}
for _, harness := range harnesses {
fmt.Printf("\n==> %s / %s\n", provider, harness)
phase := harnessPhase(harness)
fmt.Printf("\n==> %s / %s (%s)\n", provider, harness, phase)
if err := runHarness(provider, harness, *timeoutFlag); err != nil {
fmt.Printf("FAIL %s / %s: %v\n", provider, harness, err)
fmt.Printf("FAIL %s / %s (%s): %v\n", provider, harness, phase, err)
failed++
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusFailed, Error: err.Error()})
results = append(results, conformanceResult{Provider: provider, Harness: harness, Phase: phase, Status: statusFailed, Error: err.Error()})
continue
}
ran++
results = append(results, conformanceResult{Provider: provider, Harness: harness, Status: statusPassed})
results = append(results, conformanceResult{Provider: provider, Harness: harness, Phase: phase, Status: statusPassed})
}
}
@@ -140,6 +149,7 @@ const (
type conformanceResult struct {
Provider string `json:"provider"`
Harness string `json:"harness,omitempty"`
Phase string `json:"phase,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
@@ -176,14 +186,18 @@ func writeSummaryMarkdown(path string, summary conformanceSummary) error {
b.WriteString("## Capability matrix\n\n")
b.WriteString(capabilityMarkdown(summary.Capabilities))
b.WriteString("\n## Harness results\n\n")
b.WriteString("| Provider | Harness | Status | Detail |\n")
b.WriteString("| --- | --- | --- | --- |\n")
b.WriteString("| Provider | Harness | Phase | Status | Detail |\n")
b.WriteString("| --- | --- | --- | --- | --- |\n")
for _, result := range summary.Results {
harness := result.Harness
if harness == "" {
harness = "—"
}
fmt.Fprintf(&b, "| %s | %s | %s | %s |\n", result.Provider, harness, result.Status, markdownCell(result.Error))
phase := result.Phase
if phase == "" {
phase = "—"
}
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s |\n", result.Provider, harness, markdownCell(phase), result.Status, markdownCell(result.Error))
}
return os.WriteFile(path, []byte(b.String()), 0o644)
}
@@ -209,6 +223,13 @@ func markdownList(values []string) string {
return strings.Join(escaped, ", ")
}
func harnessPhase(harness string) string {
if phase, ok := harnessPhases[harness]; ok {
return phase
}
return "harness"
}
func markdownCell(s string) string {
if s == "" {
return "—"
@@ -99,7 +99,7 @@ func TestWriteSummaryMarkdown(t *testing.T) {
summary := conformanceSummary{
Capabilities: []ai.CapabilityRow{{Provider: "mock", Capabilities: ai.Capabilities{Model: true}}},
Results: []conformanceResult{
{Provider: "mock", Harness: "agent-flow", Status: statusPassed},
{Provider: "mock", Harness: "agent-flow", Phase: harnessPhase("agent-flow"), Status: statusPassed},
{Provider: "live", Status: statusSkipped, Error: "missing | key"},
},
Passed: 1,
@@ -120,8 +120,8 @@ func TestWriteSummaryMarkdown(t *testing.T) {
"Providers: —.",
"Harnesses: —.",
"| mock | ✅ | — | — | — |",
"| mock | agent-flow | passed | — |",
"| live | — | skipped | missing \\| key |",
"| mock | agent-flow | workflow event + tool call | passed | — |",
"| live | — | — | skipped | missing \\| key |",
} {
if !strings.Contains(got, want) {
t.Fatalf("summary markdown = %q, want %q", got, want)
@@ -129,6 +129,15 @@ func TestWriteSummaryMarkdown(t *testing.T) {
}
}
func TestHarnessPhaseLabelsKnownHarnesses(t *testing.T) {
if got := harnessPhase("a2a-stream-fallback"); got != "streaming fallback + tool call" {
t.Fatalf("harnessPhase() = %q, want streaming fallback phase", got)
}
if got := harnessPhase("custom"); got != "harness" {
t.Fatalf("harnessPhase(custom) = %q, want fallback phase", got)
}
}
func TestMarkdownListEscapesBackticks(t *testing.T) {
got := markdownList([]string{"agent", "bad`name"})
want := "`agent`, `bad\\`name`"
+58 -8
View File
@@ -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,17 +108,63 @@ type SendResponse struct {
Sent bool `json:"sent"`
}
type Notify struct{ sent int64 }
type Notify struct {
mu sync.Mutex
sent int64
seen map[string]struct{}
}
// Send delivers a notification.
// @example {"to": "buyer@acme.com", "message": "Your order is confirmed"}
func (s *Notify) Send(_ context.Context, req *SendRequest, rsp *SendResponse) error {
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.
// ---------------------------------------------------------------------------
@@ -229,8 +275,9 @@ func runUniverse(provider string) int {
fmt.Println("broker connect:", err)
return 2
}
cl := client.NewClient(client.Registry(reg), client.Selector(selector.NewSelector(selector.Registry(reg))))
cl := harnessutil.Client(provider, reg)
st := store.NewMemoryStore()
liveAgentOpts := harnessutil.AgentOptions(provider)
// Services.
inv, pay, ord, ntf := new(Inventory), new(Payment), new(Orders), new(Notify)
@@ -243,7 +290,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
concierge := agent.New(
conciergeOpts := []agent.Option{
agent.Name("concierge"),
agent.Services("notify"),
agent.Prompt("You notify buyers when their order is confirmed."),
@@ -257,7 +304,9 @@ 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()
@@ -268,11 +317,12 @@ 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: flow.Dispatch("concierge")},
flow.Step{Name: "notify", Run: dispatchNotifyStep("concierge", ntf)},
),
)
if err := checkout.Register(reg, br, cl); err != nil {
@@ -328,7 +378,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 by the concierge agent")
check(atomic.LoadInt64(&ntf.sent) == 1, "buyer notified exactly once by the concierge agent")
check(atomic.LoadInt64(&wrapped) >= 1, "agent tool-execution wrapper observed the call")
if pend, _ := checkout.Pending(ctx); true {
+50
View File
@@ -1,7 +1,13 @@
package main
import (
"context"
"errors"
"sync/atomic"
"testing"
"time"
"go-micro.dev/v6/flow"
)
// TestUniverseHarnessContract makes the 0→hero harness part of the ordinary
@@ -18,3 +24,47 @@ 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)
}
}
+14 -9
View File
@@ -4,14 +4,17 @@ 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 four boundaries together:
`run.sh` verifies five boundaries together:
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>`
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>`
remain available as the local run-history inspection step, with `micro flow
runs` preserving durable workflow history inspection.
4. **Deploy**`micro deploy --dry-run <target>` remains available as the
5. **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
@@ -24,15 +27,17 @@ 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 0→1 scaffold contract. Developers can run the same no-secret
contract locally with:
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:
```sh
make harness
```
That target intentionally exercises both 0→1 scaffold variants, the 0→hero
scenario, the event-driven agent-flow harness, and mock provider conformance, so
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
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).
@@ -0,0 +1,83 @@
package zerotoheroci
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestZeroToHeroReferenceDocs(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
guide := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "zero-to-hero.md"))
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",
"internal/harness/plan-delegate",
"internal/harness/universe",
} {
if !strings.Contains(guide, want) {
t.Fatalf("0→hero guide missing %q", want)
}
}
readme := readFile(t, filepath.Join(root, "README.md"))
if !strings.Contains(readme, "internal/website/docs/guides/zero-to-hero.md") {
t.Fatal("README does not point to the canonical 0→hero guide")
}
nav := readFile(t, filepath.Join(root, "internal", "website", "_data", "navigation.yml"))
if !strings.Contains(nav, "0→hero Reference") || !strings.Contains(nav, "/docs/guides/zero-to-hero.html") {
t.Fatal("website navigation does not expose the canonical 0→hero guide")
}
}
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)
if err != nil {
t.Fatalf("read %s: %v", name, err)
}
return string(data)
}
+5 -2
View File
@@ -6,9 +6,12 @@ cd "$ROOT"
# Keep the developer inner-loop boundaries executable and discoverable in CI
# without secrets or long-running daemons.
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|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.
# 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
go test ./internal/harness/universe ./internal/harness/plan-delegate -run 'Test.*Harness|TestPlanDelegateEndToEnd|TestPlanDelegateFlowHandoff' -count=1
+11 -4
View File
@@ -23,8 +23,12 @@ case $OS in
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
# Determine install directory
if [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
# 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
INSTALL_DIR="/usr/local/bin"
else
INSTALL_DIR="$HOME/.local/bin"
@@ -44,8 +48,11 @@ fi
TMP_DIR=$(mktemp -d)
TMP_FILE="${TMP_DIR}/micro.tar.gz"
# Download
if command -v curl &> /dev/null; then
# 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
curl -fsSL "$URL" -o "$TMP_FILE"
elif command -v wget &> /dev/null; then
wget -q "$URL" -O "$TMP_FILE"
+21 -10
View File
@@ -5,6 +5,10 @@ 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
@@ -32,26 +36,30 @@ examples:
- title: Real-World Examples
url: /docs/examples/realworld/
guides:
- 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: Debugging your agent
url: /docs/guides/debugging-agents.html
- title: Plan & Delegate
url: /docs/guides/plan-delegate.html
- title: Agent Guardrails
url: /docs/guides/agent-guardrails.html
- title: Payments (x402)
url: /docs/guides/x402-payments.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: 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
@@ -70,6 +78,9 @@ 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,4 +1,5 @@
<a href="/docs/">Docs</a> &middot;
<a href="/blog/">Blog</a> &middot;
<a href="https://github.com/micro/go-micro">GitHub</a> &middot;
<a href="https://discord.gg/G8Gk5j3uXr">Discord</a> &middot;
<a href="/support">Support</a>
@@ -1,4 +1,5 @@
<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>
+1 -1
View File
@@ -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/WeMU5AGxD), 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/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+1 -1
View File
@@ -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/WeMU5AGxD), 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/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+1 -1
View File
@@ -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/WeMU5AGxD), 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/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+5 -5
View File
@@ -135,8 +135,8 @@ The store backend determines durability — file-backed by default, Postgres or
| **What** | Capability | Intelligence | Event orchestration |
| **Does** | Handles requests | Manages services | Reacts to events |
| **Knows** | Its endpoints | Its services' endpoints | Its trigger topic |
| **State** | Store | Store (memory) | Stateless per event |
| **Create** | `micro.NewService()` | `micro.NewAgent()` | `micro.NewFlow()` |
| **State** | Store | Store-backed memory | Checkpointed run history |
| **Create** | `micro.NewService("name")` | `micro.NewAgent("name")` | `micro.NewFlow("name")` |
| **Package** | `service/` | `agent/` | `flow/` |
They compose:
@@ -164,7 +164,7 @@ Or build an agent in Go:
```go
package main
import "go-micro.dev/v5"
import "go-micro.dev/v6"
func main() {
agent := micro.NewAgent("task-mgr",
@@ -176,8 +176,8 @@ func main() {
}
```
The agent package is at `go-micro.dev/v5/agent`. The full interface design is documented in [AGENT_DESIGN.md](https://github.com/micro/go-micro/blob/master/internal/docs/AGENT_DESIGN.md).
The agent implementation lives under `go-micro.dev/v6/agent`; most users create agents through the top-level `go-micro.dev/v6` API. The full interface design is documented in [AGENT_DESIGN.md](https://github.com/micro/go-micro/blob/master/internal/docs/AGENT_DESIGN.md).
---
*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).*
*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).*
+1 -1
View File
@@ -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/WeMU5AGxD), 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/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+1 -1
View File
@@ -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/WeMU5AGxD), 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/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+1 -1
View File
@@ -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/WeMU5AGxD) to discuss the future of the project.
Thanks to Anthropic for the sponsorship. Join us in a new [Discord](https://discord.gg/G8Gk5j3uXr) to discuss the future of the project.
+1 -1
View File
@@ -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/WeMU5AGxD) to discuss further.
Thanks to Anthropic for the grant and the sponsorship. Join me in the [Discord](https://discord.gg/G8Gk5j3uXr) to discuss further.
+37 -7
View File
@@ -4,14 +4,44 @@ layout: default
# Learn by Example
A collection of small, focused examples demonstrating common patterns with Go Micro.
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.
- [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)
## 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.
## More
+27 -3
View File
@@ -19,6 +19,14 @@ 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
@@ -26,13 +34,21 @@ Go Micro has three core abstractions:
curl -fsSL https://go-micro.dev/install.sh | sh
# Or with Go
go install go-micro.dev/v6/cmd/micro@v6
go install go-micro.dev/v6/cmd/micro@latest
```
> 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
@@ -70,6 +86,10 @@ 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:
@@ -136,6 +156,8 @@ 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
@@ -219,6 +241,8 @@ 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
@@ -31,7 +31,7 @@ your stack — the harness *is* the stack.
| Tools | Every service endpoint is an MCP-callable tool from registry metadata — no extra code | Shipped |
| Memory | Store-backed agent memory (`AgentMemory`), durable across restarts | Shipped |
| Guardrails | `MaxSteps`, `LoopLimit`, `ApproveTool`, tool wrappers — enforced at the call site | Shipped |
| Workflows | Durable flows; `flow.Loop` for run-until-done | Shipped |
| Workflows | Durable flows; `micro.FlowLoop` for run-until-done | Shipped |
| Planning / delegation | Built-in `plan` and `delegate` tools on every agent | Shipped |
| Discovery & RPC | Registry + client; agents and services find and call each other | Shipped |
| Interop | MCP (tools), A2A (agents), x402 (paid tools) | Shipped |
+2 -2
View File
@@ -18,11 +18,11 @@ you're done") has no natural ceiling. So a usable loop needs two things:
1. a **stop condition** — how it decides it's done, and
2. a **hard cap** — a guardrail that guarantees it always terminates.
Go Micro gives you both as a flow step: `flow.Loop`.
Go Micro gives you both as a flow step: `micro.FlowLoop`.
## The shape
`flow.Loop` is a `StepFunc`, so it drops into a flow's ordered, checkpointed
`micro.FlowLoop` is a `StepFunc`, so it drops into a flow's ordered, checkpointed
step list like any other step. It runs a **body** step repeatedly, carrying the
flow `State` from one pass to the next, until a stop condition fires or the
iteration cap is hit — whichever comes first.
@@ -89,14 +89,32 @@ Agents use store-backed conversation memory by default, scoped under the agent's
name. That makes short restarts boring: the next `Ask` reloads the retained
history from the same store backend you already use for services and flows.
Long-running agents can also keep model context bounded without losing useful
prior context:
prior context. If you want retrieval without summaries, enable bounded active
context plus a durable archive of every turn:
```go
a := micro.NewAgent("conductor",
micro.AgentServices("task"),
micro.AgentProvider("anthropic"),
micro.AgentRetrievalMemory(40), // active messages kept in prompt context
micro.AgentMemoryRecallLimit(5), // archived turns recalled per Ask
)
```
`AgentRetrievalMemory(activeLimit)` switches the default memory to a store-backed
retriever. The active conversation is capped at `activeLimit`, every turn is
archived in the same scoped store used by the agent, and future asks inject
matching archived turns ahead of active context. The built-in ranking is
deterministic and credential-free for CI.
When you also want a rolling summary in active context, use compacting memory:
```go
a := micro.NewAgent("conductor",
micro.AgentServices("task"),
micro.AgentProvider("anthropic"),
micro.AgentCompactMemory(40, 12), // max active messages, recent messages kept verbatim
micro.AgentMemoryRecallLimit(5), // archived turns recalled per Ask
micro.AgentMemoryRecallLimit(5), // compacted turns recalled per Ask
)
```
@@ -105,8 +123,7 @@ deterministic compactor. Once active history grows past `maxMessages`, older
turns move into the durable archive, a provider-neutral summary is injected into
active context, and the newest `keepRecent` messages stay verbatim. On future
asks, archived turns whose text matches the current request are recalled ahead of
the active context. The built-in retrieval is intentionally simple and
credential-free for CI; teams that need embeddings or a vector database can still
the active context. Teams that need embeddings or a vector database can still
provide their own `AgentMemory` implementation.
This is harness memory, not prompt-layer orchestration: services remain the
@@ -17,7 +17,7 @@ A **task management service** with full CRUD operations that:
## Prerequisites
```bash
go install go-micro.dev/v6/cmd/micro@v6
go install go-micro.dev/v6/cmd/micro@latest
```
## Step 1: Create the Service
@@ -43,6 +43,7 @@ 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 |
+1 -1
View File
@@ -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/WeMU5AGxD](https://discord.gg/WeMU5AGxD)
- **Discord**: [discord.gg/G8Gk5j3uXr](https://discord.gg/G8Gk5j3uXr)
- **Docs**: [go-micro.dev/docs](https://go-micro.dev/docs)
@@ -0,0 +1,235 @@
---
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@v6
go install go-micro.dev/v6/cmd/protoc-gen-micro@latest
# Generate Go code
protoc --proto_path=. \
@@ -93,8 +93,6 @@ 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@v6
go install go-micro.dev/v6/cmd/protoc-gen-micro@latest
# Generate both gRPC and Go Micro code
protoc --proto_path=. \
@@ -100,7 +100,6 @@ 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@v6
go install go-micro.dev/v6/cmd/micro@latest
```
## 2. TLS is verified by default
@@ -14,6 +14,7 @@ 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"
)
@@ -0,0 +1,227 @@
---
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.
@@ -0,0 +1,110 @@
---
layout: default
---
# 0→hero reference path
The 0→hero path is the maintained, no-secret reference for the Go Micro
services → agents → workflows lifecycle. It ties the CLI inner loop and the
runtime harness together so a contributor can prove the framework still works as
one system, not as separate demos.
Use it when you want to answer: "Can I scaffold a service, run it locally, talk
to an agent, inspect durable work, and reach the deployment boundary without
cloud credentials?"
## What the contract covers
| 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.
## Run the whole no-secret path
From the repository root:
```sh
make harness
```
That target runs the scaffold contract, the CLI boundary smoke tests, the
0→hero runtime harnesses, the event-driven agent-flow harness, and mock provider
conformance. It is intentionally deterministic: no provider key, cloud account,
SSH access, or remote service is required.
## Run focused checks while iterating
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
# Event-as-prompt agent flow.
go run ./internal/harness/agent-flow
# Cross-provider semantics with the deterministic mock provider.
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.
- [`internal/harness/universe`](https://github.com/micro/go-micro/tree/master/internal/harness/universe)
boots a larger mini-world: inventory, payment, order confirmation, a concierge
agent, durable checkpoint/resume, agent run history, flow run history, and A2A
reachability.
- [`internal/harness/agent-flow`](https://github.com/micro/go-micro/tree/master/internal/harness/agent-flow)
shows the event-driven path where a `user.created` event prompts an agent to
call services and complete onboarding.
Together these scenarios keep the North Star executable: services expose typed
capabilities, agents use those capabilities with memory and guardrails, and
workflows compose the work over time.
## Keeping the guide honest
If you change the CLI inner loop, durable flow APIs, agent run history, or the
provider/tool semantics, update this guide and the harness in the same PR. The
point of 0→hero is not a polished sample app that drifts from reality; it is a
CI-verifiable contract that the documented lifecycle still works.
+7 -3
View File
@@ -16,13 +16,15 @@ It's built on a pluggable architecture of Go interfaces: service discovery, clie
## Learn More
To get started follow the getting started guide.
Otherwise continue to read the docs for more information
about the framework.
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.
## 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)
@@ -44,6 +46,8 @@ 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
+2 -4
View File
@@ -13,11 +13,9 @@ 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@v6
go install go-micro.dev/v6/cmd/micro@latest
```
> **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
@@ -95,7 +93,7 @@ publisher.Publish(ctx, &UserCreatedEvent{
## Get Help
- **[Discord Community](https://discord.gg/WeMU5AGxD)** - Chat with other users
- **[Discord Community](https://discord.gg/G8Gk5j3uXr)** - 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
+1 -1
View File
@@ -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/WeMU5AGxD).
Open an issue or start a discussion on [GitHub](https://github.com/micro/go-micro), or join the [Discord](https://discord.gg/G8Gk5j3uXr).
+1 -3
View File
@@ -28,11 +28,9 @@ 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@v6
go install go-micro.dev/v6/cmd/micro@latest
```
> **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:
+2 -1
View File
@@ -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/WeMU5AGxD" class="btn btn-secondary">Become a sponsor</a>
<a href="https://discord.gg/G8Gk5j3uXr" class="btn btn-secondary">Become a sponsor</a>
<a href="/support" class="btn btn-primary">Commercial support</a>
</div>
</section>
@@ -283,6 +283,7 @@
<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>

Some files were not shown because too many files have changed in this diff Show More