Compare commits

..

252 Commits

Author SHA1 Message Date
Asim Aslam d40d52b891 ai/openai: surface token usage on streams
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
Request stream_options.include_usage and return the final usage chunk
as a Response with Usage set, so streaming callers can record usage.
2026-06-28 23:13:22 +01:00
Asim Aslam eefc30c569 ai/atlascloud: surface token usage on streams
Request stream_options.include_usage and return the final usage chunk
as a Response with Usage set, so streaming callers can record usage.
2026-06-28 23:13:21 +01:00
Asim Aslam f8d8cb39a7 Document provider conformance scheduling (#3303)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 22:55:49 +01:00
Asim Aslam 87a0c0c93b ai/atlascloud: implement streaming (#3301)
goreleaser / goreleaser (push) Waiting to run
Atlas exposes an OpenAI-compatible SSE chat endpoint; emit content
deltas as they arrive instead of returning ErrStreamingUnsupported.
2026-06-28 22:42:03 +01:00
Copilot f31a7acf0b fix: gofmt formatting in ai/anthropic/anthropic.go (#3299)
* Initial plan

* fix: remove extra blank line in ai/anthropic/anthropic.go (gofmt)

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-28 22:38:32 +01:00
Asim Aslam 4279a5eed3 ai: add WithMaxTokens option (#3300)
goreleaser / goreleaser (push) Waiting to run
* ai: add WithMaxTokens option

Let callers cap response length; providers send max_tokens when set
(anthropic keeps its 8192 default otherwise).

* ai: add WithMaxTokens option

Let callers cap response length; providers send max_tokens when set
(anthropic keeps its 8192 default otherwise).

* ai: add WithMaxTokens option

Let callers cap response length; providers send max_tokens when set
(anthropic keeps its 8192 default otherwise).

* ai: add WithMaxTokens option

Let callers cap response length; providers send max_tokens when set
(anthropic keeps its 8192 default otherwise).
2026-06-28 22:37:50 +01:00
Asim Aslam c39a846d3b Update architecture priorities (#3298)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 22:31:33 +01:00
Asim Aslam b707c7e305 ai: thread Request.Messages into provider requests (#3293)
goreleaser / goreleaser (push) Waiting to run
* ai/atlascloud: thread Request.Messages into the request

Fold conversation history (req.Messages) between the system prompt and
the final user prompt so multi-turn context reaches the model.

Refs #3292

* ai/openai: thread Request.Messages into the request

Fold conversation history (req.Messages) between the system prompt and
the final user prompt so multi-turn context reaches the model.

Refs #3292

* ai/anthropic: thread Request.Messages into the request

Fold conversation history (req.Messages) between the system prompt and
the final user prompt so multi-turn context reaches the model.

Refs #3292
2026-06-28 22:19:59 +01:00
Asim Aslam 5bfc37708d Add support example smoke test (#3291)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 21:58:56 +01:00
Asim Aslam 4d852c5e36 Update architect priorities after deploy contract (#3289)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 21:32:15 +01:00
Asim Aslam f7a3e8461e test deploy inner-loop contract (#3287)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 21:07:20 +01:00
Asim Aslam 87eb540a04 Update architect priorities for DX gaps (#3285)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 20:35:29 +01:00
Asim Aslam 48e05385c7 Add agent human input pause resume (#3281)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 20:04:25 +01:00
Asim Aslam cb10149f54 Update architect priorities after memory work (#3279)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 19:24:54 +01:00
Asim Aslam 66438697b3 Improve compacting memory recall ranking (#3276)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 19:03:39 +01:00
Asim Aslam 6e1c11ca74 Update architect priorities after A2A handoffs (#3274)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 18:33:59 +01:00
Asim Aslam 44c08a6bd2 Add A2A resubscribe and input-required handoffs (#3271)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 17:59:49 +01:00
Asim Aslam 9c01202599 Update architect priorities after OTel spans (#3269)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 17:32:36 +01:00
Asim Aslam b4cfe51115 Emit agent run events on OTel spans (#3267)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 17:04:26 +01:00
Asim Aslam 2221834707 Update architect priorities after streaming conformance (#3265)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 16:33:06 +01:00
Asim Aslam 5813f53117 test ai streaming conformance (#3263)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 16:17:23 +01:00
Asim Aslam 72e6161b02 Update architect priorities after agent checkpointing (#3261)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 15:41:44 +01:00
Asim Aslam 1789819c39 Add agent tool checkpoint replay protection (#3259)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 15:22:11 +01:00
Asim Aslam 8676dcacf2 Update architect priority queue after 0-to-hero (#3257)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 14:38:08 +01:00
Asim Aslam 22b3b9ca22 Add zero-to-hero CI reference scenario (#3252)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 14:09:17 +01:00
Asim Aslam 4ca8948ba4 Update architect priorities after 0-to-1 verification (#3250)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 13:34:18 +01:00
Asim Aslam 28cbf0be7e test: verify scaffolded service run and call contract (#3248)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 13:14:22 +01:00
Asim Aslam e8e6cb1210 Update architect priorities after conformance (#3246)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 12:40:00 +01:00
Asim Aslam 2a06a043bd ci: run live agent provider conformance (#3244)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 12:23:04 +01:00
Asim Aslam 6eef58f815 Update architect priorities (#3242)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 11:13:36 +01:00
Asim Aslam 6da799d64f Classify AI retry failures (#3238)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 10:42:37 +01:00
Asim Aslam 220b370641 Update architect priorities (#3236)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 10:08:20 +01:00
Asim Aslam ac2163ef49 docs: align public TLS and A2A status (#3231)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 09:20:26 +01:00
Asim Aslam 9d6814f5ae docs: refresh roadmap after A2A task support (#3229)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 08:55:36 +01:00
Asim Aslam 929cd1ab1d Add A2A task continuation and push callbacks (#3226)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 07:09:03 +01:00
Asim Aslam fe651b4d01 Update architect priorities after x402 (#3224)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 06:35:10 +01:00
Asim Aslam 05ee980bf5 Harden x402 spend caps (#3222)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 04:54:15 +01:00
Asim Aslam e1d19c4cb5 Update architect priorities after memory and HITL (#3220)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 04:08:37 +01:00
Asim Aslam 61f873174c Add durable approval pause for agents (#3218)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 03:37:50 +01:00
Asim Aslam 71581e9a61 Add agent memory compaction controls (#3216)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 02:39:06 +01:00
Asim Aslam 892b4f185d Update architect priority queue after lifecycle hooks (#3213)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 01:30:09 +01:00
Asim Aslam 30f53e42a7 Expose model retry attempt metadata (#3207)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 00:56:21 +01:00
Asim Aslam 016eb82a32 Update architect priorities after streaming (#3205)
Co-authored-by: Codex <codex@openai.com>
2026-06-28 00:29:03 +01:00
Asim Aslam a576f65b08 Improve A2A streaming task lifecycle (#3203)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 23:57:24 +01:00
Asim Aslam 07f0d30aea Update architect priority queue after observability (#3201)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 23:26:33 +01:00
Asim Aslam 3db9617f43 Add agent trace provider helper (#3198)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 23:02:09 +01:00
Asim Aslam c19e4829ba Update architect priority queue (#3196)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 22:28:28 +01:00
Asim Aslam f37ed42bf3 Add registry service readiness check (#3194)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 21:56:22 +01:00
Asim Aslam 114cd7e19e Update architect priorities after conformance (#3192)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 21:30:21 +01:00
Asim Aslam 28fc2ea340 Add agent provider conformance matrix (#3190)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 21:08:04 +01:00
Asim Aslam 38f5c13e65 Update architect priority queue (#3188)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 20:37:32 +01:00
Asim Aslam 27f5e2be52 Add OpenAI streaming path (#3185)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 20:01:45 +01:00
Asim Aslam 1fa7584be8 Update architect priority queue (#3183)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 19:34:11 +01:00
Asim Aslam e8c632456a Add checkpointed agent run resume (#3180)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 19:00:18 +01:00
Asim Aslam 9a410cbe8b Update architect priority queue (#3176)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 17:46:45 +01:00
Asim Aslam 620374ef22 Add zero-to-hero harness test contract (#3174)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 17:08:04 +01:00
Asim Aslam 31556b310e loop: continuous architect + lead the North Star with the mission/canon (#3172)
* ci: run the architect continuously as the founder lens

Make the architect a continuous overseer (hourly at :59, just before the :29
increment) instead of an every-few-days check-in. Each run it tracks live state
(what merged, what's in flight), keeps the roadmap priorities live, and judges
cohesion across harness/framework/dev-UX plus missing pieces and realignment —
re-ranking internal/docs/PRIORITIES.md to match reality. It only opens a PR when
the ranking actually changes (otherwise it just posts an assessment and closes
its issue), to avoid churn.

* thesis: lead the North Star with the mission, grounded in the canon

Instill the years of context the loop was missing: the vision lives in the
corpus (blog, README, website), not a single doc. Lead THESIS.md with an
explicit Mission — "what problem we solve" (make building an agent as easy as
building a service, on one runtime, because an agent is a distributed system),
distilled from the corpus — and name the blog/README/website as the canon the
North Star is a distillation of and must stay faithful to. Wire the architect to
judge every priority against the mission and re-derive alignment from the canon,
flagging drift in either direction (work vs mission, or thesis vs the blog).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:58:24 +01:00
Asim Aslam 0e67971c53 ci: couple architect and increment loop through a ranked priority queue (#3171)
The architect now prioritizes the roadmap + an internal scan into a single
ranked, issue-linked queue in internal/docs/PRIORITIES.md (re-ranked each run),
and the hourly increment loop works the top open item from that queue instead
of independently guessing each hour — falling back to its own judgment only
when the queue is empty. So work is roadmap-driven by default and the human can
redirect by reordering the file or its issues.

- New internal/docs/PRIORITIES.md (seeded from the roadmap + open issues).
- architecture-review.yml: architect owns/re-ranks PRIORITIES.md and keeps each
  top item backed by a scoped issue.
- continuous-improvement.yml: pick the top open queue item; close both the
  priority issue and the run tracker.
- CONTINUOUS_IMPROVEMENT.md: document the architect → queue → increment pipeline.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-27 16:18:03 +01:00
Asim Aslam 2af5e6ae8f Record A2A agent replies in task history (#3170)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 16:09:30 +01:00
Asim Aslam 656c4ea1e7 flow: tag single-step run info as flow (#3168)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 15:16:38 +01:00
Asim Aslam 4a09b4469a Add flow execution timeout option (#3164)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 14:04:58 +01:00
Alexander Serheyev 25d66f737d fix: retract phantom modules for cmd paths (#3154)
* fix: retract phantom modules for cmd and cmd/micro

* fix: remove temporary go.mod files to restore build
2026-06-27 13:19:53 +01:00
Asim Aslam 45d68c932e Track parent run IDs for durable flows (#3162)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 13:07:18 +01:00
Asim Aslam c5c08e24d8 test: cover micro new no-mcp contract (#3160)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 12:17:19 +01:00
Asim Aslam 8a0f4a7636 Add flow step context to run info (#3158)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 11:22:23 +01:00
Asim Aslam bcf2c92931 Add A2A completed-task streaming (#3156)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 10:29:01 +01:00
Asim Aslam 57ca94a88b docs: align public x402 flag examples (#3153)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 09:08:38 +01:00
Asim Aslam ba231efe71 Update README.md 2026-06-27 07:08:08 +01:00
Asim Aslam f755eadd47 Propagate flow checkpoint failures (#3150)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 06:55:56 +01:00
Asim Aslam 467c937873 Improve flow run history validation (#3148)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 04:55:49 +01:00
Asim Aslam 3f45771840 Publish provider conformance summaries (#3146)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 03:29:12 +01:00
Asim Aslam c9e045961d Add flow run stage filtering (#3144)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 02:28:39 +01:00
Asim Aslam 590aacb518 docs: keep AI provider capability matrix verified (#3142)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 00:58:39 +01:00
Asim Aslam 87eb5ce665 Add filters for flow run history (#3140)
Co-authored-by: Codex <codex@openai.com>
2026-06-27 00:00:58 +01:00
Asim Aslam ed904aa6ad Propagate flow run lineage to agent chats (#3138)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 23:04:18 +01:00
Asim Aslam 59d028b8e1 test ai retry policy semantics (#3136)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 22:16:59 +01:00
Asim Aslam 19818ad638 Expose streaming in provider capabilities (#3134)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 21:18:38 +01:00
Asim Aslam 993ce668b2 Add provider capability markdown export (#3132)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 20:20:08 +01:00
Asim Aslam 1d412fa6f5 Trigger test workflow on PR updates (#3130)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 19:14:39 +01:00
Asim Aslam 271c27c44f Validate durable flow step names (#3128)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 18:18:06 +01:00
Asim Aslam cefc9438c4 Add JSON output for AI provider capabilities (#3126)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 17:31:18 +01:00
Asim Aslam af39aac297 Add provider conformance JSON summary (#3124)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 16:31:25 +01:00
Asim Aslam 41717aaa9a ci: run 0-to-1 scaffold contract in harness (#3122)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 15:26:25 +01:00
Asim Aslam 5d1137d8f9 docs: add provider conformance matrix (#3106)
Co-authored-by: Codex <codex@openai.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-26 14:30:51 +01:00
Asim Aslam a14b3e79e2 docs: add provider conformance matrix (#3120)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 14:23:56 +01:00
Asim Aslam 130022e544 Record full agent timelines without tracing (#3118)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 13:27:08 +01:00
Asim Aslam 0d3f3f2be5 Record agent run timelines without tracing (#3116)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 12:35:03 +01:00
Asim Aslam ae91863343 Add trace filter for agent runs (#3114)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 10:50:49 +01:00
Asim Aslam 72f8067e57 docs: finish constructor example cleanup (#3112)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 09:30:58 +01:00
Asim Aslam 8533306e64 docs: align service constructor examples (#3111)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 09:29:36 +01:00
Asim Aslam 384987da9f docs: align service constructor examples (#3110)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 09:26:40 +01:00
Asim Aslam ddde9472fc Expose agent chat run identifiers (#3108)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 08:53:33 +01:00
Asim Aslam d7a74735c8 Add AI provider capability command (#3104)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 04:55:16 +01:00
Asim Aslam 581b2120a3 Verify zero-to-hero harness via workflow (#3102)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 03:29:00 +01:00
Asim Aslam 871af7e6d3 Add pending filter for flow run history (#3100)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 02:35:50 +01:00
Asim Aslam 9d39ed3292 Add OpenTelemetry spans for stepped flows (#3098)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 01:10:11 +01:00
Asim Aslam d8f711431f agent: stop canceled tool execution (#3096)
Co-authored-by: Codex <codex@openai.com>
2026-06-26 00:07:26 +01:00
Asim Aslam 5d22cd4a5b Correlate agent trace spans (#3094)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 23:09:39 +01:00
Asim Aslam f1f987cb8d provider conformance: print capability matrix (#3092)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 22:18:34 +01:00
Asim Aslam 7b9c583dcf blog: "How Go Micro Builds Itself" — the autonomous Codex loop (#3090)
A post on how the framework is increasingly built by an autonomous loop of
two AI agents (Codex implementing scoped increments, Claude Code
orchestrating, the human setting direction): the dispatch→build→auto-merge
mechanism, the three altitudes (architect/increment/DevRel), the failure
modes we hit wiring it up, and the concrete increments it produces.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 21:59:00 +01:00
Asim Aslam b7f1ac6d6d Add flow run correlation to step contexts (#3089)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 21:20:38 +01:00
Asim Aslam fb14ccbc17 Add deterministic AI capability rows (#3087)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 20:21:00 +01:00
Asim Aslam 5f40cae7af ci: add DevRel + Architect overseer passes to the loop (#3085)
The hourly loop ships increments but nothing watches the whole. Add two
periodic high-altitude passes, same dispatch mechanism (fresh issue → @codex):

- devrel-review.yml (daily): audits README, website, docs, blog for coherence
  with the North Star, README crispness, and blog-worthy material. Safe
  alignment/crispness fixes auto-merge; brand/positioning copy and blog drafts
  are surfaced in a report for the human, never auto-merged.
- architecture-review.yml (every ~3 days): reviews the framework/harness against
  the thesis and files scoped follow-up issues that feed the increment loop. It
  does not make breaking/architectural changes itself.

Documented both in CONTINUOUS_IMPROVEMENT.md (Overseer passes).


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 19:42:58 +01:00
Asim Aslam 496d5b7644 Show parent agent runs in CLI history (#3084)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 19:28:32 +01:00
Asim Aslam 44e62f5e8b flow: honor canceled checkpoint contexts (#3082)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 18:24:35 +01:00
Asim Aslam 18ae00dad0 Harden agent harness contracts (#3080)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 17:36:36 +01:00
Asim Aslam 6f9adfd375 ai: expose unsupported streaming sentinel (#3078)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 16:38:14 +01:00
Asim Aslam 8fc4f58a17 Add AI provider capability matrix (#3076)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 15:26:49 +01:00
Asim Aslam 19ad28e04b Propagate A2A request cancellation (#3074)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 14:32:06 +01:00
Asim Aslam 319ff1972b ci: require live provider conformance keys (#3072)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 13:28:57 +01:00
Asim Aslam 03791759ef Add filtered agent run summaries (#3070)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 12:35:39 +01:00
Asim Aslam 5e3cef7cd9 Add agent run summary status and duration (#3068)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 10:50:49 +01:00
Asim Aslam 751530aaec website: refresh Developer Experience copy for the harness DX (#3066)
The section only mentioned micro run (services) and micro deploy. Update it
to the current lifecycle DX: micro new scaffolds a service or agent (every
endpoint an MCP tool), micro run starts everything with hot reload + gateway
+ console, and micro chat talks to agents and services. Drops micro deploy.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 10:33:34 +01:00
Asim Aslam 3a9f45750b docs + website: loop mechanics doc; landing "Features" + subtitle trim (#3065)
* ci: self-merge Codex PRs via native auto-merge; retire the sweep

With branch protection + "Allow auto-merge" now enabled on master, Codex
enables GitHub auto-merge on its own PR (gh pr merge --squash --auto) right
after opening it, so the PR lands the moment the required CI checks pass —
no polling sweep, and the green-CI gate is enforced by GitHub instead of by
gh pr checks in a cron. Removes auto-merge-codex.yml and updates the dispatch
and AGENTS.md accordingly.

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

* docs: document the durable loop mechanics (stub→gh, branch, auto-merge)

Capture the hard-won wiring of the autonomous loop so it isn't re-derived:
fresh issue per increment, user-PAT dispatch (Codex ignores the bot), Codex
opening the PR via gh (make_pr is a no-op stub), unique codex/ branch + label,
and native auto-merge gated by branch protection with 0 approvals. Adds a
"do-not-break" list (don't re-add approvals, don't reuse one tracker issue,
don't use make_pr, don't re-implement during the summary→PR lag).

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

* website: rename section to "Features"; trim subtitle

Rename the feature-grid heading from "The Runtime Around the Agent" to
"Features", and drop "once they leave the demo." from the subtitle.

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-06-25 10:22:51 +01:00
Asim Aslam 2def581408 ci: self-merge Codex PRs via native auto-merge; retire the sweep (#3064)
With branch protection + "Allow auto-merge" now enabled on master, Codex
enables GitHub auto-merge on its own PR (gh pr merge --squash --auto) right
after opening it, so the PR lands the moment the required CI checks pass —
no polling sweep, and the green-CI gate is enforced by GitHub instead of by
gh pr checks in a cron. Removes auto-merge-codex.yml and updates the dispatch
and AGENTS.md accordingly.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 10:13:24 +01:00
Asim Aslam 95ee402c8b ci: pin Codex PRs to a unique codex/ branch + codex label (#3063)
Codex was pushing to a generic branch (e.g. "work"), which the auto-merge
sweep ignores (it only matches codex/* branches with the codex label) and
which collides across runs. The dispatch and AGENTS.md now instruct Codex to
create a unique codex/increment-<issue> branch and pass --label codex to
gh pr create, so every increment is isolated and actually swept.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 09:48:25 +01:00
Asim Aslam 8aeba56839 Harden provider conformance selection (#3061)
Co-authored-by: Codex <codex@openai.com>
2026-06-25 09:47:55 +01:00
Asim Aslam 42fdad439c docs: AGENTS.md — open PRs via gh, not the make_pr stub (#3062)
The initial AGENTS.md instructed Codex tasks to use the make_pr tool and
said a shell GitHub token "is not a substitute" — but make_pr in the Codex
sandbox is a no-op stub that only records metadata and never opens a PR, so
that guidance steers every run back into the broken path. Replace it with
the working flow: push the branch and open the PR with the gh CLI
(git push + gh pr create), which is what actually creates PRs now.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 09:32:50 +01:00
Asim Aslam b45c94e201 ci: instruct Codex to open the PR via gh, not the make_pr stub (#3059)
Codex's make_pr tool in the Cloud sandbox is a no-op stub — it records the
PR title/body and returns them "for downstream consumption" (the manual
"Create PR" click), but never pushes a branch or calls the GitHub API. That
is why "make_pr called, nothing happened": the tool literally cannot open a
PR. With the gh CLI now installed in the Codex setup and origin pointed at
the repo, the dispatch tells Codex to push and open the PR itself
(git push + gh pr create) instead of relying on make_pr.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-25 08:02:23 +01:00
Asim Aslam 23051459d8 docs: require Codex PR creation step (#3058) 2026-06-25 07:35:06 +01:00
Asim Aslam 4c7e6e96eb agent: correlate run timelines with traces (#3057) 2026-06-25 07:20:11 +01:00
Asim Aslam ef21ac6494 Add JSON output for agent run inspection (#3055) 2026-06-25 06:51:06 +01:00
Asim Aslam 53193fbbe7 agent: mark successful run timelines complete (#3054) 2026-06-25 06:50:51 +01:00
Asim Aslam 105cc9d2fb Add flow retry backoff (#3053) 2026-06-25 06:50:36 +01:00
Asim Aslam d369cf290c Improve agent run index summaries (#3049) 2026-06-25 01:31:08 +01:00
Asim Aslam 949542fa65 flow: add pending run resume helper (#3048) 2026-06-25 01:30:29 +01:00
Asim Aslam 1421ccbea6 Improve flow run inspection (#3047) 2026-06-25 01:30:01 +01:00
Asim Aslam 7a0ca65b68 Expose agent run IDs on responses (#3044) 2026-06-24 23:10:45 +01:00
Asim Aslam 28433f2db8 docs: align continuous improvement scheduler notes (#3043) 2026-06-24 23:09:49 +01:00
Asim Aslam cb61ccb9d7 Make flow checkpoint listing deterministic (#3040) 2026-06-24 21:51:18 +01:00
Asim Aslam fbb5fd46ae ci: open a fresh issue per increment so Codex opens a new PR each run (#3039)
Codex derives its PR branch name from the triggering issue's context, so
re-commenting on a single tracker issue (#3024) every hour collapsed every
increment onto one branch name. The first increment opened a PR; the rest
collided on the occupied branch and silently failed to open one — which is
why repeated runs produced "make_pr called, nothing happened."

Open a unique issue per run and dispatch Codex there, so each increment gets
its own branch and a clean PR. The dispatch asks Codex to "Closes #<issue>"
so each tracking issue auto-closes when its PR merges. The explicit
branch-name request (which Codex ignored in favor of the issue-derived name)
is dropped.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 21:46:44 +01:00
Asim Aslam ff6af5d46b Make agent run timelines deterministic (#3038) 2026-06-24 20:42:38 +01:00
Asim Aslam 89ae0a10eb website: h1 → "An Agent Harness for Go" (#3037)
Claude-Session: https://claude.ai/code/session_01CmdEY7pYmV5zzwCjNJ4ykL

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 20:29:41 +01:00
Asim Aslam 666e1f765e ci: give each Codex dispatch a unique branch to avoid collisions (#3036)
* fix: harden flow step execution against nil Run and cancellation

A step with no Run function panicked the run; it now returns a clear
configuration error. The retry loop also kept retrying after the run's
context was canceled or its deadline passed — it now stops immediately
and surfaces the context error, preserving cancellation/deadline
semantics for durable workflow runs. Adds regression coverage for both.

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

* ci: give each Codex dispatch a unique branch to avoid collisions

Codex derives its PR branch name from the dispatch text. The hourly loop
posted an identical generic prompt every run, so every increment resolved
to the same branch name — the previous increment's branch (until auto-
merged and deleted) blocked the next PR from opening. Include the run
number in the prompt and explicitly request a fresh
codex/improvement-<run_number> branch, so each increment is isolated.
auto-merge-codex.yml still matches (codex/* prefix) and deletes on merge.

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-06-24 20:26:29 +01:00
Asim Aslam 5a0df7991e fix: harden flow step execution against nil Run and cancellation (#3034)
A step with no Run function panicked the run; it now returns a clear
configuration error. The retry loop also kept retrying after the run's
context was canceled or its deadline passed — it now stops immediately
and surfaces the context error, preserving cancellation/deadline
semantics for durable workflow runs. Adds regression coverage for both.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 18:49:32 +01:00
Asim Aslam 631c58cb75 ci: gate Codex dispatch on CODEX_TRIGGER_TOKEN secret (#3033)
goreleaser / goreleaser (push) Waiting to run
* thesis: position Go Micro as complementary to LangChain, not competing

Add a 'Where we fit' section: 'harness' has two layers — the intra-agent
harness (single-model runtime: prompts, tools, context, sandbox, the Ralph
loop) that LangChain/LangGraph/deepagents/Claude Code own and we do NOT
compete with, and the operational harness (the distributed substrate agents
operate inside: services-as-tools, discovery, durable runs, observability,
interop, the services->agents->workflows lifecycle) which is our focus. They
stack and interoperate via MCP/A2A; we make those agents better neighbours,
not obsolete.

* ci: gate Codex dispatch on CODEX_TRIGGER_TOKEN secret

Codex ignores @codex comments authored by the github-actions bot, so the
hourly dispatch was producing no PRs while still posting to the tracker
issue. Gate the comment step on the presence of CODEX_TRIGGER_TOKEN: the
workflow now no-ops until a PAT for a Codex-followed account is set, then
activates automatically with no further change.

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-06-24 17:39:44 +01:00
Asim Aslam 57e83ca9a0 thesis: position Go Micro as complementary to LangChain, not competing (#3032)
Add a 'Where we fit' section: 'harness' has two layers — the intra-agent
harness (single-model runtime: prompts, tools, context, sandbox, the Ralph
loop) that LangChain/LangGraph/deepagents/Claude Code own and we do NOT
compete with, and the operational harness (the distributed substrate agents
operate inside: services-as-tools, discovery, durable runs, observability,
interop, the services->agents->workflows lifecycle) which is our focus. They
stack and interoperate via MCP/A2A; we make those agents better neighbours,
not obsolete.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 15:54:56 +01:00
Asim Aslam 8d59779465 website: fix mobile horizontal overflow on the landing page (#3031)
Grid items defaulted to min-width:auto, so the wide <pre> blocks (install
command, the 'micro run' sample) forced their row past the viewport —
clipped by overflow-x:hidden, so no scroll but not fitting either. Let
grid items shrink (.two-col > * { min-width:0 }) and wrap the code blocks
on mobile (white-space:pre-wrap; overflow-wrap:anywhere) so every section
fits the screen with no overflow or scroll.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 15:48:35 +01:00
Asim Aslam 3d0c5237f0 docs: burger (☰) sidebar toggle on the left on mobile (#3030)
Replace the docs header 'Menu' button with a ☰ burger icon and move it to the
left next to the wordmark (where the logo used to be), grouped in a .nav-left
container. Keeps id=menuToggle so the existing sidebar-toggle JS still works;
desktop is unchanged (toggle hidden).

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 15:31:52 +01:00
Asim Aslam cf1f42d15b website: text-only 'Go Micro' brand, drop the nav logo (#3029)
Remove the logo image from the nav-brand in the marketing nav include and the
docs and blog layouts; the brand is now just the 'Go Micro' wordmark.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 15:27:47 +01:00
Asim Aslam 728ea5eae7 loop: add the thesis / North Star and make the loop align to it (#3028)
Add internal/docs/THESIS.md — the vision the autonomous loop steers by: a
holistic agent harness AND service framework encapsulating the lifecycle of
services -> agents -> workflows (workloads come after agents; the value is in
composing it into systems that do real work, on schedules and in loops).

Wire it in as the alignment criterion: the continuous-improvement charter and
the Codex dispatch prompt now require every increment to advance the North Star,
so improvements compound toward the thesis instead of drifting locally.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 15:23:01 +01:00
Asim Aslam 5e9accfd45 Add agent OpenTelemetry run observability (#3027) 2026-06-24 14:57:25 +01:00
Asim Aslam 0adb4f0aac loop: hourly Codex dispatch + auto-merge green Codex PRs (#3026)
- continuous-improvement: cadence 12h -> hourly.
- add auto-merge-codex workflow: every 15 min, merge open PRs that are
  codex-labelled AND from a codex/* branch once all checks are green
  (gh pr checks). CI (build/test/lint/harnesses) is the only gate — no
  human involvement. Scoped tightly so nothing else auto-merges.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 14:26:31 +01:00
Asim Aslam ee44926191 loop: drive the scheduled backbone via Codex, not an Anthropic key (#3025)
A Claude Max subscription exposes no API key for CI, and Atlas Cloud models
can't run a coding agent — so the durable scheduler triggers Codex instead:
on a cadence it posts an @codex instruction on the tracker issue (#3024) to
run one improvement increment. Prefers a CODEX_TRIGGER_TOKEN PAT if set
(in case Codex ignores the Actions bot), else the default token.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 14:15:41 +01:00
Asim Aslam 09cf00b5ec docs: mark harness Resilience as shipped (#3023)
* loop: establish the continuous-improvement charter + scheduled backbone

Define the autonomous improvement loop (internal/docs/CONTINUOUS_IMPROVEMENT.md):
full autonomy with correctness (build/test/lint) as the only gate, work sourced
from roadmap + issues + an improvement radar + dogfooding, Claude Code driving
and Codex executing scoped tasks, with brand/positioning and breaking API kept
with the human.

Add a durable scheduled GitHub Action (.github/workflows/continuous-improvement.yml)
as the session-independent backbone — a safe no-op until an ANTHROPIC_API_KEY
secret is added.

* docs: mark harness Resilience as shipped

Resilience (per-call timeout + context propagation + opt-in retry/backoff)
landed in #3017/#3021; update the agent-harness status table from
'In progress' to 'Shipped'.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 13:58:02 +01:00
Asim Aslam da5fa1f27c loop: establish the continuous-improvement charter + scheduled backbone (#3022)
Define the autonomous improvement loop (internal/docs/CONTINUOUS_IMPROVEMENT.md):
full autonomy with correctness (build/test/lint) as the only gate, work sourced
from roadmap + issues + an improvement radar + dogfooding, Claude Code driving
and Codex executing scoped tasks, with brand/positioning and breaking API kept
with the human.

Add a durable scheduled GitHub Action (.github/workflows/continuous-improvement.yml)
as the session-independent backbone — a safe no-op until an ANTHROPIC_API_KEY
secret is added.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 13:56:38 +01:00
Asim Aslam a8ea60120e agent: make model retries opt-in and harden retry backoff (#3021)
Follow-up to #3017. A Generate runs the whole tool-execution turn, so
auto-retrying it re-runs already-executed (possibly side-effecting) tool
calls. Default ModelMaxAttempts 3 -> 1: retries are now opt-in via
ModelRetry. The timeout stays as a safety net.

Also harden ai.GenerateWithRetry: always back off between retries
(exponential, capped at 30s, default 200ms if unset) so an opt-in retry
can't busy-loop the provider even with Backoff=0.

Verified: go build, go test -race ./agent/... ./ai/, golangci-lint.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 12:42:15 +01:00
Asim Aslam d7050e9cc2 website: size hero image to 600px (#3020)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 12:39:22 +01:00
Asim Aslam 536806119e website: tighten hero — drop 'in Go', simpler tagline, image in-hero at 800px (#3019)
- h1: 'An Agent Harness and Service Framework' (drop 'in Go')
- tagline: 'Build agents, services, and workflows on one runtime'
- move the hero image into the hero block (between tagline and the install
  line), capped at 800px to fit the hero column, and remove the separate
  full-width image section

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 12:33:58 +01:00
Asim Aslam ab2c092693 Add agent model retry and timeout resilience (#3017) 2026-06-24 12:30:58 +01:00
Asim Aslam 7144cddc00 website: new hero image + reframe landing as harness AND service framework (#3018)
- Regenerate the hero image (text-free, via the framework's own Atlas Cloud
  image model) — an agent orchestrating service nodes. The old one had baked-in
  'AI native microservices framework' text that contradicts the positioning;
  text-free so copy changes can't invalidate it again.
- h1 -> 'An Agent Harness and Service Framework in Go' (both, not harness-only).
- Tagline reframed around agents, services, and flows on one runtime.
- Drop the 'moved from a service framework to an agent harness' line — it's
  additive (both in one), not a pivot away from services.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 12:27:09 +01:00
Asim Aslam 9f28cc4c86 docs: bake cross-agent coordination rules into CLAUDE.md and CODEX.md (#3016)
Add matching 'Coordination' sections so Claude Code and Codex self-coordinate:
lane/branch ownership (claude/* vs codex/*, never share a branch), base PRs
on master (don't stack on an in-flight branch — the #3007 orphan lesson),
one concern per PR, cross-review before merge, the @codex dispatch
conventions (review reserved, serial, fix-in-place), and CI as the gate.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 12:13:21 +01:00
Asim Aslam 8a19dc2d7c docs/blog: anchor the agent-harness positioning (#3015)
- Add the canonical concept doc 'The Agent Harness' (docs/guides/agent-harness.md)
  + nav entry: what the harness is, each piece mapped to a feature, honest
  about shipped vs in-progress.
- Add blog post /blog/30 'Go Micro is an Agent Harness' (the public articulation;
  precise about what ships today vs the Now/Next roadmap).
- Align the ROADMAP.md opening line to the agent-harness framing.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 11:59:42 +01:00
Asim Aslam daacd4830f harness/new: make conformance timeout honest and contract test cheaper (#3008)
Follow-up to #3006:

- provider-conformance: build each harness to a temp binary and run that
  instead of 'go run'. 'go run' launches the harness as a child it doesn't
  kill on context cancellation, so a timed-out harness (which starts local
  services) could be orphaned and outlive the run. Running the built binary
  makes the per-run timeout actually terminate the work.
- contract test: skip under -short, and use 'go build ./...' instead of
  'go test ./...' (the contract is that the generated service builds). This
  keeps the default unit-test suite from shelling out to the toolchain and
  the network on every run.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-24 11:48:40 +01:00
Asim Aslam 844ab4a14b Add provider-conformance harness, contract test, and reframe docs to 'agent harness' (#3006)
* docs: reposition go micro as agent harness

* ci: rename universe workflow to harness
2026-06-24 11:37:43 +01:00
Asim Aslam 156b051191 docs: fix service quickstart snippets (#3005) 2026-06-24 08:21:38 +01:00
Asim Aslam 21a6746d86 docs: add codex maintainer playbook (#3004) 2026-06-24 07:58:48 +01:00
Asim Aslam 2c04f61331 blog: announce OpenAI Codex for Open Source grant (/blog/29) (#3003)
Add a blog post for the OpenAI Codex for Open Source grant, list it on
the blog index, and repoint the OpenAI sponsor logo (README + landing) to
the post — matching how the Anthropic (/blog/3) and Atlas Cloud (/blog/8)
sponsor logos link to their posts.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 18:35:51 +01:00
Asim Aslam 634fc9bced sponsors: add OpenAI (Codex for Open Source) (#3002)
Go Micro received an OpenAI Codex for Open Source grant. Add the OpenAI
logo to the README and landing-page sponsors, alongside Anthropic and
Atlas Cloud.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 18:21:35 +01:00
Asim Aslam a503628ef1 flow: add Loop — run a step until done, with a guaranteed ceiling (#3001)
Adds the agentic 'loop' to flows: flow.Loop(body, opts...) is a StepFunc
that runs a body step repeatedly, carrying State across passes, until a
stop condition fires or a hard iteration cap is reached.

- Stop modes: flow.Until (code-defined predicate) and flow.UntilLLM (the
  model judges the goal met after each pass — the supervised 'Ralph'
  loop). Either firing stops the loop.
- flow.LoopMax is the guardrail: the body never runs more than n times, so
  the loop always terminates and can't run up an unbounded bill. Hitting
  the cap returns the latest state rather than erroring.
- flow.OnIteration reports per-pass progress.
- Composes as a normal flow step (checkpointed by the step engine).
- Exposed at the top level as micro.FlowLoop / FlowUntil / FlowUntilLLM /
  FlowLoopMax / FlowOnIteration, symmetric with the other Flow* helpers.

Includes tests, an offline runnable example (examples/flow-loop), an
'Agent Loops' guide, and a CHANGELOG entry.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 12:39:17 +01:00
Asim Aslam 3f40028a7f website: deduplicate header/footer via Jekyll includes (#3000)
Extract the nav and footer link lists into _includes/nav-links.html and
_includes/footer-links.html, and the full marketing nav/footer into
_includes/marketing-nav.html and _includes/marketing-footer.html.

- index.html and support.html now pull their nav/footer from the shared
  marketing includes (and gain Liquid front matter so includes resolve).
- The docs and blog layouts pull the same link lists, so a nav/footer
  link lives in exactly one place site-wide. This also adds the missing
  Support link to the blog nav and fixes the blog footer's Support link
  (it still pointed at the question template).
- Active nav state is driven by a per-page nav_active flag.

Verified with a local jekyll build: landing, support, docs, and blog
pages all render the shared nav/footer correctly.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-23 05:45:09 +01:00
Asim Aslam b9665ad196 website: add Support to top nav (landing + docs layout) (#2999)
* website: add Support to top nav (landing + docs layout)

Link /docs/support.html from the nav bar on the landing page and the
shared docs layout, and point the docs-layout footer Support link there
too (was the question issue template).

* website: add a dedicated marketing /support page

New top-level /support.html styled like the landing site (hero, tier
cards, community links, CTAs) — separate from the docs reference at
/docs/support.html. Repoint the site nav, footer, landing 'Commercial
support' button, and the FUNDING button to it.

* website: serve the support page at a clean /support URL

Add 'permalink: /support' to support.html (same mechanism the blog uses)
and point all nav/footer/CTA/FUNDING links at /support instead of
/support.html.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 22:42:10 +01:00
Asim Aslam 053103fb7f website: trim landing tagline and use 'workflows' (#2998)
Drop the 'Every service is an MCP tool; every agent speaks A2A' sentence
and use 'workflows' (clearer than 'flows') in the one-liner.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 19:54:35 +01:00
Asim Aslam 0ba2253357 website: trim landing tagline to the one-liner (#2997)
Drop the 'Every service is an MCP tool; every agent speaks A2A' sentence.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 19:52:05 +01:00
Asim Aslam 2339155ff2 Enforce blocking golangci-lint in CI and clear lint backlog (#2996)
goreleaser / goreleaser (push) Waiting to run
* lint: clear the golangci-lint backlog and enforce a blocking lint in CI

Fixes #2988. Brings 'golangci-lint run ./...' to zero issues (was ~373):

- errcheck: explicitly ignore fire-and-forget calls with '_ =' (and a small
  errcheck.exclude-functions list for response writes — json Encoder.Encode,
  http ResponseWriter.Write, fmt.Fprint*); genuine cases handled.
- unused: remove dead code (unexported decls and dead test helpers) and the
  imports they orphaned.
- staticcheck: ST1005 error strings, ST1016 receiver names, S1000/S1017/S1019/
  S1023 simplifications, SA4004/SA4006/SA4010 dead code, SA1021 net.IP.Equal,
  SA6002 (store *[]byte in sync.Pool).
- govet: fix a context leak (lostcancel) in internal/util/mdns and move
  t.Fatal/Fatalf out of goroutines (testinggoroutine) in tests.
- ineffassign, unconvert: mechanical fixes.

CI: the Lint workflow now runs a blocking full-tree 'golangci-lint run' on
pushes and PRs (dropped only-new-issues now that the tree is clean).

Verified: go build, go vet, test compilation, and unit tests for the
behaviourally-touched packages all pass.

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

* website: emit go-source meta for /vN paths

pkg.go.dev uses go-source to link to the right files and lines. The /vN
go-get response now returns go-source alongside go-import. Does not affect
go install resolution.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 19:48:09 +01:00
Asim Aslam 3e885308a0 lint: clear the golangci-lint backlog and enforce a blocking lint in CI (#2995)
Fixes #2988. Brings 'golangci-lint run ./...' to zero issues (was ~373):

- errcheck: explicitly ignore fire-and-forget calls with '_ =' (and a small
  errcheck.exclude-functions list for response writes — json Encoder.Encode,
  http ResponseWriter.Write, fmt.Fprint*); genuine cases handled.
- unused: remove dead code (unexported decls and dead test helpers) and the
  imports they orphaned.
- staticcheck: ST1005 error strings, ST1016 receiver names, S1000/S1017/S1019/
  S1023 simplifications, SA4004/SA4006/SA4010 dead code, SA1021 net.IP.Equal,
  SA6002 (store *[]byte in sync.Pool).
- govet: fix a context leak (lostcancel) in internal/util/mdns and move
  t.Fatal/Fatalf out of goroutines (testinggoroutine) in tests.
- ineffassign, unconvert: mechanical fixes.

CI: the Lint workflow now runs a blocking full-tree 'golangci-lint run' on
pushes and PRs (dropped only-new-issues now that the tree is clean).

Verified: go build, go vet, test compilation, and unit tests for the
behaviourally-touched packages all pass.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 18:51:30 +01:00
Asim Aslam 4311b73361 Enhance ADK vs Go Micro comparison and apply lint fixes (#2994)
* docs: compare Go Micro with Google ADK in the comparison guide

Adds a 'vs Agent Frameworks (Google ADK)' section: ADK builds an agent,
Go Micro builds the distributed system the agent lives in (agents are
services in the mesh). Covers the category difference, a feature table,
when to choose each, and MCP/A2A interoperability.

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

* docs: replace ADK comparison slogan with concrete explanation

State plainly what each tool provides (ADK builds an agent process; Go Micro
builds the surrounding service mesh) instead of marketing phrasing.

* lint: apply golangci-lint autofixes; exclude ST1003 and demo errcheck

Mechanical, behaviour-preserving fixes applied by 'golangci-lint run --fix':
gofmt, misspell (US spelling), usestdlibvars (http.Method*/Status*), unconvert,
and the auto-fixable staticcheck simplifications (QF*, S1017/S1019/S1023/S1039).

Config: exclude ST1003 (remaining offenders are exported API renames, e.g.
web.Id, which would break compatibility) and skip errcheck for examples/ and
internal/harness/ (demo code where fire-and-forget is intentional).

Build and test compilation verified.

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

* lint: WIP cleanup checkpoint (errcheck config + partial fixes)

Checkpoint of an in-progress golangci-lint cleanup (background pass). Builds
cleanly; lint is not yet zero. Follow-up commit will complete the cleanup and
switch CI to a blocking full-tree lint.

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-06-22 17:21:47 +01:00
Asim Aslam c3542127dd Enhance comparison guide for Go Micro and Google ADK (#2993)
* docs: compare Go Micro with Google ADK in the comparison guide

Adds a 'vs Agent Frameworks (Google ADK)' section: ADK builds an agent,
Go Micro builds the distributed system the agent lives in (agents are
services in the mesh). Covers the category difference, a feature table,
when to choose each, and MCP/A2A interoperability.

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

* docs: replace ADK comparison slogan with concrete explanation

State plainly what each tool provides (ADK builds an agent process; Go Micro
builds the surrounding service mesh) instead of marketing phrasing.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 17:07:45 +01:00
Asim Aslam 06659032a9 docs: install the CLI with @v6 instead of @latest (#2991)
Plain 'go install go-micro.dev/v6/cmd/micro@latest' fails on the public
module proxy with a version-constraints conflict: the proxy has cached the
sub-paths go-micro.dev/v6/cmd and .../cmd/micro as standalone v0/v1 modules
(from old github.com/micro/go-micro tags, surfaced during an earlier vanity
meta bug), so @latest resolves to v1.18.0 with a mismatched module path.

A version-prefix query (@v6) sidesteps it: those cached sub-path modules
have no v6.x.x versions, so Go falls back to the go-micro.dev/v6 root
module and builds correctly. Verified against proxy.golang.org.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 16:39:04 +01:00
Asim Aslam 8cad95cc29 website: add commercial support to the landing page (#2990)
The Sponsors section now also points production users to commercial
support & consulting, and the footer Support link routes to the support
page (community + commercial) instead of the question issue template.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-22 15:50:23 +01:00
Asim Aslam 2cbd6264d7 support: advertise commercial support, consulting, and sponsorship (#2989)
* support: advertise commercial support, consulting, and sponsorship

Adds a clear path to fund the project and pay for help, surfaced where
people look:
- SUPPORT.md + website /docs/support.html with a tier ladder (community,
  sponsor, support retainer, consulting)
- Commercial Support / Consulting issue template (the GitHub inbound funnel)
  and an issue-chooser config linking Sponsors and docs
- FUNDING.yml custom link to the support page; README section + nav entry

Community support stays free via issues; paid support and consulting are
scoped per engagement.

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

* ci: migrate golangci-lint to v2 config and enforce in CI (#2988)

- Rewrite .golangci.yaml for the v2 schema: start from the standard linter
  set (errcheck, govet, ineffassign, staticcheck, unused) plus bodyclose,
  misspell, unconvert, usestdlibvars. Sensible exclusions: generated code,
  built-in presets, looser tests, SA1019 deprecations (coordinated migration
  is separate), and the ported protoc-gen-micro generator for unused.
- Add a Lint workflow running golangci/golangci-lint-action with
  only-new-issues, so linting is enforced on new/changed code without a
  flag-day cleanup of the existing backlog.

The pre-existing backlog (errcheck/unused/naming and a few real bugs the
linter surfaces) is left for a dedicated follow-up so it can be reviewed on
its own rather than buried in this wiring change.

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-06-22 14:39:28 +01:00
Asim Aslam aa7cd6dc3b Enhance support agent example and fix protoless service scaffolding (#2986)
goreleaser / goreleaser (push) Waiting to run
* examples: support desk agent + blog walkthrough

A real-world, runnable example (examples/support): customers/tickets/notify
services become the agent's tools, a flow turns a ticket.created event into
the agent's work, and an approval gate guards the one action that touches a
customer. Runs with no API key (mock model) or against a live provider.

Adds blog/28 'Building a Support Agent in Go' and indexes both.

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

* fix(new): protoless services by default; fix @latest install (#2985)

micro new now scaffolds a reflection-based service by default — plain Go
types registered via service.Handle, no .proto, no Makefile proto target.
The generated project builds and runs with 'go run .' and zero external
tooling. Protocol Buffers move behind --proto (the crud/pubsub/api
templates imply it). When the proto workflow is used and protoc /
protoc-gen-go / protoc-gen-micro are missing, print exact install
instructions instead of failing with a cryptic plugin error.

Also fixes the 'go install go-micro.dev/v6/cmd/micro@latest' version
constraint conflict: the vanity go-import meta still advertised /v5, so Go
fell back to the bare module and resolved an ancient v1.x tag. Advertise
/v6 (keeping /v5 for existing users) and add a version-pin fallback note to
the install docs.

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

* fix(new,install): pin generated go.mod to current go-micro; lead install with prebuilt binary (#2985)

- micro new now requires the exact go-micro version the CLI was built from
  (via build info), falling back to 'latest' for dev builds. An explicit
  require is also more robust than a bare import: 'go mod tidy' reliably
  resolves it, where a requireless go.mod could fail vanity discovery.
- Make the precompiled binary (curl install.sh) the recommended install in
  the docs; demote 'go install' to a from-source option with the version-pin
  fallback note.
- Sync the stale internal/scripts/install.sh to the working website script
  (it expected an old micro-OS-ARCH asset name; releases ship
  micro_OS_ARCH.tar.gz).

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

* website: add corrected nginx vanity-import config (#2985)

The live go-micro.dev handler echoed the full request path into the
go-import prefix ($host$1), so go install go-micro.dev/v6/cmd/micro@latest
got prefix go-micro.dev/v6/cmd/micro — a package, not the module root — and
Go fell back to the ancient v1.x tags (version constraints conflict).

Add a dedicated /vN location that emits the module root (go-micro.dev/vN)
for any sub-path, and make the catch-all advertise the current module roots
instead of echoing arbitrary paths.

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-06-22 10:22:25 +01:00
Asim Aslam 456ff18176 examples: support desk agent + blog walkthrough (#2984)
A real-world, runnable example (examples/support): customers/tickets/notify
services become the agent's tools, a flow turns a ticket.created event into
the agent's work, and an approval gate guards the one action that touches a
customer. Runs with no API key (mock model) or against a live provider.

Adds blog/28 'Building a Support Agent in Go' and indexes both.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-20 10:23:24 +01:00
Asim Aslam cdc6ee9aa1 examples: support desk agent + blog walkthrough (#2983)
A real-world, runnable example (examples/support): customers/tickets/notify
services become the agent's tools, a flow turns a ticket.created event into
the agent's work, and an approval gate guards the one action that touches a
customer. Runs with no API key (mock model) or against a live provider.

Adds blog/28 'Building a Support Agent in Go' and indexes both.


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

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-19 21:55:50 +01:00
Asim Aslam e5bf72abd8 docs: consolidate to a single agentic/DX roadmap (#2982)s
Replace the drifted, contradictory roadmap set (two public roadmaps, the
AI-native-era business-model doc, stale status snapshots) with one
canonical roadmap focused on agentic development and developer experience.

- internal/website/docs/roadmap.md — the single source of truth: where we
  are (v6), the principles (build into what people run; CLI-first; the
  0->1 and 0->hero getting-started contract; interaction; battle-tested),
  and prioritized work (cross-provider conformance + resilience now;
  durable agent loop, streaming, observability next).
- ROADMAP.md — concise, points to the canonical.
- roadmap-2026 + the internal ROADMAP_2026/STATUS docs — collapsed to
  pointers (keeps blog/CLAUDE links alive, removes drift).
- CLAUDE.md — references the single roadmap + CHANGELOG for status.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-19 20:11:02 +01:00
Asim Aslam 232126476a Revise acknowledgment for sponsorship and Discord invite
Updated the acknowledgment section to reflect sponsorship and invite discussion.
2026-06-19 19:27:13 +01:00
Asim Aslam 358b2ffd27 Update blog post to refine 'Flows' definition
Clarified the definition of 'Flows' to include agents and corrected minor wording issues.
2026-06-19 17:53:44 +01:00
Asim Aslam 45346337e2 Update V3 description for clarity and accuracy
Clarified the description of V3 and its market reception.
2026-06-19 17:50:21 +01:00
Asim Aslam 4b8b4d7bab Fix typo in blog post about Go Micro development 2026-06-19 17:35:57 +01:00
Asim Aslam 97f932a2e7 Revise blog post on Go Micro's revival and evolution
Updated the blog post to reflect the journey of Go Micro, including its revival and the integration of agents into the framework. Enhanced clarity and structure throughout the text.
2026-06-19 17:23:15 +01:00
Asim Aslam ca87efef2f feat(agent): expose run metadata + structured guardrail reasons to tool wrappers (#2981)
goreleaser / goreleaser (push) Waiting to run
Closes the remaining ask in #2980 without adding a parallel callback API.
ToolResult.Refused tags a guardrail block with a reason (ai.RefusedLoop /
RefusedMaxSteps / RefusedApproval) so a wrapper can switch on it instead of
parsing the message. ai.RunInfo (RunID, ParentID, Agent) rides on the
context passed to the tool handler, giving wrappers run correlation and
delegation lineage. Before/after/retry/failure were already covered by
AgentWrapTool; this adds the metadata. Docs + tests included.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-19 08:09:22 +01:00
Asim Aslam d54cfab1bd blog: Bringing an Open Source Project Back from the Dead (#27) (#2979)
A first-person journey of Go Micro from January 2015 through the
VC/company era, the platform pivot, the quiet years, and the revival via
the Claude Code grant — into v6 and the services/agents/flows model.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-18 19:41:08 +01:00
Asim Aslam 46c56ef7df test(natsjs): fix flaky TempDir cleanup race + vet-unsafe Fatalf (#2978)
TestSingleEvent intermittently failed on cleanup with 'directory not
empty': the embedded NATS JetStream server was still releasing files when
t.TempDir's RemoveAll ran at test end. Own the store dir (os.MkdirTemp)
and remove it only after server.Shutdown()+WaitForShutdown(). Also report
setup errors with Errorf instead of Fatalf/require, which are unsafe from
the non-test goroutine (go vet).

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-18 12:06:19 +01:00
Asim Aslam c7657f73f4 Refactor agent plan storage, update docs, and release v6 (#2977)
goreleaser / goreleaser (push) Waiting to run
* test(harness): read agent plan from the scoped store

The store-scoping change moved an agent's plan from the default table
key agent/{name}/plan to its own table (database "agent", table {name},
key "plan"). The plan-delegate harness tests still read the old key and
failed with 'not found'; read through store.Scope(mem, "agent", name)
like the agent does.

* docs: orient agents-first across README, landing, and docs overview

Lead with agents (then services and flows), surface MCP + A2A as the
interop story, and frame agents as services. Landing hero and feature
grid reordered agents-first with an A2A gateway card.

* v6: module path go-micro.dev/v6, TLS secure by default, NewService

Cut v6. Three breaking changes, bundled so the major bump is paid once:

- Module path go-micro.dev/v5 -> go-micro.dev/v6 across all imports + go.mod.
- TLS verification on by default (was off). MICRO_TLS_SECURE removed;
  MICRO_TLS_INSECURE=true opts out for self-signed/dev.
- micro.NewService(name, opts...) is the canonical service constructor,
  symmetric with NewAgent/NewFlow; micro.New kept as a deprecated alias;
  the old name-less NewService(opts...) removed. Generators emit NewService.

Also ports the JWT auth token provider in-module (go-micro.dev/v6/auth/jwt/token
on golang-jwt/jwt/v5), dropping the v5-pinned github.com/micro/plugins/v5/auth/jwt
and the deprecated dgrijalva/jwt-go.

Docs/README/landing updated to v6 and @latest; v5->v6 migration guide added;
CHANGELOG cut as [6.0.0]. Blog posts left at their historical versions.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-18 11:55:35 +01:00
Asim Aslam b9586f920b test(harness): read agent plan from the scoped store (#2976)
The store-scoping change moved an agent's plan from the default table
key agent/{name}/plan to its own table (database "agent", table {name},
key "plan"). The plan-delegate harness tests still read the old key and
failed with 'not found'; read through store.Scope(mem, "agent", name)
like the agent does.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-18 11:04:14 +01:00
Asim Aslam f42c9d1d69 feat(a2a): agents can serve A2A directly, no gateway required (#2975)
Refactor the A2A handler into a reusable dispatcher + Invoke seam and
expose NewAgentHandler(card, invoke) + Card(). An agent now serves its
own A2A endpoint with AgentA2A(addr) / WithA2A — handling tasks
in-process (no RPC hop, no separate gateway). The gateway and embedded
agent share the same handler; the only difference is RPC vs in-process
invocation. Docs, README, and changelog cover both deployment modes.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-18 10:57:38 +01:00
Asim Aslam 307b94aab7 Implement A2A protocol gateway and update documentation (#2974)
goreleaser / goreleaser (push) Waiting to run
* feat(a2a): Agent2Agent protocol gateway

Add gateway/a2a — exposes registered agents over the open A2A protocol so
agents on other frameworks can discover and call them. Agent Cards are
generated from registry metadata (the same way the MCP gateway derives
tools from service endpoints); incoming A2A tasks translate to the
agent's existing Agent.Chat RPC, so there's no per-agent code.

v1 is the synchronous JSON-RPC binding: message/send returns a completed
Task, tasks/get retrieves it, and Agent Cards are served for discovery;
streaming and push notifications are advertised as unsupported. Run with
'micro a2a serve' (cmd/micro/a2a). Tests cover card generation,
message/send, tasks/get, listing, and unknown-method errors.

* docs: A2A guide, README contents + A2A section, universe A2A check

- Add a Contents table of contents at the top of the README and an A2A
  subsection under Building Agents.
- Add the Agent2Agent (A2A) guide and register it in the docs nav.
- Exercise the A2A gateway in the universe harness: the concierge agent
  is reached over A2A (message/send -> Agent.Chat -> completed task).

* feat(a2a): outbound client — call external A2A agents

Add a2a.Client (Send/Card) so a Go Micro agent or flow can call an agent
on any framework by URL — the outbound counterpart to the gateway. Wired
in two places: flow.A2A(url) as a workflow step (the cross-framework
Dispatch), and agent delegate to an http(s) URL routes over A2A. The
universe harness now drives the gateway through the client, exercising
both directions. Tests cover client send/card and the round trip.

* docs: A2A both-directions — guide, README, changelog, blog #26

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 20:33:20 +01:00
Asim Aslam 7ee32e8721 Update changelog and enhance retrospective blog on agentic features (#2973)
* docs: changelog catch-up + retrospective blog (agentic development)

Add the headline agentic features that shipped this quarter but were
never logged (agents, plan/delegate, guardrails, workflows, x402) to the
changelog, and add blog #25 — a three-month progress reflection on Go
Micro becoming a framework for agentic development.

* docs: tighten retrospective post — cut slogans, triads, and filler

* docs: tighten retrospective intro, bridge, and conclusion for a single through-line

* docs: frame Go Micro as how you build a distributed system, not run one

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 12:29:25 +01:00
Asim Aslam 9fdcc24cce Implement durable execution and scoped state management for flows (#2972)
* docs: design note for flow steps + Checkpoint durable execution

* docs: fold in durable-execution decisions (State struct, single Step, run retention, retry)

* docs: rename State.Payload to State.Data

* feat(flow): ordered steps + Checkpoint durable execution

A flow can now be an ordered list of steps (a task with stages) instead
of a single LLM turn. State carries typed Data plus a Stage marker; each
step is checkpointed before and after via a pluggable Checkpoint
(store-backed by default), so a run survives a crash and resumes where it
stopped without re-running completed steps. Flow-level Retry with a
per-step override; runs retained for audit unless DeleteOnSuccess.

Step actions: Call (RPC), LLM (augmented turn), Dispatch (to an agent),
or any StepFunc. Single-step and agent-dispatch flows are unchanged.

* feat(flow): top-level re-exports + durable flow example

Expose the step/checkpoint API from the micro package (FlowSteps,
FlowStep, FlowState, FlowRetry, FlowWithCheckpoint, FlowCall/LLM/Dispatch,
Checkpoint, StoreCheckpoint) and add a runnable, key-free example
demonstrating crash + resume.

* docs: document durable flow steps (guide, README, CLI help)

* docs: blog post + changelog for durable workflows

* fix(flow): scope checkpoint keys by flow name (flow/{name}/runs/{id})

Run keys were flow/runs/{id} — a single global keyspace shared by every
flow on the default store. Namespace them by flow name so each flow's
state is kept apart. StoreCheckpoint now takes a scope argument (the flow
passes its name by default).

* feat(store): Scope handle; scope agent and flow state by name

Add store.Scope(s, database, table) — a store handle that confines every
operation to a database/table without mutating the shared store, so
co-located components don't clobber each other's table (the failure mode
of the global Init(Table(...)) approach).

Use it to keep each agent's memory and plan in its own table
(agent/{name}) and each flow's runs in its own (flow/{name}), instead of
one global table partitioned only by key prefix. Services already scope
by service name.

* feat: consistent state model — service store scoping, flow registry, list/history CLI

- service: scope store via store.Scope (database service / table name),
  retiring the Init(store.Table(name)) global-mutation hack; bridge the
  default store so handlers using store.DefaultStore stay isolated.
- flow: register in the registry as type=flow while running (with trigger
  and step count), deregister on Stop. Live discovery, like agents.
- cli: micro flow list (registry), micro flow runs <name> (durable store),
  micro agent history <name> (durable store). list = running, runs/history
  = durable, mirroring the service model.

* test: mini-universe end-to-end harness + scheduled GitHub Action

internal/harness/universe boots a small but real go-micro world — four
services, a durable checkout flow that crashes at payment and resumes,
and a guardrailed agent with a tool wrapper reached over RPC — drives the
scenario, asserts the end state (10 checks), and shuts down. Everything
is real except the LLM (mocked), so it's deterministic and needs no key;
-provider anthropic runs it live. Exits non-zero on failure, so it's an
end-to-end test, not just a demo.

Adds .github/workflows/universe.yml (push/PR/daily/dispatch) running the
universe + existing harnesses on the mock provider, plus an opt-in job
that runs live when ANTHROPIC_API_KEY is set. 'make harness' runs them
locally.

* ci: run the live universe job against AtlasCloud (ATLASCLOUD_API_KEY)

* ci: run the live universe job only on schedule or manual dispatch

The deterministic mock job still runs on push/PR/daily; the live
(AtlasCloud) job runs daily and on manual workflow_dispatch only, so
changes don't burn API credits on every PR but can still be checked
against a real model on demand.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 12:02:47 +01:00
Asim Aslam 6b4ce55a7c Implement tool-execution wrappers and update documentation (#2971)
* feat(agent): tool-execution wrappers via WrapTool

Restructure ai.ToolHandler to the structured, ctx-carrying shape that
mirrors a go-micro RPC handler:

    func(ctx context.Context, call ai.ToolCall) ai.ToolResult

This reuses the existing ToolCall (with its correlation ID) and
ToolResult types instead of the flat (name, input)->(any, string)
signature, and adds ToolCall.Scan for typed argument access.

Add ai.ToolWrapper and the agent option WrapTool / micro.AgentWrapTool —
the tool-side analogue of client.CallWrapper and server.HandlerWrapper.
Reframe the built-in guardrails (MaxSteps, LoopLimit, ApproveTool) as
composed wrappers around a base handler; developer wrappers compose
outermost, so they observe every call and result, including refusals.

Update all provider call sites, the MCP server and chat handlers, the
integration harnesses, and docs to the new signature.

* examples: add agent-wrap-tool showing AgentWrapTool

A runnable example of tool-execution middleware: an observe wrapper that
times calls and records per-tool metrics (correlated by call ID), and a
retry wrapper that recovers a flaky service call before the model sees
it. Demonstrates outermost-first composition and the wrapper/guardrail
interaction (retries are seen by loop detection).

* docs: note AgentWrapTool in README capabilities and CHANGELOG

* fix(generate): pin scaffolded go.mod to one version constant

The two generators pinned different, stale go-micro versions (v5.24.0
for services, v5.25.0 for agents). Centralize on a single
goMicroVersion constant (v5.29.0) so generated services and agents stay
in sync with the framework and there's one place to bump on release.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-17 09:52:27 +01:00
Asim Aslam ce0741a80c Implement tool-execution wrappers and restructure ToolHandler (#2970)
* feat(agent): tool-execution wrappers via WrapTool

Restructure ai.ToolHandler to the structured, ctx-carrying shape that
mirrors a go-micro RPC handler:

    func(ctx context.Context, call ai.ToolCall) ai.ToolResult

This reuses the existing ToolCall (with its correlation ID) and
ToolResult types instead of the flat (name, input)->(any, string)
signature, and adds ToolCall.Scan for typed argument access.

Add ai.ToolWrapper and the agent option WrapTool / micro.AgentWrapTool —
the tool-side analogue of client.CallWrapper and server.HandlerWrapper.
Reframe the built-in guardrails (MaxSteps, LoopLimit, ApproveTool) as
composed wrappers around a base handler; developer wrappers compose
outermost, so they observe every call and result, including refusals.

Update all provider call sites, the MCP server and chat handlers, the
integration harnesses, and docs to the new signature.

* examples: add agent-wrap-tool showing AgentWrapTool

A runnable example of tool-execution middleware: an observe wrapper that
times calls and records per-tool metrics (correlated by call ID), and a
retry wrapper that recovers a flaky service call before the model sees
it. Demonstrates outermost-first composition and the wrapper/guardrail
interaction (retries are seen by loop detection).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-16 17:26:30 +01:00
Asim Aslam 5e5d253abd feat(agent): tool-execution wrappers via WrapTool (#2969)
Restructure ai.ToolHandler to the structured, ctx-carrying shape that
mirrors a go-micro RPC handler:

    func(ctx context.Context, call ai.ToolCall) ai.ToolResult

This reuses the existing ToolCall (with its correlation ID) and
ToolResult types instead of the flat (name, input)->(any, string)
signature, and adds ToolCall.Scan for typed argument access.

Add ai.ToolWrapper and the agent option WrapTool / micro.AgentWrapTool —
the tool-side analogue of client.CallWrapper and server.HandlerWrapper.
Reframe the built-in guardrails (MaxSteps, LoopLimit, ApproveTool) as
composed wrappers around a base handler; developer wrappers compose
outermost, so they observe every call and result, including refusals.

Update all provider call sites, the MCP server and chat handlers, the
integration harnesses, and docs to the new signature.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-16 16:26:56 +01:00
Asim Aslam e079e083da feat(agent): loop detection guardrail + document/blog agent guardrails (#2968)
Add LoopLimit: refuse a tool call repeated with identical arguments in
one Ask, with a self-heal message so the model changes approach. Catches
the no-progress loop that MaxSteps (count) and the gateway circuit
breaker (failures) miss. Enforced at the same tool-handler choke point as
MaxSteps/ApproveTool; on by default (lenient 3); AgentLoopLimit(0) to
disable. Tests cover repeats, distinct calls, disabled, and default-on.

Docs: new Agent Guardrails guide (MaxSteps/LoopLimit/ApproveTool, the
ApproveTool integration seam for external policy engines, and the
gateway's RateLimit/CircuitBreaker), nav + README + AGENT_DESIGN updates,
and blog/23 'Agent Guardrails'.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-16 14:40:05 +01:00
Asim Aslam a1a57799c3 Add payment requirements to tool catalog and implement x402 client (#2966)
* feat(mcp): advertise x402 payment requirements in the tool catalog

/mcp/tools now includes each priced tool's payment requirements (amount,
network, asset, payTo) when payments are enabled, so an agent can see the
cost before calling and choose by price — a shoppable catalog, the
foundation for a tool marketplace. Free tools carry no payment block; the
shared Tool struct is copied when pricing so it isn't mutated. Tests cover
priced/free tools and payments-disabled. Documented in the payments guide.

* feat(x402): consumer client with a spend budget (pay-and-retry)

Add x402.Client, the consumer counterpart to Middleware: it settles 402
challenges automatically via a pluggable Payer, up to a spend Budget. A
call that would exceed the budget is refused before any payment is made,
and spend accumulates across calls — the spend cap that keeps an
autonomous, paying caller in bounds. Tests cover pay-within-budget,
refuse-over-budget, budget accumulation, and free endpoints, end to end
against the server Middleware with a mock facilitator and payer. Guide
documents the consumer side; agent-level AgentMaxSpend is the next step.

* chore: gofmt gateway/mcp/benchmark_test.go (trailing newline)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 21:12:29 +01:00
Asim Aslam e4d2a41c32 refactor(x402): Amount/Amounts naming + per-tool amounts + docs (#2965)
goreleaser / goreleaser (push) Waiting to run
Follow-up to the merged x402 integration (#2964). Drop the commerce-y
'price' vocabulary for the protocol's own 'amount', and add per-tool
pricing as an operator concern (the way scopes/rate-limits are set at the
gateway).

- x402.Config: Price -> Amount (default), plus Amounts map for per-tool
  overrides; AmountFor(tool) resolves per-tool -> default. Add a Require
  primitive (per-request enforcement) and LoadConfig for an operator
  config file.
- MCP gateway: enforce payment per-tool inside /mcp/call (where scopes
  are enforced) using AmountFor, instead of a flat path-based middleware.
- CLI: --x402-price -> --x402-amount; add --x402-config (per-tool file)
  to micro mcp serve and micro-mcp-gateway.
- docs: new Payments (x402) guide + nav + README section; blog/22
  updated to Amount/Amounts and the config-file model.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 17:04:34 +01:00
Asim Aslam 9deac487cb feat(x402): opt-in agent-native payments for tools (#2964)
Integrate the x402 payment protocol (HTTP 402) so a tool can require a
stablecoin payment and an agent can settle it — the next step after
autonomous agents (blog 21): agents that act, and pay.

- wrapper/x402: HTTP middleware enforcing the 402 challenge/verify flow,
  with a pluggable Facilitator interface. Go Micro carries no chain or
  crypto code — verification/settlement is delegated to a facilitator
  (Coinbase CDP, Alchemy, self-hosted), so Base and Solana are just
  different facilitators behind one interface. HTTPFacilitator default;
  tests cover challenge / accept / reject via a mock facilitator.
- MCP gateway: optional Options.Payment gates /mcp/call (listing tools
  and health stay free); off unless configured.
- micro mcp serve and micro-mcp-gateway: opt-in --x402-pay-to/-price/
  -network/-facilitator flags (env vars on the standalone binary).
- blog/22 'Integrating x402: Payments for Agents'; README feature row.

Pricing is flat per call for now; richer models and an agent-side spend
cap (next to MaxSteps/ApproveTool) are follow-ups.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 16:41:25 +01:00
Asim Aslam 6eec83a045 blog: 'When the Event Is the Prompt' (#21) + autonomous agent-flow harness (#2962)
The autonomy direction: agents that run on events, not human prompts.

- internal/harness/agent-flow: a runnable, deterministic demo — a
  user.created broker event drives a Flow that hands off to a registered
  agent (FlowAgent), which creates a workspace and sends a welcome over
  real RPC. Only the LLM is mocked; passes under -race.
- blog/21: 'When the Event Is the Prompt' — the shift from agents you
  talk to, to agents that act on their own; where microagents become
  real; and the honest bar autonomy raises (guardrails, observability,
  durable/resumable execution — the next things to build).

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-15 12:16:08 +01:00
Asim Aslam 9dae4e34b7 Enhance README with sponsorship CTA and improve agent architecture (#2961)
* docs: add 'become a sponsor' call-to-action linking to Discord

Now that there are a couple of sponsors, invite more: a short CTA under
the Sponsors section in the README and on the landing page, pointing to
the Discord to get in touch.

* fix(health): remove duplicate RegistryCheck declaration

Two PRs (#2957 and #2958) each added a RegistryCheck to the health
package, leaving the package uncompilable on master (RegistryCheck
redeclared: health/registry.go vs health/health.go). Keep the
health.go implementation — it honors the check's context timeout so a
hung registry (e.g. an unreachable etcd) reports down instead of
blocking the probe — and remove the duplicate registry.go and its test.
registry_check_test.go already covers healthy/down/nil/timeout/not-ready.

* feat(agent): pluggable memory and custom tools

Make agents compose the way services do — pluggable pieces with working
defaults — by adding the two abstractions an agent needs beyond the model:

- Memory: a pluggable interface for conversation memory. The default is
  store-backed and durable across restarts (the previous hardcoded
  behavior, now behind an interface); supply your own with WithMemory
  (in-memory, database, semantic store). NewMemory / NewInMemory provided.
- Custom tools: WithTool registers any function as a tool the agent can
  call, so agents are no longer limited to orchestrating RPC services.

Both exposed at the micro package (AgentMemory, AgentTool, NewMemory,
NewInMemory). Behavior-preserving refactor of the agent's history into
the default Memory; tests cover persistence, in-memory, clear, custom
tool dispatch and errors. README + AGENT_DESIGN document the pluggable
composition (model / memory / tools / guardrails).

* blog: 'Doubling Down on Agents' (#20)

The vision post for making agents a first-class framework the way
services were: opinionated, batteries-included, pluggable. Frames an
agent as a composition of model + memory + tools + guardrails with
working defaults; introduces the new pluggable memory and custom tools;
makes the microagents argument (an agent for everything, distributed
like microservices); and lays out the three primitives — services,
agents, workflows — as one substrate, with an honest list of the gaps
still to fill (knowledge/retrieval, streaming, explicit loop).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-10 11:04:23 +01:00
Asim Aslam b41dfa02f2 docs: add 'become a sponsor' call-to-action linking to Discord (#2960)
Now that there are a couple of sponsors, invite more: a short CTA under
the Sponsors section in the README and on the landing page, pointing to
the Discord to get in touch.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-10 10:30:10 +01:00
Copilot 0b41c71681 feat(health): add RegistryCheck for registry connectivity health checks (#2957)
goreleaser / goreleaser (push) Waiting to run
* Initial plan

* feat(health): add RegistryCheck for registry connectivity health checks

Add a RegistryCheck function to the health package that creates a health
check verifying connectivity to the service registry. This enables
Kubernetes readiness probes to detect when a service loses its connection
to the registry (e.g. etcd).

Usage:
  health.Register("registry", health.RegistryCheck(reg))

* fix(health): simplify error assertion in registry check test

---------

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
2026-06-10 08:25:23 +01:00
Asim Aslam 550033dcce Optimize image formats and fix lease re-registration issue (#2959)
* perf: convert generated PNGs to optimized JPEGs (12MB -> 1.5MB)

The landing and docs loaded 18 AI-generated PNGs at 0.5-1MB each. They're
1200x800 RGB illustrations with no transparency, so they recompress ~8x
as progressive JPEG (quality 82) with no visible loss. Convert all,
update every reference (.png -> .jpg), and drop the originals (including
the unused hero.png). Generated images: 12.3MB -> 1.5MB.

* fix(registry/etcd): re-register when a lease silently expires (#2956)

The keepalive rework (long-lived KeepAlive instead of KeepAliveOnce)
moved lease renewal entirely onto the keepalive goroutine; the 30s
periodic Register now skips on the 'unchanged' check. The goroutine only
reacted to the keepalive channel closing, so a lease that expired
server-side without a prompt channel close (e.g. a partition that
outlasted the 90s TTL) left the node de-registered from etcd while the
cache still believed it was registered — and nothing re-registered it.
That is the hidden-failure mode reported in #2956.

React to a non-positive TTL keepalive response the same as a channel
close: drop the cached lease/hash so the next Register performs a full
re-registration. Extract the loop into keepAliveLoop and unit-test the
TTL-expired, channel-closed, and healthy paths (no etcd required).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-10 08:24:49 +01:00
Asim Aslam 584a9f2132 feat(health): add RegistryCheck for registry connectivity (#2956) (#2958)
A go-micro service can keep running while it has silently lost its
connection to the registry (etcd, Consul, …) — the process looks healthy
but other services can no longer discover it, and Kubernetes sees the
pod as fine. health.RegistryCheck(reg) probes connectivity via
ListServices and, registered as a critical check, makes /health/ready
report not-ready so a readiness probe can pull the pod from rotation.

- Works with any registry implementation (no interface change).
- Honors the check timeout: an unreachable/hung registry is reported
  down rather than blocking the probe.
- Tests cover healthy, down, timeout, nil, and the not-ready integration.
- Documented in the health guide with the Kubernetes readiness example.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-10 08:18:00 +01:00
Asim Aslam 6488d8402d Organize README features, enhance testing, and update docs (#2955)
* docs: group README features by section (AI / Framework / DX)

The features table repeated 'AI' down the Category column. Split into
three grouped tables — AI, Framework, Developer experience & deployment —
dropping the repetitive column. Adds a Guardrails row (MaxSteps,
ApproveTool).

* test: flow-to-agent end-to-end in the harness

Proves 'Flow triggers, Agent reasons': a workflow with FlowAgent hands an
event to the registered conductor agent over RPC, which plans, creates
tasks, and delegates to comms — the whole chain over real RPC with only
the LLM mocked. Deterministic (shared in-memory registry, no sleeps),
passes under -race.

* blog: 'The Evolution of Microservices' (#19)

A technical history of distributed-systems eras — the monolith's
coordination cost, the distributed-systems tax, containers and
declarative orchestration, the service mesh, and the modular-monolith
correction — establishing the durable unit (named, typed, discoverable,
independently deployable) that every runtime wave required. Then the
technical argument for agents: an LLM tool call needs exactly a service
interface, so the caller shifts from deterministic code to a reasoner
that composes typed capabilities from intent, with the honest caveats
(non-determinism, cost, guardrails). Not a product pitch.

* docs: bump install version to v5.27.0

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-08 10:16:05 +01:00
Asim Aslam d3be610367 Refactor README features and add end-to-end flow testing (#2954)
goreleaser / goreleaser (push) Waiting to run
* docs: group README features by section (AI / Framework / DX)

The features table repeated 'AI' down the Category column. Split into
three grouped tables — AI, Framework, Developer experience & deployment —
dropping the repetitive column. Adds a Guardrails row (MaxSteps,
ApproveTool).

* test: flow-to-agent end-to-end in the harness

Proves 'Flow triggers, Agent reasons': a workflow with FlowAgent hands an
event to the registered conductor agent over RPC, which plans, creates
tasks, and delegates to comms — the whole chain over real RPC with only
the LLM mocked. Deterministic (shared in-memory registry, no sleeps),
passes under -race.

* blog: 'The Evolution of Microservices' (#19)

A technical history of distributed-systems eras — the monolith's
coordination cost, the distributed-systems tax, containers and
declarative orchestration, the service mesh, and the modular-monolith
correction — establishing the durable unit (named, typed, discoverable,
independently deployable) that every runtime wave required. Then the
technical argument for agents: an LLM tool call needs exactly a service
interface, so the caller shifts from deterministic code to a reasoner
that composes typed capabilities from intent, with the honest caveats
(non-determinism, cost, guardrails). Not a product pitch.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-08 09:09:48 +01:00
Asim Aslam 35bc58e5d2 Enhance onboarding experience and clarify workflows vs agents (#2953)
* blog: 'Not Everything Should Be an Agent' (#18) on workflows

The workflow counterpart to the plan/delegate post: when the path is
known, use a deterministic Flow, not an autonomous agent. Frames flow vs
agent as two modes of the same building blocks, covers flow-triggers-
agent dispatch and the agent guardrails, and gives the simplest-first
guidance (single call -> workflow -> agent). Continues the arc from
blog 14/16/17; references Building Effective Agents in passing.

* docs: fix new-user onboarding friction

- README: lead Quick Start with a no-key 30-second path (micro new ->
  micro run -> curl), then the AI --prompt path with an explicit
  'export ANTHROPIC_API_KEY' so the headline command no longer fails
  silently for users without a key.
- Unify all install versions to v5.26.0 (README + docs were split across
  v5.16.0 / v5.25.0).
- Refresh the docs landing overview from the old 'microservices
  framework' framing to 'services and agents', matching the README.
- getting-started: add Prerequisites (Go 1.21+, and that a provider key
  is only needed for AI features).
- README features: Flows -> Workflows wording.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-08 08:52:11 +01:00
Asim Aslam e416ea4a75 Enhance agent workflows with guardrails and documentation updates (#2952)
* docs: map go-micro onto Anthropic's workflows-vs-agents taxonomy

- new guide 'Agents and Workflows': adopts Anthropic's Building Effective
  Agents vocabulary — workflow (predefined path) = flow, agent (dynamic
  self-direction) = agent — maps the augmented-LLM building block and the
  five workflow patterns onto go-micro, and shows routing (chat router)
  and orchestrator-workers (conductor + plan/delegate) are already native.
- flow package doc reframed as a workflow (predefined path) per the same
  taxonomy, with guidance on flow vs agent.
- nav + README link the new guide.

* feat: agent guardrails — step limit and tool approval hook

Anthropic's Building Effective Agents stresses stopping conditions and
human-in-the-loop checkpoints for autonomous agents. Add both as plain
options enforced at the tool-handler choke point — no provider changes,
no new abstraction:

- MaxSteps(n): bound tool executions per Ask; beyond the limit, actions
  are refused and the model is told to stop and summarize.
- ApproveTool(fn): gate each action before it runs; returning false
  blocks it and surfaces the reason to the model. The internal plan tool
  is never gated.

Exposed at the micro package (AgentMaxSteps, AgentApproveTool, ApproveFunc).
Tests cover the limit, blocking, and that plan is not gated. Guardrails
section of the agents-and-workflows guide updated from 'active work' to
documented options.

* feat: flow can dispatch to an agent (flow triggers, agent reasons)

Unify the engine without collapsing the workflow/agent distinction. A
Flow with Agent set hands each event's rendered prompt to a named
registered agent over RPC (Agent.Chat) instead of running its own LLM
step — so the workflow stays the deterministic trigger and the agent is
the reasoning engine, with its plan, delegate, memory, and guardrails.
A plain flow is unchanged (single augmented-LLM step).

- flow.Agent(name) / micro.FlowAgent(name); flow stores the client and
  skips model setup when dispatching.
- test: dispatch routes to comms.Agent.Chat with the rendered prompt and
  records the reply.
- guide: 'Flow triggers, Agent reasons' section.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-08 08:32:32 +01:00
Asim Aslam 830c8d84c2 Claude/loving meitner 3 etoi (#2951)
* rename coordinator agent to conductor in example and docs

* add plan & delegate integration harness

Runs the real go-micro stack end to end — services, registry, RPC, the
agent loop, store, and delegate-first routing — with only the LLM mocked
by a deterministic provider. Proves discovery, tool execution, plan
persistence, and agent-to-agent delegation over RPC work without an API
key; swap the provider to run the same flow against a live model.

* test: deterministic CI integration test + provider flag for harness

- main_test.go: TestPlanDelegateEndToEnd drives the full real stack
  (services, RPC, agent loop, store, delegate-first routing) over a
  shared in-memory registry — no mDNS, no sleeps. Asserts 3 tasks
  created via RPC, plan persisted to the store, and delegation reaching
  the comms agent (notify called once). Passes under -race, ~0.03s.
- main.go: add -provider flag (defaults to mock) and key detection so the
  same harness runs against a live model with no code change.

* chore: gitignore built harness/example binaries

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 19:28:24 +01:00
Asim Aslam 6a73608e9c rename coordinator agent to conductor in example and docs (#2950)
Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 18:33:04 +01:00
Asim Aslam cb33decd97 Add built-in plan and delegate tools for agents with examples (#2949)
* feat: add plan and delegate as built-in agent tools

Give agents two self-capabilities, expressed as plain tools wired into
the existing tool handler — no harness or graph, consistent with
"services are the only abstraction":

- plan: record/update an ordered plan, persisted to store-backed memory
  and surfaced in the system prompt on later turns (externalized
  planning).
- delegate: hand a self-contained subtask to another agent.
  Delegate-first — if the target names a registered agent it is called
  via RPC; otherwise a focused ephemeral sub-agent is created with
  agent.New + Ask in a fresh, isolated context (loads/persists no
  history, no built-in tools, so it cannot re-delegate).

Both are added automatically to any non-ephemeral agent, so existing
micro.NewAgent services and micro chat routing get them for free.
Tests are hermetic (memory store + memory registry).

* feat: add agent-plan-delegate example and document plan/delegate

- examples/agent-plan-delegate: coordinator that plans multi-step work,
  creates tasks with its own tools, and delegates notification to a
  separate registered comms agent over RPC.
- integration tests driving the full Ask loop through a fake provider:
  plan tool exposure + persistence, ephemeral delegation with isolated
  context, delegate-first RPC routing to a registered agent.
- docs: README (Building Agents + features + examples), AGENT_DESIGN
  (Built-in Capabilities), agent-patterns guide (Pattern 9), CLAUDE.md.

* docs: blog post and guide for plan & delegate

- blog/17: "Plan & Delegate: Deep Agents in Go" — what the feature is,
  how plan and delegate work, and a runnable getting-started path.
- guides/plan-delegate: reference guide with the smallest-agent snippet,
  plan/delegate semantics, and the multi-agent example; linked in nav.
- example: auto-detect provider/key from common env vars (ANTHROPIC_API_KEY,
  OPENAI_API_KEY, ...) so 'export KEY && go run main.go' just works.
- onboarding: getting-started paths now include go mod init / go get and a
  clone-and-run path, so a reader can actually run it from a cold start.

* refactor: reframe plan/delegate blog and clean up sub-agent construction

- blog/17 retitled "Agents That Plan and Delegate" and reframed around
  intent (plan = state intent, delegate = direct it), positioned as the
  next beat after blog 15/16 and tied to the existing store + agent RPC
  rather than re-announcing them. "Deep agents" now a single in-passing
  nod, matching how blog 14 references LangChain.
- agent: add unexported newEphemeral constructor for sub-agents instead
  of type-asserting the public Agent interface to set an internal field;
  matches the options-only construction idiom used elsewhere.

* feat: expose plan & delegate in the micro chat fallback

Add agent.Builtins(opts...) — returns the built-in tools plus a handler,
so the plan/delegate capabilities can be wired into a tool loop that
isn't a running Agent. micro chat's direct-service fallback now reuses
it (single source of truth, no duplicated handler logic), so planning
and delegation are available there too, not just for registered agents.
Adds a test for the accessor; notes CLI availability in the guide.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-07 10:55:34 +01:00
Asim Aslam 9bed04ced0 Enhance micro run with interactive console and image compression (#2948)
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
goreleaser / goreleaser (push) Waiting to run
* perf: compress hero image — 1.4MB to 80KB

Resized from 1536px to 1200px, converted to JPEG at quality 80.
80KB loads instantly vs 1.4MB stalling on slower connections.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* docs: micro run drops into interactive console

One command does everything — generate, start, and chat. No
separate micro chat step. The landing page shows micro run
dropping straight into the > prompt.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: interactive console in micro run, -d for detached mode

micro run now drops into an interactive chat console after services
start. The console discovers services, exposes them as tools, and
lets you talk to them through an LLM — same as micro chat but
built into the run experience.

- Detects MICRO_AI_PROVIDER and MICRO_AI_API_KEY from environment
- Falls back to provider-specific env vars (ANTHROPIC_API_KEY, etc.)
- If no API key, prints hint and blocks on Ctrl-C (no console)
- -d / --detach flag skips the console (background mode)
- Ctrl-C always shuts everything down

Removed adopters section from README.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 20:49:25 +01:00
Asim Aslam 2e89961386 perf: compress hero image — 1.4MB to 80KB (#2947)
Resized from 1536px to 1200px, converted to JPEG at quality 80.
80KB loads instantly vs 1.4MB stalling on slower connections.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 14:38:02 +01:00
Asim Aslam 39e8dc7311 Reposition hero section and optimize hero image for landing page (#2946)
* docs: reposition hero — framework for services and agents

Landing page: "Build Services and Agents in Go" — positions as a
framework, not a code generator. Tagline: "A framework for
microservices that AI agents can discover, use, and manage."

Hero command reverts to go get (the framework) instead of
micro run --prompt (a feature).

README matches: "framework for building services and agents in Go."

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* perf: compress hero image — 1.4MB to 80KB

Resized from 1536px to 1200px, converted to JPEG at quality 80.
80KB loads instantly vs 1.4MB stalling on slower connections.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 14:32:35 +01:00
Asim Aslam c93bcdf4d3 Update landing page documentation and fix flow CLI import (#2945)
* docs: remove code references from landing page feature grid

Landing page describes concepts, not APIs. Removed micro.NewAgent()
and micro.NewFlow() code references. Features described in plain
language — what they do, not how to code them.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* fix: update micro flow CLI to import from go-micro.dev/v5/flow

The flow package moved to top-level but the CLI still imported
from the old ai/flow path. Fixed to use go-micro.dev/v5/flow.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 11:20:40 +01:00
Asim Aslam cd760a29f1 Update landing page content and fix CLI import path (#2944)
* docs: remove code references from landing page feature grid

Landing page describes concepts, not APIs. Removed micro.NewAgent()
and micro.NewFlow() code references. Features described in plain
language — what they do, not how to code them.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* fix: update micro flow CLI to import from go-micro.dev/v5/flow

The flow package moved to top-level but the CLI still imported
from the old ai/flow path. Fixed to use go-micro.dev/v5/flow.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 11:12:00 +01:00
Asim Aslam e45d3df0ad docs: remove code references from landing page feature grid (#2943)
Landing page describes concepts, not APIs. Removed micro.NewAgent()
and micro.NewFlow() code references. Features described in plain
language — what they do, not how to code them.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 11:08:17 +01:00
Asim Aslam 6731ee2f0c Update documentation and blogs for RPC-based agent architecture (#2942)
* docs: update blog posts 15 and 16 to reflect RPC-based agents

Blog 15: replaced broker-based agent communication with RPC —
agents are services, they communicate via standard RPC, no pub/sub
hacks. Updated the framework mapping section.

Blog 16: added proto definition, micro call example, and explanation
that agents are real services with proto-defined endpoints. Updated
Ask() method name.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* docs: update agent design doc and blog posts for RPC-based agents

Rewrote AGENT_DESIGN.md — agents are services with proto-defined
Agent.Chat endpoints, communicate via RPC, no broker dependency.
Includes proto definition, CLI examples, generation output.

Blog 15: replaced broker references with RPC.
Blog 16: added proto definition and micro call example.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* docs: purge all stale broker-based agent references

Updated across all surfaces:
- README: agents described as services with RPC, Ask() not Chat(),
  micro call example instead of micro agent chat
- Blog 15: replaced broker communication with RPC description
- Blog 16: replaced "coordinate through the broker" with RPC
- Getting started: agent is a service with proto endpoint, Ask()
  not Chat(), added micro flow CLI commands (run/exec), expanded
  CLI workflow table

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 11:02:45 +01:00
Asim Aslam f7c042ef26 docs: update blog posts 15 and 16 to reflect RPC-based agents (#2941)
Blog 15: replaced broker-based agent communication with RPC —
agents are services, they communicate via standard RPC, no pub/sub
hacks. Updated the framework mapping section.

Blog 16: added proto definition, micro call example, and explanation
that agents are real services with proto-defined endpoints. Updated
Ask() method name.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 10:31:02 +01:00
Asim Aslam 1bc886fa82 Introduce Agent abstraction and integrate with chat router (#2939)
* docs: Agent interface design sketch

Proposes Agent as a top-level abstraction alongside Service in the
micro package. Agent manages services — scoped tools, system prompt,
conversation memory, registry-discoverable.

Design only, no implementation.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: Agent as a first-class abstraction

Introduce micro.NewAgent() alongside micro.New() — Agent is to
intelligence what Service is to capability.

Agent interface:
- Chat(ctx, message) (*Response, error) — core interaction method
- Run() — registers in registry, subscribes to broker, blocks
- Stop() — graceful shutdown
- Scoped tools — only sees endpoints of its assigned services
- Persistent memory — conversation history stored in store
- Agent-to-agent — communication via broker topics

Top-level API:
  agent := micro.NewAgent("task-mgr",
      micro.AgentServices("task"),
      micro.AgentPrompt("You manage tasks."),
      micro.AgentProvider("anthropic"),
  )
  agent.Run()

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: wire agents into chat router, add micro agent CLI, expose Flow

Three top-level abstractions:
  micro.New("task")              — Service (capability)
  micro.NewAgent("task-mgr")     — Agent (intelligence)
  micro.NewFlow("onboard-user")  — Flow (event-driven orchestration)

micro chat as router:
- Discovers agents from registry on startup
- Single agent: routes directly
- Multiple agents: LLM classifies intent, dispatches to right agent
  via route_to_agent tool
- No agents: falls back to current direct-service behaviour
- Banner shows discovered agents

micro agent CLI:
- micro agent list — shows registered agents and their services
- micro agent describe <name> — shows agent details from registry

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: move flow to top level, update docs for three abstractions

Package structure now consistent:
  service/   — Service (capability)
  agent/     — Agent (intelligence)
  flow/      — Flow (event-driven orchestration)

ai/flow/ kept as backward-compatible re-export.

Updated across all surfaces:
- CLAUDE.md: added agent/ and flow/ to project structure
- README: added "Building Agents" section with NewAgent() examples,
  updated features table (Agents, Flows, Chat router), CLI table
  (agent list, agent describe), docs links
- Website: features grid shows Services, Agents, Flows as the three
  pillars alongside generation, MCP, and pluggable architecture
- micro.go: Flow imported from top-level flow/ package

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* docs: rewrite getting-started, fix ai-integration import paths

Getting started now covers all three abstractions:
- Service (write handlers, micro run, templates)
- Agent (micro.NewAgent, scoped tools, memory, CLI)
- Flow (event-driven LLM orchestration)
Leads with prompt-based generation, then manual service creation.

ai-integration.md: fixed flow import path from go-micro.dev/v5/ai/flow
to go-micro.dev/v5/flow, updated stack diagram to show agent/flow/chat.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* blog: Introducing micro.NewAgent()

Post 16 — announces Agent as a first-class abstraction. Shows the
API (NewAgent, AgentServices, AgentPrompt, AgentProvider), scoped
tools, persistent memory, multi-service agents, multi-agent systems,
and the three-abstraction comparison table (Service/Agent/Flow).

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: fix agent registration, blog post 16

Agent registration:
- Add node with address so mDNS can discover agents
- Store type and services in node metadata (mDNS requirement)
- Connect broker before subscribing, non-fatal if broker unavailable
- Print registration confirmation on Run()

Agent/chat discovery:
- Check both service-level and node-level metadata for type=agent
  (mDNS stores metadata on nodes, not services)

Blog post 16: "Introducing micro.NewAgent()" — announces the Agent
abstraction with code examples, comparison table, multi-agent patterns.

Tested end-to-end: micro run → micro agent list discovers the agent →
micro chat routes to it → agent calls service endpoints.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: agents are proper services with RPC Chat endpoint

Refactored agent to use server.Server instead of fake registry entries.
An agent now:
- Creates a real RPC server with server.Name(agentName)
- Registers an Agent.Chat handler callable via standard RPC
- Sets server metadata type=agent, services=x,y for discovery
- No more fake addresses or broker hacks

micro chat calls agents via RPC (client.Call) instead of creating
local agent instances. The registry stays clean — agents are real
services with real endpoints.

Removed broker dependency from agent options. Agent-to-agent
communication is just RPC like everything else.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: agent uses proto-defined RPC interface

Added agent/proto/agent.proto with Agent service definition:
  rpc Chat(ChatRequest) returns (ChatResponse)

Agent now implements the generated AgentHandler interface and
registers via pb.RegisterAgentHandler. The Chat endpoint is a
standard proto-based RPC callable by any go-micro client.

Renamed the programmatic API from Chat() to Ask() to avoid
collision with the proto handler method name.

micro chat calls agents via standard RPC with JSON-encoded
request/response — no special types needed on the caller side.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: generate agent alongside services, update all docs

micro run --prompt now generates an agent binary that manages all
the generated services. The agent reads MICRO_AI_PROVIDER and
MICRO_AI_API_KEY from the environment. micro run propagates these
when started with --prompt.

Run banner shows services and agents separately.

Updated README, getting-started guide, and landing page to show
the complete flow: generate → services + agent start → micro chat
routes to agent → agent orchestrates services.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-05 10:25:14 +01:00
Asim Aslam 844ac5bc52 Revamp landing hero and introduce agent-based microservices model (#2938)
* docs: update landing hero — describe it, run it, talk to it

Lead with the AI-first experience instead of "write services in Go."
The entry point is now describing what you need, not writing code.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* blog: Agents for Services — a new model for microservices

Post 15 — explores the concept of distributed agents managing
services. Each service has an agent assigned to it (not embedded
in it). Agents are the intelligence layer; services are the
capability layer. Multi-service agents span domain boundaries.
Agent-to-agent communication through the broker.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 20:05:45 +01:00
Asim Aslam bc570d674f docs: update landing hero — describe it, run it, talk to it (#2937)
Lead with the AI-first experience instead of "write services in Go."
The entry point is now describing what you need, not writing code.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 18:51:41 +01:00
Asim Aslam e59d056f97 Update Discord invite link and enhance landing hero description (#2936)
* chore: update Discord invite link everywhere

Replace discord.gg/jwTYuUVAGh and discord.gg/go-micro with
discord.gg/WeMU5AGxD across all docs, blog posts, issue templates,
security policy, and contrib READMEs.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* docs: update landing hero — describe it, run it, talk to it

Lead with the AI-first experience instead of "write services in Go."
The entry point is now describing what you need, not writing code.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 17:50:11 +01:00
Asim Aslam e13ef7eed5 chore: update Discord invite link everywhere (#2935)
Replace discord.gg/jwTYuUVAGh and discord.gg/go-micro with
discord.gg/WeMU5AGxD across all docs, blog posts, issue templates,
security policy, and contrib READMEs.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 13:46:30 +01:00
Asim Aslam 23f682181c Update section title from 'The Bet' to 'The next step' 2026-06-04 11:53:31 +01:00
Asim Aslam bc1092b18a Restructure README for AI experience and framework clarity (#2934)
* docs: restructure README — AI story completes before manual code

Quick Start now flows through the full AI experience: generate →
review → run → chat → grow (mid-conversation service generation).
The reader sees the complete prompt-to-production story without
interruption.

"Writing Services" is a separate section below for developers who
want to understand the framework underneath. Shows Go code, doc
comments, @example tags, micro run, and scaffolding templates.

Features table and CLI table reordered: AI first, then framework.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* blog: Going All In on AI

Post 14 — the strategic case for making AI the primary direction.
Covers the evolution from microservices framework to AI-native
platform, why the timing is right (tool calling works, MCP is real,
sponsors align), and what's not changing (framework still works,
no agent framework complexity).

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 11:52:48 +01:00
Asim Aslam 16918669ca docs: restructure README — AI story completes before manual code (#2933)
Quick Start now flows through the full AI experience: generate →
review → run → chat → grow (mid-conversation service generation).
The reader sees the complete prompt-to-production story without
interruption.

"Writing Services" is a separate section below for developers who
want to understand the framework underneath. Shows Go code, doc
comments, @example tags, micro run, and scaffolding templates.

Features table and CLI table reordered: AI first, then framework.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 11:35:57 +01:00
Asim Aslam a357d1870d Update README and landing page with new AI-native hero image (#2932)
* docs: add binary install option to README quick start

Show curl install.sh first (no Go required), go install second.
Uses the existing install script at go-micro.dev/install.sh which
downloads pre-built binaries from GitHub releases.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* docs: regenerate hero image for new AI-native positioning

New hero shows terminal running micro run with service generation,
an AI agent orchestrating, and task/shipping/category service nodes.
Matches the "Microservices That AI Agents Can Use" headline.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* fix: replace video with hero image on landing page

The old video autoplayed and covered the new hero image. Replace
the video element with a static img tag showing the new AI-native
hero graphic.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 11:27:21 +01:00
Asim Aslam 91a545c6f2 Enhance README with binary install option and update hero image (#2931)
* docs: add binary install option to README quick start

Show curl install.sh first (no Go required), go install second.
Uses the existing install script at go-micro.dev/install.sh which
downloads pre-built binaries from GitHub releases.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* docs: regenerate hero image for new AI-native positioning

New hero shows terminal running micro run with service generation,
an AI agent orchestrating, and task/shipping/category service nodes.
Matches the "Microservices That AI Agents Can Use" headline.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 11:21:47 +01:00
Asim Aslam 00392aa1f2 docs: add binary install option to README quick start (#2930)
Show curl install.sh first (no Go required), go install second.
Uses the existing install script at go-micro.dev/install.sh which
downloads pre-built binaries from GitHub releases.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 11:15:40 +01:00
Asim Aslam b54507710d Revise README with new install command and examples
Updated installation command and added usage examples.
2026-06-04 11:14:40 +01:00
Asim Aslam d541625e32 docs: unify README and website around one story (#2929)
Both surfaces now lead with the same line: "Go Micro is a framework
for building microservices that AI agents can use."

README:
- Leads with prompt generation + chat (the differentiator)
- Features as a compact table instead of paragraph-per-feature
- CLI workflow table
- Removed redundant sections, tightened to ~150 lines

Website:
- Hero: "Microservices That AI Agents Can Use"
- Hero command: micro run --prompt instead of go get
- Features grid reordered: AI tools, orchestration, generation first
- First two-col section: describe/generate/run/chat story
- Architecture and DX sections follow

Both tell the same story in the same order:
1. What it is (microservices framework)
2. What makes it different (every service is an AI tool)
3. How you use it (prompt → run → chat)
4. What's underneath (registry, RPC, store — all pluggable)

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 11:13:03 +01:00
Asim Aslam 7662b26b07 syntax highlighting (#2928)
* docs: add blog post 13 to blog index

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* docs: add syntax highlighting to blog post 13 code blocks

Add language tags (bash, go, text) to all fenced code blocks so
Rouge highlights them correctly.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-04 08:19:52 +01:00
Asim Aslam 397010a82a docs: add blog post 13 to blog index (#2927)
https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 22:16:06 +01:00
Asim Aslam d3c4981326 Enhance AI service generation with prompt-based architecture and logic (#2926)
goreleaser / goreleaser (push) Waiting to run
* feat: add micro new --prompt and micro run --prompt

Add AI-powered service generation: describe a system in natural
language and get real go-micro services with proto definitions,
handlers, doc comments, and MCP support.

micro new --prompt "a contact book with notes and tags" \
  --provider anthropic

Generates:
  contacts/ — CRUD service with name, email, phone fields
  notes/    — notes linked to contacts
  tags/     — tagging system

Each service gets:
  proto/{name}.proto   — domain model + CRUD endpoints
  handler/{name}.go    — in-memory store, @example tags for MCP
  main.go              — MCP-enabled, proper imports
  go.mod + Makefile     — compiles with go mod tidy + make proto

micro run --prompt does the same then starts all services.

The LLM designs the architecture (service names, fields, endpoints,
descriptions) and returns structured JSON. Code generation uses
the existing template patterns — the output is standard go-micro
code that compiles, runs, and is immediately callable via MCP
and micro chat. No AI dependency at runtime.

* feat: LLM generates real business logic with compile-fix loop

Rebuild the generate package so the LLM writes actual handler
code with business logic, not just CRUD scaffolding.

The flow is now:
1. LLM designs architecture (service names, fields, endpoints)
   → returns structured JSON
2. Proto, main.go, go.mod, Makefile generated deterministically
   from the design (guaranteed to be correct)
3. go mod tidy + make proto compiles the protos
4. LLM generates handler code with REAL business logic
   → given the proto, endpoint descriptions, and go-micro patterns
5. go build — does it compile?
6. If no: feed errors back to LLM, get fixed code (up to 3 attempts)
7. If yes: service is ready

The handler prompt instructs the LLM to:
- Use sync.RWMutex for thread-safe in-memory state
- Include validation, edge cases, meaningful errors
- Write doc comments with @example tags for MCP
- Implement actual domain logic, not just map operations

Proto generation still uses deterministic templates (CRUD +
custom endpoints from the design spec) to guarantee correctness.
The compile-fix loop catches LLM mistakes automatically.

Both micro new --prompt and micro run --prompt use this flow.

* fix: handle edge cases in prompt-based generation

- Fix PATH for protoc-gen-micro in child processes
- Handle existing directories: skip structural files (main.go,
  go.mod, Makefile) if dir exists, always regenerate proto,
  only write placeholder handler if none exists
- Allow re-running micro new --prompt on same directory to
  iterate on business logic without clobbering user edits

Tested end-to-end: "a simple todo list with tasks and categories"
generates 2 services (task-service, category-service) with real
business logic (validation, toggle complete, etc.), compiles
after 1 fix iteration, and runs with 6 MCP tools discovered.

* feat: auto-detect modified handlers on regeneration

Instead of requiring a --keep-handlers flag, the generate package now
tracks a SHA-256 hash of each generated handler in a .micro metadata
file. On re-run, if the user has edited the handler since generation,
it's left untouched. Unmodified handlers are regenerated normally.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: add tests, fix go.mod, gitignore, proto tracking, spinner

- Add 12 tests covering helpers, proto generation, hash tracking
- Fix go.mod: write minimal module file, let go mod tidy resolve deps
- Add .gitignore to prompt-generated services
- Protect user-edited proto files (same hash tracking as handlers)
- Add spinner during LLM calls so it doesn't look hung

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: signal handling, existing service discovery, help text

- Ctrl+C during generation now cancels LLM calls immediately via
  signal-aware context; re-run picks up where it left off
- Design() scans for existing services in the working directory and
  includes their proto definitions in the prompt, so the LLM extends
  the system rather than redesigning from scratch
- Updated --prompt help text with usage examples on both new and run
- Listed all supported providers in flag descriptions
- Added discoverExisting test

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: show endpoints in run --prompt output, add micro chat hint

Print endpoint names and descriptions when designing services so users
see what was built. Add a micro chat hint to the run banner so users
know how to interact with their services after startup.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* fix: generated go.mod uses go 1.24 with explicit go-micro require

go 1.22 with no explicit require caused Go to resolve sub-packages
(gateway/mcp, client, server) as separate modules, hitting stale v1.18
tags. Pin to go 1.24 + require go-micro.dev/v5 v5.24.0 so go mod tidy
resolves all sub-packages from the root module correctly.

Tested end-to-end: 4 services generated and compiled successfully.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* fix: skip handler regeneration when proto unchanged

Compare proto hash before and after structure generation. If the proto
didn't change and the handler wasn't edited by the user, skip go mod
tidy, make proto, LLM handler generation, and compile-fix entirely.
Prints "(unchanged)" instead.

Reduces re-run of 4-service project from ~2 minutes to ~10 seconds.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: confirm design before generating code

Show the service design (names, endpoints) and prompt "Generate? [Y/n]"
before spending LLM time on handler generation. Applies to both
micro new --prompt and micro run --prompt. Default is yes (enter).

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* fix: use port :0 for MCP in generated multi-service projects

Each generated service had mcp.WithMCP(":3001") hardcoded, causing
port conflicts when running multiple services. Use :0 to auto-assign
a free port. micro run's central gateway handles unified MCP access.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: truncation detection, tool result display in chat

- Detect truncated LLM responses (unbalanced braces, doesn't end
  with '}') and retry with a conciseness hint before falling through
  to compile-fix
- Show tool call results in micro chat output (← for success, ✗ for
  errors) so users can see what the LLM did
- Add Result/Error fields to ToolCall, populated by Anthropic provider
  after tool execution
- Add isTruncated tests

Tested end-to-end with Anthropic: services generate, compile, start,
register, respond to RPC calls, and micro chat discovers and calls
tools correctly.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* fix: Anthropic tool loop, service naming, chat tool results

Anthropic provider:
- Fix tool execution loop to properly iterate (was re-processing all
  tool calls instead of only new ones each round)
- Clean assistant content blocks before sending back (strip 'id' from
  text blocks that Anthropic rejects on input)
- Include tools in follow-up requests so model can make additional calls
- Loop up to 10 rounds until model responds with text only

Service naming:
- Strip '-service' suffix from micro.New() name so services register
  as 'task', 'category' instead of 'taskservice', 'categoryservice'

Chat:
- Show tool results (← for success) and errors (✗) in chat output

Tested end-to-end: create task → list tasks works as multi-step
orchestration through micro chat with Anthropic Claude.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: blog post 13 — from prompt to production

Covers the full micro run --prompt flow: design, generate, compile-fix,
run, and chat orchestration. Positions agent-as-orchestrator as the
answer to service coordination.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* fix: timeouts, max_tokens, TTY detection, smaller services

- Add 60s timeout on design, 90s on handler generation, 60s on
  compile-fix LLM calls so hung providers don't block forever
- Bump Anthropic max_tokens from 4096 to 8192 to reduce truncation
- Add TTY detection: spinner prints static message in non-TTY (CI/pipes)
  instead of ANSI escape codes
- Tighten prompts: max 200 lines per handler, 2-4 services, 5-8 fields,
  explicit "services don't call each other" rule

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: chat suggests creating services when capabilities are missing

Update system prompt with the list of available services. When the user
asks for something no existing service can handle, the agent explains
what's available and suggests the exact micro new --prompt command to
create the missing service.

This is the natural evolution path: start with a few services, talk to
them via chat, and when the domain grows, the agent tells you what to
add. Each service stays small and focused.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: chat generates and starts services inline, drop -service suffix

Chat agent now has a micro_generate_service tool. When the user asks
for a capability that doesn't exist, the agent generates the service,
compiles it, starts it as a background process, waits for registration,
re-discovers tools, and uses the new endpoints immediately — all within
the conversation.

Service naming: design prompt now instructs LLM to return names without
'-service' suffix (e.g. 'task' not 'task-service'). buildMain keeps
TrimSuffix as safety net for backward compatibility.

Spawned processes are cleaned up when chat exits.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* docs: rewrite blog post 13 with inline service generation

Updated to reflect the full UX: services generate and start within
the chat conversation. Added the shipping example showing the agent
creating a service mid-conversation. Removed -service suffix from
all examples. Tightened the narrative around agent-as-orchestrator.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

* feat: persistent storage, README quickstart, auto-detect new services

Storage: generated handlers now use go-micro's store package instead
of in-memory maps. Data persists across restarts. The handler prompt
includes store API examples so the LLM generates correct store usage.

README: added "Generate From a Prompt" section with micro run --prompt
and micro chat examples, linking to blog post 13.

Watcher: micro run now scans for new service directories every 5s. When
micro chat generates a service, micro run detects the new directory,
builds it, starts it, and adds it to the watcher — fully automatic.
Added AddDir/Dirs methods to the watcher.

Blog: updated post 13 with persistent storage example and watcher note.

https://claude.ai/code/session_01QTp4SshuVmLAvvEGJe4TJd

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 20:31:01 +01:00
Asim Aslam 96280678ee Expose framework primitives via API gateway and MCP with auth control (#2925)
* feat: expose framework primitives via API gateway and MCP

Add registry, store, and broker as both HTTP routes and MCP tools
so AI agents and HTTP clients can inspect and operate the framework.

API gateway (/micro/* namespace):
  GET  /micro/registry         List registered services
  GET  /micro/registry/{name}  Describe a service
  GET  /micro/store            List store keys
  GET  /micro/store/{key}      Read a record
  POST /micro/store/{key}      Write a record
  POST /micro/broker/{topic}   Publish a message

MCP gateway (micro_* tool prefix):
  micro_registry_list    List services
  micro_registry_get     Describe a service
  micro_store_list       List keys
  micro_store_read       Read a record
  micro_store_write      Write a record
  micro_broker_publish   Publish a message

Framework tools use a Handler field on the MCP Tool struct for
direct dispatch (no RPC). Service tools continue to use RPC.
Rate limiters and circuit breakers are applied to framework
tools the same as service tools.

* fix: make framework internals opt-in on API and MCP gateways

Framework primitives (registry, broker, store) are now only
exposed when explicitly enabled:

API gateway:  micro api --internal
MCP gateway:  Options{Internal: true}

Off by default — user services are always exposed, framework
internals require the flag. Banner output only shows framework
routes when enabled.

* fix: always expose framework internals, gate by auth in production

Revert the --internal flag approach. Framework primitives (registry,
broker, store) are now always exposed:

- micro api: /micro/* routes always available (dev tool)
- MCP gateway: micro_* tools always registered. When Auth is
  configured (production), they require micro:admin scope.
  Without Auth (dev), they're open — same as all other tools.

This follows the existing pattern: micro run/api = dev (open),
micro server = production (auth + scopes). Framework internals
follow the same security model as user services.

Remove the Internal option from MCP Options. Remove --internal
flag from micro api.

Note: scope persistence depends on the store backend. The default
in-memory store does not survive restarts. Use MICRO_STORE=file
for persistent scopes in production.

* fix: correct DefaultStore comment — it's file-backed, not memory

* fix(server): don't recreate deleted admin user on restart

When the default admin account is deleted via the dashboard, set
a marker key (auth/.admin-deleted) in the store. On startup, skip
admin creation if the marker exists. This prevents the default
admin/micro credentials from reappearing after restart when the
user has intentionally removed them.

* fix: improve agent playground first-run UX and fix doc 404s

Agent playground:
- Add setup hint in empty state explaining how to get started
  (click Settings, enter API key, type a prompt)
- Hide hint automatically when API key is already configured
- Add all 7 providers to dropdown (was only OpenAI + Anthropic)
- Include CLI fallback suggestion (micro chat)

Docs:
- Fix .md links to .html across all doc pages — Jekyll serves
  .html files, not .md. Fixes 404s including the micro run guide.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 11:05:51 +01:00
Asim Aslam 69fc228c73 Enhance CLI color output and expose framework primitives via API (#2924)
* feat: expose framework primitives via API gateway and MCP

Add registry, store, and broker as both HTTP routes and MCP tools
so AI agents and HTTP clients can inspect and operate the framework.

API gateway (/micro/* namespace):
  GET  /micro/registry         List registered services
  GET  /micro/registry/{name}  Describe a service
  GET  /micro/store            List store keys
  GET  /micro/store/{key}      Read a record
  POST /micro/store/{key}      Write a record
  POST /micro/broker/{topic}   Publish a message

MCP gateway (micro_* tool prefix):
  micro_registry_list    List services
  micro_registry_get     Describe a service
  micro_store_list       List keys
  micro_store_read       Read a record
  micro_store_write      Write a record
  micro_broker_publish   Publish a message

Framework tools use a Handler field on the MCP Tool struct for
direct dispatch (no RPC). Service tools continue to use RPC.
Rate limiters and circuit breakers are applied to framework
tools the same as service tools.

* fix: make framework internals opt-in on API and MCP gateways

Framework primitives (registry, broker, store) are now only
exposed when explicitly enabled:

API gateway:  micro api --internal
MCP gateway:  Options{Internal: true}

Off by default — user services are always exposed, framework
internals require the flag. Banner output only shows framework
routes when enabled.

* fix: always expose framework internals, gate by auth in production

Revert the --internal flag approach. Framework primitives (registry,
broker, store) are now always exposed:

- micro api: /micro/* routes always available (dev tool)
- MCP gateway: micro_* tools always registered. When Auth is
  configured (production), they require micro:admin scope.
  Without Auth (dev), they're open — same as all other tools.

This follows the existing pattern: micro run/api = dev (open),
micro server = production (auth + scopes). Framework internals
follow the same security model as user services.

Remove the Internal option from MCP Options. Remove --internal
flag from micro api.

Note: scope persistence depends on the store backend. The default
in-memory store does not survive restarts. Use MICRO_STORE=file
for persistent scopes in production.

* fix: correct DefaultStore comment — it's file-backed, not memory

* fix(server): don't recreate deleted admin user on restart

When the default admin account is deleted via the dashboard, set
a marker key (auth/.admin-deleted) in the store. On startup, skip
admin creation if the marker exists. This prevents the default
admin/micro credentials from reappearing after restart when the
user has intentionally removed them.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-03 08:26:47 +01:00
Asim Aslam 5d7609027b Enhance CLI with color output and add teardown blog post (#2923)
* feat(cli): add color output to micro chat and micro api

micro chat:
- Startup banner matching micro run style: bold header, cyan
  provider/model, green dots for each discovered tool endpoint
- Cyan bold prompt (> ) instead of plain
- Yellow arrow (→) with dimmed tool name for tool calls
- Red "error:" prefix for errors
- Dimmed "(history cleared)" for reset

micro api:
- Startup banner matching micro run style: bold header, cyan
  address, colored HTTP methods (green GET, yellow POST)

Brings the CLI UX closer to what the generated terminal
screenshot depicts — color-coded, professional, readable.

* feat(cli): adopt consistent color output across all commands

Apply the same banner/output style across the remaining commands:

micro new:    bold header, cyan service name, green ✓, cyan URLs
micro build:  green ✓ checkmarks, cyan file paths
micro deploy: bold header, cyan target
micro mcp:    bold header, green dots per tool, dimmed count
micro flow:   bold header, cyan flow/topic/provider

All commands now follow the micro run/chat/api pattern:
bold header, cyan values, green status indicators, dimmed hints.

* docs: add "Tools as Services" blog post

Write blog/12 — connects the AI story back to Go Micro's original
design: services were always self-describing, named, and uniformly
callable. The path from API gateway to MCP to LLM tools is the
same pattern — read the registry, present services in a format
the consumer understands, route calls back.

Covers the access layer pattern (HTTP, web, CLI, MCP, chat),
why doc comments became functional in the AI era, and how the
framework primitives (registry, broker, store) could all become
tools using the same mechanism.

Add to blog index, link forward from blog/11.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 22:15:18 +01:00
Asim Aslam 5601be009a docs: add "Build Your Own AI Agent CLI" teardown blog post (#2921)
Write blog/11 — a teardown of micro chat showing how to build an
LLM tool-calling agent in ~150 lines. Walks through the four pieces:
discover tools, create the model, track conversation, run the loop.
Uses the actual chat.go source. Ends with extension ideas and a
"make it yours" framing.

Add to blog index, link forward from blog/10.

Co-authored-by: Claude <noreply@anthropic.com>
2026-06-02 12:06:22 +01:00
Asim Aslam 888dbbca4a Refactor AI tool handling and enhance CLI command documentation (#2920)
goreleaser / goreleaser (push) Waiting to run
* refactor(ai): rename ToolSet to Tools, simplify wiring with WithTools

Move tool discovery/execution fully into the ai package as ai.Tools
(formerly ai.ToolSet), and simplify the usage model:

- NewTools(reg, ai.ToolClient(c)) takes the execution client as an
  option instead of threading it through Handler(c) per call
- New ai.WithTools(tools) option wires the tool handler into a model
  in one call, replacing ai.WithToolHandler(set.Handler(c))
- ai.DiscoverTools(reg) for one-shot discovery

Before:
  set := ai.NewToolSet(reg)
  list, _ := set.Discover()
  m := ai.New(p, ai.WithToolHandler(set.Handler(client)))

After:
  tools := ai.NewTools(reg, ai.ToolClient(client))
  list, _ := tools.Discover()
  m := ai.New(p, ai.WithTools(tools))

Update ai/flow, micro chat, README, ai integration doc, Atlas Cloud
guide, and blog posts 3/8/9/10.

* feat(cli): add per-interface commands (registry, broker, store, config)

Map go-micro's core interfaces onto the CLI so the framework's
building blocks are inspectable and manipulable from the terminal:

  micro registry list/get/watch       service discovery
  micro broker publish/subscribe      pub/sub messaging
  micro store read/write/delete/list  persistence
  micro config get/dump               dynamic config (from env)

Structured pluggably in cmd/micro/resource: each interface is one
file exposing a Command() func, all wired through a commandFuncs
slice in resource.go. Adding a new resource command is a single
file plus one slice entry. Shared printJSON/fail helpers keep
output and errors consistent across commands.

Each command's verbs mirror the interface methods. Output is JSON
for structured data, raw for single values. Update README and
getting-started with an "inspecting the framework" section.

* docs: update CLI README with all new commands

Add documentation for commands that were missing from the CLI README:
- micro new --template (crud, pubsub, api)
- micro api (standalone HTTP gateway)
- micro registry list/get/watch
- micro broker publish/subscribe
- micro store read/write/delete/list
- micro config get/dump
- micro chat (interactive LLM agent)
- micro flow run/exec (event-driven orchestration)
- micro mcp serve/list/test

Organized into sections: API Gateway, Inspecting the Framework
(registry, broker, store, config), and AI & Agents (chat, flow, mcp).

* refactor(ai): move History from caller to Request field

History is now pure state (no Generate method). Instead, pass it
via Request.History and call ai.Generate(ctx, model, req):

Before:
  hist := ai.NewHistory("system prompt", 50)
  resp, _ := hist.Generate(ctx, model, prompt, tools)

After:
  hist := ai.NewHistory(50)
  resp, _ := ai.Generate(ctx, model, &ai.Request{
      Prompt:       prompt,
      SystemPrompt: "system prompt",
      Tools:        tools,
      History:      hist,
  })

The model is always the thing you call. History is context you
pass in. ai.Generate() handles the bookkeeping: prepends
accumulated messages before the call, records the exchange after.

NewHistory no longer takes a system prompt (it belongs on the
Request, where it always did).

Update micro chat, ai/flow, and all blog posts/docs.

* refactor(ai): make History a plain message accumulator

History no longer has Generate or touches the model. It's just
Add/Messages/Reset/Len with truncation — a helper for building
Request.Messages across turns.

Before:
  hist := ai.NewHistory(50)
  resp, _ := ai.Generate(ctx, m, &ai.Request{History: hist, ...})

After:
  hist := ai.NewHistory(50)
  hist.Add("user", prompt)
  resp, _ := m.Generate(ctx, &ai.Request{Messages: hist.Messages(), ...})
  hist.Add("assistant", resp.Reply)

Remove History field from Request. Remove package-level
ai.Generate(ctx, model, req) wrapper — users call m.Generate()
directly, which is the interface method. History is a convenience
for accumulating messages, not a participant in generation.

Update micro chat, ai/flow, blog posts 9 and 10.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-30 16:20:38 +01:00
Asim Aslam f48d81c760 Cli commands (#2919)
* refactor(ai): rename ToolSet to Tools, simplify wiring with WithTools

Move tool discovery/execution fully into the ai package as ai.Tools
(formerly ai.ToolSet), and simplify the usage model:

- NewTools(reg, ai.ToolClient(c)) takes the execution client as an
  option instead of threading it through Handler(c) per call
- New ai.WithTools(tools) option wires the tool handler into a model
  in one call, replacing ai.WithToolHandler(set.Handler(c))
- ai.DiscoverTools(reg) for one-shot discovery

Before:
  set := ai.NewToolSet(reg)
  list, _ := set.Discover()
  m := ai.New(p, ai.WithToolHandler(set.Handler(client)))

After:
  tools := ai.NewTools(reg, ai.ToolClient(client))
  list, _ := tools.Discover()
  m := ai.New(p, ai.WithTools(tools))

Update ai/flow, micro chat, README, ai integration doc, Atlas Cloud
guide, and blog posts 3/8/9/10.

* feat(cli): add per-interface commands (registry, broker, store, config)

Map go-micro's core interfaces onto the CLI so the framework's
building blocks are inspectable and manipulable from the terminal:

  micro registry list/get/watch       service discovery
  micro broker publish/subscribe      pub/sub messaging
  micro store read/write/delete/list  persistence
  micro config get/dump               dynamic config (from env)

Structured pluggably in cmd/micro/resource: each interface is one
file exposing a Command() func, all wired through a commandFuncs
slice in resource.go. Adding a new resource command is a single
file plus one slice entry. Shared printJSON/fail helpers keep
output and errors consistent across commands.

Each command's verbs mirror the interface methods. Output is JSON
for structured data, raw for single values. Update README and
getting-started with an "inspecting the framework" section.

* docs: update CLI README with all new commands

Add documentation for commands that were missing from the CLI README:
- micro new --template (crud, pubsub, api)
- micro api (standalone HTTP gateway)
- micro registry list/get/watch
- micro broker publish/subscribe
- micro store read/write/delete/list
- micro config get/dump
- micro chat (interactive LLM agent)
- micro flow run/exec (event-driven orchestration)
- micro mcp serve/list/test

Organized into sections: API Gateway, Inspecting the Framework
(registry, broker, store, config), and AI & Agents (chat, flow, mcp).

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-30 14:52:22 +01:00
Asim Aslam 3acf74a29a Refactor tool management and add CLI commands for interfaces (#2918)
* refactor(ai): rename ToolSet to Tools, simplify wiring with WithTools

Move tool discovery/execution fully into the ai package as ai.Tools
(formerly ai.ToolSet), and simplify the usage model:

- NewTools(reg, ai.ToolClient(c)) takes the execution client as an
  option instead of threading it through Handler(c) per call
- New ai.WithTools(tools) option wires the tool handler into a model
  in one call, replacing ai.WithToolHandler(set.Handler(c))
- ai.DiscoverTools(reg) for one-shot discovery

Before:
  set := ai.NewToolSet(reg)
  list, _ := set.Discover()
  m := ai.New(p, ai.WithToolHandler(set.Handler(client)))

After:
  tools := ai.NewTools(reg, ai.ToolClient(client))
  list, _ := tools.Discover()
  m := ai.New(p, ai.WithTools(tools))

Update ai/flow, micro chat, README, ai integration doc, Atlas Cloud
guide, and blog posts 3/8/9/10.

* feat(cli): add per-interface commands (registry, broker, store, config)

Map go-micro's core interfaces onto the CLI so the framework's
building blocks are inspectable and manipulable from the terminal:

  micro registry list/get/watch       service discovery
  micro broker publish/subscribe      pub/sub messaging
  micro store read/write/delete/list  persistence
  micro config get/dump               dynamic config (from env)

Structured pluggably in cmd/micro/resource: each interface is one
file exposing a Command() func, all wired through a commandFuncs
slice in resource.go. Adding a new resource command is a single
file plus one slice entry. Shared printJSON/fail helpers keep
output and errors consistent across commands.

Each command's verbs mirror the interface methods. Output is JSON
for structured data, raw for single values. Update README and
getting-started with an "inspecting the framework" section.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-30 14:43:09 +01:00
Asim Aslam c4b4cbef25 refactor(ai): rename ToolSet to Tools, simplify wiring with WithTools (#2917)
Move tool discovery/execution fully into the ai package as ai.Tools
(formerly ai.ToolSet), and simplify the usage model:

- NewTools(reg, ai.ToolClient(c)) takes the execution client as an
  option instead of threading it through Handler(c) per call
- New ai.WithTools(tools) option wires the tool handler into a model
  in one call, replacing ai.WithToolHandler(set.Handler(c))
- ai.DiscoverTools(reg) for one-shot discovery

Before:
  set := ai.NewToolSet(reg)
  list, _ := set.Discover()
  m := ai.New(p, ai.WithToolHandler(set.Handler(client)))

After:
  tools := ai.NewTools(reg, ai.ToolClient(client))
  list, _ := tools.Discover()
  m := ai.New(p, ai.WithTools(tools))

Update ai/flow, micro chat, README, ai integration doc, Atlas Cloud
guide, and blog posts 3/8/9/10.

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-30 14:21:35 +01:00
Asim Aslam a9421e5b7e Update logo design, add AI integration documentation and blog post (#2916)
* feat: update Go Micro logo to interconnected nodes design

Replace the text-on-blue-square logo with a modern icon: three
teal nodes connected in a triangle, representing distributed
systems. Generated via Atlas Cloud. Clean at all sizes — works
as GitHub avatar, favicon, and nav bar icon.

* feat: new logo, AI integration architecture doc, and landing page CTA

Update logo to triangle-nodes icon + "Go Micro" text wordmark.
Save icon-only variant for favicon/avatar use.

Add docs/ai-integration.md — a single page that explains how the
AI stack fits together: services → registry → MCP gateway →
ai/tools → ai.Model → micro chat. Layer-by-layer with code
examples, provider table, and "what you don't need" section.

Add AI Integration to docs sidebar navigation (after Getting
Started). Update the landing page AI section with a direct CTA
button linking to the new doc.

* fix: restore original logo and add border-radius to all renders

Revert logo to original. Add border-radius: 8px to the logo img
in the landing page nav, docs layout nav, and blog layout nav
so the square logo renders with rounded corners everywhere.
Remove unused icon.png.

* docs: add micro chat blog post

Write blog/10 — a dedicated post for micro chat covering:
- What it does (interactive LLM agent for services)
- How it works (ai/tools → ai.History → ai.Model stack)
- Multi-turn conversation examples
- All provider options and env vars
- Single prompt mode for scripting
- Why it works (registry metadata + doc comments = tool descriptions)
- Programmatic usage with the same building blocks
- Link to micro flow as the event-driven counterpart

Add to blog index. Update blog 9 nav to link forward.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-30 13:46:05 +01:00
Asim Aslam 03f912f68a Add AtlasCloud sponsor logo to README 2026-05-30 09:58:44 +01:00
Asim Aslam 594ee107b7 Clean up README.md formatting
Removed unnecessary whitespace and line breaks in README.
2026-05-30 09:58:08 +01:00
Asim Aslam 2f164595f2 Add Atlas Cloud logo link to README
Added an image link for Atlas Cloud to the README.
2026-05-30 09:49:55 +01:00
Asim Aslam 740980a9cc Clean up whitespace in README.md
Removed extra whitespace before the second image link.
2026-05-30 09:49:33 +01:00
Asim Aslam 3d2d9acd68 Fix formatting issues in README.md 2026-05-30 09:46:28 +01:00
Asim Aslam 88aa0cc666 Update logo, add AI integration docs, and enhance CLI features (#2915)
* feat: update Go Micro logo to interconnected nodes design

Replace the text-on-blue-square logo with a modern icon: three
teal nodes connected in a triangle, representing distributed
systems. Generated via Atlas Cloud. Clean at all sizes — works
as GitHub avatar, favicon, and nav bar icon.

* feat: new logo, AI integration architecture doc, and landing page CTA

Update logo to triangle-nodes icon + "Go Micro" text wordmark.
Save icon-only variant for favicon/avatar use.

Add docs/ai-integration.md — a single page that explains how the
AI stack fits together: services → registry → MCP gateway →
ai/tools → ai.Model → micro chat. Layer-by-layer with code
examples, provider table, and "what you don't need" section.

Add AI Integration to docs sidebar navigation (after Getting
Started). Update the landing page AI section with a direct CTA
button linking to the new doc.

* fix: restore original logo and add border-radius to all renders

Revert logo to original. Add border-radius: 8px to the logo img
in the landing page nav, docs layout nav, and blog layout nav
so the square logo renders with rounded corners everywhere.
Remove unused icon.png.

* feat(ai): add ai/flow package and micro flow CLI

Add ai/flow — event-driven LLM orchestration for go-micro. A Flow
subscribes to a broker topic, discovers services as tools, and
feeds each event into an LLM that decides which RPCs to call.

Key types:
- flow.New(name, opts...) creates a flow with trigger topic,
  prompt template, provider config
- flow.Register(registry, broker, client) wires it into a service
- flow.Execute(ctx, data) runs the flow once (for testing/CLI)
- flow.Results() returns execution history

Add micro flow CLI with two subcommands:
- micro flow run: subscribe to a topic and react to events
- micro flow exec: one-shot execution with inline data

Both output JSON results with flow name, prompt, tool calls,
reply, answer, duration, and errors.

Example:
  micro flow run --trigger events.user.created \
    --prompt "New user: {{.Data}}. Send welcome email." \
    --provider anthropic

  micro flow exec --prompt "List all users" --provider anthropic

* docs: update flows blog post with ai/flow package and CLI examples

Add "Update: We Built It" section to blog/9 showing the ai/flow
package API, CLI usage for both event-driven and one-shot modes,
and what it does/doesn't do. Links the conceptual discussion to
the shipped implementation.

* feat(cli): add micro api gateway command, clarify run vs server

Add 'micro api' — a standalone lightweight HTTP-to-RPC gateway:
- POST /{service}/{endpoint} proxies to RPC calls
- GET / lists all services and endpoints
- GET /{service} describes a service
- GET /health returns ok
- Supports Micro-Endpoint header for endpoint routing
- No dashboard, no auth, no hot reload — just the proxy

Update help text to clarify the three gateway modes:
- micro api: bare HTTP-to-RPC proxy
- micro run: development mode (hot reload + gateway + agent playground)
- micro server: production mode (dashboard + auth + JWT)

* docs: update README, getting started, and AI integration for all new features

Update the development workflow table in both README and getting
started to include all CLI commands: micro new --template,
micro api, micro chat, micro flow, micro call.

Getting started:
- Add CRUD template example to quick start
- Update workflow table with 8 stages
- Add AI Integration, MCP, and gRPC Interop to Next Steps

README:
- Add template flag to quick start example
- Update workflow table
- Reorder User Guides with AI Integration prominent

AI Integration doc:
- Update stack diagram to include micro api and ai/flow
- Add micro flow section with Go API and CLI examples
- Add micro api section
- Renumber layers (now 8 instead of 7)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-29 22:49:37 +01:00
Asim Aslam 601c67675f Update logo, add AI integration docs, and enhance CLI features (#2914)
* feat: update Go Micro logo to interconnected nodes design

Replace the text-on-blue-square logo with a modern icon: three
teal nodes connected in a triangle, representing distributed
systems. Generated via Atlas Cloud. Clean at all sizes — works
as GitHub avatar, favicon, and nav bar icon.

* feat: new logo, AI integration architecture doc, and landing page CTA

Update logo to triangle-nodes icon + "Go Micro" text wordmark.
Save icon-only variant for favicon/avatar use.

Add docs/ai-integration.md — a single page that explains how the
AI stack fits together: services → registry → MCP gateway →
ai/tools → ai.Model → micro chat. Layer-by-layer with code
examples, provider table, and "what you don't need" section.

Add AI Integration to docs sidebar navigation (after Getting
Started). Update the landing page AI section with a direct CTA
button linking to the new doc.

* fix: restore original logo and add border-radius to all renders

Revert logo to original. Add border-radius: 8px to the logo img
in the landing page nav, docs layout nav, and blog layout nav
so the square logo renders with rounded corners everywhere.
Remove unused icon.png.

* feat(ai): add ai/flow package and micro flow CLI

Add ai/flow — event-driven LLM orchestration for go-micro. A Flow
subscribes to a broker topic, discovers services as tools, and
feeds each event into an LLM that decides which RPCs to call.

Key types:
- flow.New(name, opts...) creates a flow with trigger topic,
  prompt template, provider config
- flow.Register(registry, broker, client) wires it into a service
- flow.Execute(ctx, data) runs the flow once (for testing/CLI)
- flow.Results() returns execution history

Add micro flow CLI with two subcommands:
- micro flow run: subscribe to a topic and react to events
- micro flow exec: one-shot execution with inline data

Both output JSON results with flow name, prompt, tool calls,
reply, answer, duration, and errors.

Example:
  micro flow run --trigger events.user.created \
    --prompt "New user: {{.Data}}. Send welcome email." \
    --provider anthropic

  micro flow exec --prompt "List all users" --provider anthropic

* docs: update flows blog post with ai/flow package and CLI examples

Add "Update: We Built It" section to blog/9 showing the ai/flow
package API, CLI usage for both event-driven and one-shot modes,
and what it does/doesn't do. Links the conceptual discussion to
the shipped implementation.

* feat(cli): add micro api gateway command, clarify run vs server

Add 'micro api' — a standalone lightweight HTTP-to-RPC gateway:
- POST /{service}/{endpoint} proxies to RPC calls
- GET / lists all services and endpoints
- GET /{service} describes a service
- GET /health returns ok
- Supports Micro-Endpoint header for endpoint routing
- No dashboard, no auth, no hot reload — just the proxy

Update help text to clarify the three gateway modes:
- micro api: bare HTTP-to-RPC proxy
- micro run: development mode (hot reload + gateway + agent playground)
- micro server: production mode (dashboard + auth + JWT)

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-29 18:13:06 +01:00
Asim Aslam b97f45106d Update logo, add AI integration docs, and implement ai/flow package (#2913)
* feat: update Go Micro logo to interconnected nodes design

Replace the text-on-blue-square logo with a modern icon: three
teal nodes connected in a triangle, representing distributed
systems. Generated via Atlas Cloud. Clean at all sizes — works
as GitHub avatar, favicon, and nav bar icon.

* feat: new logo, AI integration architecture doc, and landing page CTA

Update logo to triangle-nodes icon + "Go Micro" text wordmark.
Save icon-only variant for favicon/avatar use.

Add docs/ai-integration.md — a single page that explains how the
AI stack fits together: services → registry → MCP gateway →
ai/tools → ai.Model → micro chat. Layer-by-layer with code
examples, provider table, and "what you don't need" section.

Add AI Integration to docs sidebar navigation (after Getting
Started). Update the landing page AI section with a direct CTA
button linking to the new doc.

* fix: restore original logo and add border-radius to all renders

Revert logo to original. Add border-radius: 8px to the logo img
in the landing page nav, docs layout nav, and blog layout nav
so the square logo renders with rounded corners everywhere.
Remove unused icon.png.

* feat(ai): add ai/flow package and micro flow CLI

Add ai/flow — event-driven LLM orchestration for go-micro. A Flow
subscribes to a broker topic, discovers services as tools, and
feeds each event into an LLM that decides which RPCs to call.

Key types:
- flow.New(name, opts...) creates a flow with trigger topic,
  prompt template, provider config
- flow.Register(registry, broker, client) wires it into a service
- flow.Execute(ctx, data) runs the flow once (for testing/CLI)
- flow.Results() returns execution history

Add micro flow CLI with two subcommands:
- micro flow run: subscribe to a topic and react to events
- micro flow exec: one-shot execution with inline data

Both output JSON results with flow name, prompt, tool calls,
reply, answer, duration, and errors.

Example:
  micro flow run --trigger events.user.created \
    --prompt "New user: {{.Data}}. Send welcome email." \
    --provider anthropic

  micro flow exec --prompt "List all users" --provider anthropic

* docs: update flows blog post with ai/flow package and CLI examples

Add "Update: We Built It" section to blog/9 showing the ai/flow
package API, CLI usage for both event-driven and one-shot modes,
and what it does/doesn't do. Links the conceptual discussion to
the shipped implementation.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-29 17:13:16 +01:00
Asim Aslam 28fa44b2ca Update logo design, enhance AI integration docs, and simplify navigation (#2912)
* feat: update Go Micro logo to interconnected nodes design

Replace the text-on-blue-square logo with a modern icon: three
teal nodes connected in a triangle, representing distributed
systems. Generated via Atlas Cloud. Clean at all sizes — works
as GitHub avatar, favicon, and nav bar icon.

* feat: new logo, AI integration architecture doc, and landing page CTA

Update logo to triangle-nodes icon + "Go Micro" text wordmark.
Save icon-only variant for favicon/avatar use.

Add docs/ai-integration.md — a single page that explains how the
AI stack fits together: services → registry → MCP gateway →
ai/tools → ai.Model → micro chat. Layer-by-layer with code
examples, provider table, and "what you don't need" section.

Add AI Integration to docs sidebar navigation (after Getting
Started). Update the landing page AI section with a direct CTA
button linking to the new doc.

* fix: restore original logo and add border-radius to all renders

Revert logo to original. Add border-radius: 8px to the logo img
in the landing page nav, docs layout nav, and blog layout nav
so the square logo renders with rounded corners everywhere.
Remove unused icon.png.

* fix: trim nav to 3 links across all layouts

Remove Reference and Home links from nav across landing page,
docs layout, and blog layout. Keep only Docs, Blog, GitHub —
the three things people actually need. Fixes crowded nav on
mobile where 5 links plus a menu button didn't fit.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-29 16:55:36 +01:00
Asim Aslam 985621f4b4 AI integration updates (#2911)
* feat: update Go Micro logo to interconnected nodes design

Replace the text-on-blue-square logo with a modern icon: three
teal nodes connected in a triangle, representing distributed
systems. Generated via Atlas Cloud. Clean at all sizes — works
as GitHub avatar, favicon, and nav bar icon.

* feat: new logo, AI integration architecture doc, and landing page CTA

Update logo to triangle-nodes icon + "Go Micro" text wordmark.
Save icon-only variant for favicon/avatar use.

Add docs/ai-integration.md — a single page that explains how the
AI stack fits together: services → registry → MCP gateway →
ai/tools → ai.Model → micro chat. Layer-by-layer with code
examples, provider table, and "what you don't need" section.

Add AI Integration to docs sidebar navigation (after Getting
Started). Update the landing page AI section with a direct CTA
button linking to the new doc.

* fix: restore original logo and add border-radius to all renders

Revert logo to original. Add border-radius: 8px to the logo img
in the landing page nav, docs layout nav, and blog layout nav
so the square logo renders with rounded corners everywhere.
Remove unused icon.png.

---------

Co-authored-by: Claude <noreply@anthropic.com>
2026-05-29 16:30:23 +01:00
651 changed files with 29035 additions and 6772 deletions
+1
View File
@@ -1 +1,2 @@
github: asim
custom: ["https://go-micro.dev/support"]
+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/jwTYuUVAGh)
- [Discord Community](https://discord.gg/WeMU5AGxD)
@@ -0,0 +1,23 @@
---
name: Commercial Support / Consulting
about: Inquire about paid support, consulting, training, or a retainer
title: '[SUPPORT] '
labels: commercial-support
assignees: asim
---
## What are you building?
A short description of your project and how you're using (or planning to use) Go Micro.
## What do you need?
- [ ] Production support / retainer (priority fixes, direct line, response SLA)
- [ ] Consulting (integration, architecture, agent design)
- [ ] Training / onboarding for a team
- [ ] Sponsored feature or fix
- [ ] Not sure yet — let's talk
## Scale & timeline
Team size, where you're running it, and any timeline that matters.
## Anything else?
Links, context, constraints. For anything you'd rather keep private, become a [sponsor](https://github.com/sponsors/asim) and message directly.
+8
View File
@@ -0,0 +1,8 @@
blank_issues_enabled: true
contact_links:
- name: 💖 Sponsor Go Micro
url: https://github.com/sponsors/asim
about: Fund ongoing development and see your name or logo on the project.
- name: 📖 Documentation
url: https://go-micro.dev/docs
about: Guides, examples, and the full reference.
+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/jwTYuUVAGh)
- [Discord Community](https://discord.gg/WeMU5AGxD)
+51
View File
@@ -0,0 +1,51 @@
name: Architecture Review
# Continuous high-altitude oversight of the whole framework and harness — the
# "founder lens" of the autonomous loop (internal/docs/CONTINUOUS_IMPROVEMENT.md).
# Where DevRel watches the public story and the increment loop ships code, the
# architect watches the SYSTEM and runs alongside the builders: it tracks what is
# in flight and what just merged, keeps the roadmap priorities live, and judges
# cohesion (harness <-> framework <-> dev UX), missing pieces, and realignment.
#
# Its OUTPUT is the ranked queue in internal/docs/PRIORITIES.md plus an assessment
# — NOT large refactors. Breaking public-API and architectural changes stay with
# the human (see CONTINUOUS_IMPROVEMENT.md).
#
# Runs hourly, offset before the increment loop (:29) so it re-prioritizes and
# THEN the loop builds the new top of the queue. Opens a fresh issue and
# dispatches Codex via CODEX_TRIGGER_TOKEN.
on:
workflow_dispatch: {}
schedule:
- cron: "59 * * * *" # hourly at :59, just before the :29 increment run (tunable)
permissions:
issues: write
concurrency:
group: architecture-review
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open an architecture review issue and dispatch Codex
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Architecture review #$RUN_NUMBER" \
--body "Continuous architecture / harness oversight against the North Star in internal/docs/THESIS.md. Output: a re-ranked internal/docs/PRIORITIES.md (only if it changed) plus an assessment.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching Codex (Architect)."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Act as the architect — the founder lens — for go-micro, running continuously alongside the builders. Hold the whole picture: how the harness, the framework, and the developer UX fit together cohesively, what is in flight and what just merged, what to prioritize next on the roadmap, and what is missing or has drifted. Each run: (1) TRACK STATE — scan recently merged PRs and open codex PRs/issues to see what shipped and what is being built right now, so the queue reflects reality (drop done items, don't re-queue in-flight work). (2) ASSESS against the North Star in internal/docs/THESIS.md — lead with its Mission (*the problem we solve: make building an agent as easy as building a service, on one runtime*) and re-derive alignment from the CANON it names (the blog under internal/website/blog, the README, and the website — read these, don't rely on THESIS.md alone), then ROADMAP.md (Now → Next → Later). Judge every priority against the mission: does it make the services → agents → workflows lifecycle simpler, more cohesive, and more operable? 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)."
@@ -0,0 +1,65 @@
name: Continuous Improvement
# Durable backbone for the autonomous improvement loop
# (see internal/docs/CONTINUOUS_IMPROVEMENT.md).
#
# A Claude Max subscription provides no API key for CI, so the loop is driven by
# Codex rather than Claude Code: on a cadence this opens a fresh tracking issue and
# posts an @codex instruction on it, and Codex runs one improvement increment, opens
# a PR (git push + gh pr create — the make_pr tool is a no-op stub), and enables
# GitHub auto-merge (gh pr merge --auto) so the PR lands once the required CI checks
# pass. No separate merge sweep — branch protection + native auto-merge is the gate.
# (See the per-issue rationale below.)
#
# Codex does NOT respond to comments authored by the github-actions bot, so the
# dispatch is GATED on a CODEX_TRIGGER_TOKEN secret (a PAT for a user account Codex
# follows). Until that secret is set the workflow runs but no-ops — this avoids
# piling up @codex comments that Codex silently ignores. The moment the secret is
# added the loop activates with no further change.
#
# Each run opens a FRESH issue and dispatches Codex there, rather than re-commenting
# on one tracker issue. Codex derives its PR branch name from the triggering issue's
# context, so repeated dispatches on a single issue all collapse onto one branch name
# (codex/github-mention-<that-issue-slug>) — the first increment opens a PR, the rest
# collide on the occupied branch and silently fail to open one. A unique issue per
# run gives each increment its own branch and a clean PR. The dispatch asks Codex to
# "Closes #<issue>" so each tracking issue auto-closes when its PR merges.
on:
workflow_dispatch: {}
schedule:
- cron: "29 * * * *" # hourly, off-minute (tune as needed)
permissions:
issues: write
concurrency:
group: continuous-improvement
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open a fresh increment 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 dispatch."
echo "Codex ignores comments from the github-actions bot, so posting now"
echo "would only create noise. Add a CODEX_TRIGGER_TOKEN secret (a PAT for"
echo "a user account Codex follows) to activate the loop."
exit 0
fi
# A unique issue per run → unique codex/ branch → no collisions.
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Continuous improvement increment #$RUN_NUMBER" \
--body "Autonomous continuous-improvement increment. North Star: internal/docs/THESIS.md; charter: internal/docs/CONTINUOUS_IMPROVEMENT.md. Tracker: #3024.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching Codex."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Run one continuous-improvement increment per internal/docs/CONTINUOUS_IMPROVEMENT.md, aligned to the North Star in internal/docs/THESIS.md (the holistic services → agents → workflows lifecycle). PICK THE WORK FROM THE QUEUE: read internal/docs/PRIORITIES.md and take the highest-ranked item whose linked issue is still OPEN — that is your task, and its issue number is the one you close. (If PRIORITIES.md is missing or every listed item's issue is already closed, fall back to picking the single highest-value roadmap/issue/improvement-radar item yourself.) Implement it, and verify \`go build ./...\`, \`go test ./...\`, and \`golangci-lint run ./...\`. Then open the PR YOURSELF from the shell — do NOT use the make_pr tool (in this environment it only records metadata and never creates a PR). Create a uniquely-named branch under the codex/ prefix and open the PR from it: \`git switch -c codex/increment-$ISSUE_NUM\`, then \`git push -u origin codex/increment-$ISSUE_NUM\`, then \`gh pr create --base master --label codex --title \"<title>\" --body \"<body; include 'Closes #<the priority issue you built>' so it leaves the queue, and 'Closes #$ISSUE_NUM' for this run's tracker>\"\`. Finally enable auto-merge so GitHub merges it once CI is green: \`gh pr merge --squash --auto --delete-branch\`. The gh CLI is installed and authenticated and origin points to $REPO. One concern per PR; stay out of brand/positioning copy and breaking public API."
+47
View File
@@ -0,0 +1,47 @@
name: DevRel Review
# Daily higher-altitude coherence pass over the PUBLIC surface — README,
# website (landing + docs), and blog — part of the autonomous loop
# (internal/docs/CONTINUOUS_IMPROVEMENT.md). The hourly increment loop ships
# code; this keeps the story coherent: docs/website aligned, README crisp, and
# a steady supply of things worth blogging about.
#
# Like the increment loop it opens a fresh issue and dispatches Codex via
# CODEX_TRIGGER_TOKEN (Codex ignores Actions-bot comments). Autonomy boundary:
# SAFE factual-alignment and crispness fixes auto-merge; brand/positioning copy
# and blog drafts are surfaced in the report for the human, never auto-merged.
on:
workflow_dispatch: {}
schedule:
- cron: "0 7 * * *" # daily, 07:00 UTC (tunable)
permissions:
issues: write
concurrency:
group: devrel-review
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- name: Open a DevRel review issue and dispatch Codex
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TRIGGER_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TRIGGER_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (Codex ignores Actions-bot comments)."
exit 0
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "DevRel coherence review #$RUN_NUMBER" \
--body "Daily DevRel / coherence pass over README, website (landing + docs), and the blog. North Star: internal/docs/THESIS.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching Codex (DevRel)."
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body \
"@codex Act as DevRel for go-micro. Audit the PUBLIC surface — \`README.md\`, \`internal/website/\` (landing \`index.html\` + \`docs/\`), and the blog under \`internal/website/blog/\` — for coherence with the North Star in internal/docs/THESIS.md (an agent harness and service framework; the services → agents → workflows lifecycle). Look for: (1) places where README / website / docs contradict each other, are stale, or describe behavior that has since changed (cross-check against the code and recent merged PRs / CHANGELOG.md); (2) whether the README is crisp and leads with the harness positioning; (3) one to three genuinely blog-worthy items from recently shipped work. Then do BOTH of these: (A) post a concise findings report as a comment on this issue (#$ISSUE_NUM) — what is aligned, what drifted, what you fixed, and the blog ideas; (B) for SAFE factual-alignment and crispness fixes only (NOT brand/marketing/positioning rewrites), open one PR: \`git switch -c codex/devrel-$ISSUE_NUM\`, \`git push -u origin codex/devrel-$ISSUE_NUM\`, \`gh pr create --base master --label codex --title \"<title>\" --body \"<summary, including 'Closes #$ISSUE_NUM'>\"\`, then \`gh pr merge --squash --auto --delete-branch\`. Leave brand/positioning copy and blog drafts for the human — describe them in the report, do NOT open auto-merging PRs for them. Do not use the make_pr tool (it is a no-op stub). If you touch code, verify go build/test/golangci-lint. Stay out of breaking public-API changes."
+101
View File
@@ -0,0 +1,101 @@
name: Harness (E2E)
# Runs the end-to-end harnesses for agents, services, flows, and provider
# conformance. The default job uses deterministic mock LLMs and needs no
# secrets. A second job runs the same harnesses against the live provider set and
# skips providers whose secrets are absent, so scheduled conformance
# remains safe in no-key forks while still failing configured providers that drift.
on:
push:
branches: ["**"]
pull_request:
branches: ["**"]
schedule:
- cron: "17 6 * * *" # daily, so the world is exercised even without changes
workflow_dispatch:
jobs:
harness:
name: Harnesses (mock LLM)
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
cache: true
- name: Build
run: go build ./...
- name: 0→1 scaffold contract
run: go test ./cmd/micro/cli/new -run TestZeroToOneContract -count=1
- name: Universe end-to-end (asserts; exits non-zero on failure)
run: go run ./internal/harness/universe
- name: Agent-flow harness
run: go run ./internal/harness/agent-flow
- name: 0→hero run/chat/inspect reference scenario
run: ./internal/harness/zero-to-hero-ci/run.sh
harness-live:
name: Provider harnesses (live LLM conformance)
runs-on: ubuntu-latest
# Only on the daily schedule or a manual run — never automatically on
# every push/PR, so changes don't quietly burn API credits. Trigger it
# by hand (Actions → Harness → Run workflow) when changing the agent,
# flow, or AI internals and you want a real-model check.
if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'
steps:
- uses: actions/checkout@v4
- uses: actions/setup-go@v5
with:
go-version: stable
cache: true
- name: Agent provider conformance matrix
env:
GO_MICRO_AGENT_CONFORMANCE_LIVE: "1"
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
run: go test ./agent -run TestAgentProviderConformanceMatrix -count=1 -v
- name: Provider conformance against live models
env:
ANTHROPIC_API_KEY: ${{ secrets.ANTHROPIC_API_KEY }}
OPENAI_API_KEY: ${{ secrets.OPENAI_API_KEY }}
GEMINI_API_KEY: ${{ secrets.GEMINI_API_KEY }}
GROQ_API_KEY: ${{ secrets.GROQ_API_KEY }}
MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}
TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}
ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}
run: |
go run ./internal/harness/provider-conformance \
-summary-json provider-conformance-summary.json \
-summary-markdown provider-conformance-summary.md \
-capabilities-markdown provider-capabilities.md
- name: Publish provider conformance summary
if: always()
run: |
if [ -f provider-conformance-summary.md ]; then
cat provider-conformance-summary.md >> "$GITHUB_STEP_SUMMARY"
fi
if [ -f provider-capabilities.md ]; then
{
echo
echo "## Registered provider capabilities"
echo
cat provider-capabilities.md
} >> "$GITHUB_STEP_SUMMARY"
fi
- name: Upload provider conformance summary
if: always()
uses: actions/upload-artifact@v4
with:
name: provider-conformance
path: |
provider-conformance-summary.json
provider-conformance-summary.md
provider-capabilities.md
if-no-files-found: ignore
+30
View File
@@ -0,0 +1,30 @@
name: Lint
on:
push:
branches:
- "**"
pull_request:
types:
- opened
- reopened
- synchronize
branches:
- "**"
jobs:
golangci:
name: golangci-lint
runs-on: ubuntu-latest
permissions:
contents: read
steps:
- uses: actions/checkout@v4
- name: Set up Go
uses: actions/setup-go@v5
with:
go-version: 1.24
check-latest: true
cache: true
- name: golangci-lint
uses: golangci/golangci-lint-action@v8
with:
version: v2.5.0
+1
View File
@@ -7,6 +7,7 @@ on:
types:
- opened
- reopened
- synchronize
branches:
- "**"
jobs:
+10
View File
@@ -57,3 +57,13 @@ examples/mcp/hello/hello
# IDE-specific files
.DS_Store
/micro
# Built example/harness binaries (go build ./path/... drops these at repo root)
/plan-delegate
/agent-plan-delegate
/micro-mcp-gateway
# Local Jekyll / Bundler artifacts
internal/website/.bundle/
internal/website/_site/
internal/website/.jekyll-cache/
+57 -245
View File
@@ -1,251 +1,63 @@
# This file contains all available configuration options
# with their default values.
version: "2"
# options for analysis running
run:
# go: '1.18'
# default concurrency is a available CPU number
# concurrency: 4
# timeout for analysis, e.g. 30s, 5m, default is 1m
deadline: 10m
# exit code when at least one issue was found, default is 1
issues-exit-code: 1
# include test files or not, default is true
timeout: 5m
tests: true
# which files to skip: they will be analyzed, but issues from them
# won't be reported. Default value is empty list, but there is
# no need to include all autogenerated files, we confidently recognize
# autogenerated files. If it's not please let us know.
skip-files:
[]
# - .*\\.pb\\.go$
allow-parallel-runners: true
# list of build tags, all linters use it. Default is empty list.
build-tags: []
# output configuration options
output:
# Format: colored-line-number|line-number|json|tab|checkstyle|code-climate|junit-xml|github-actions
#
# Multiple can be specified by separating them by comma, output can be provided
# for each of them by separating format name and path by colon symbol.
# Output path can be either `stdout`, `stderr` or path to the file to write to.
# Example: "checkstyle:report.json,colored-line-number"
#
# Default: colored-line-number
format: colored-line-number
# Print lines of code with issue.
# Default: true
print-issued-lines: true
# Print linter name in the end of issue text.
# Default: true
print-linter-name: true
# Make issues output unique by line.
# Default: true
uniq-by-line: true
# Add a prefix to the output file references.
# Default is no prefix.
path-prefix: ""
# Sort results by: filepath, line and column.
sort-results: true
# all available settings of specific linters
linters-settings:
wsl:
allow-cuddle-with-calls: ["Lock", "RLock", "defer"]
funlen:
lines: 80
statements: 60
varnamelen:
# The longest distance, in source lines, that is being considered a "small scope".
# Variables used in at most this many lines will be ignored.
# Default: 5
max-distance: 26
ignore-names:
- err
- id
- ch
- wg
- mu
ignore-decls:
- c echo.Context
- t testing.T
- f *foo.Bar
- e error
- i int
- const C
- T any
- m map[string]int
errcheck:
# report about not checking of errors in type assetions: `a := b.(MyStruct)`;
# default is false: such cases aren't reported by default.
check-type-assertions: true
# report about assignment of errors to blank identifier: `num, _ := strconv.Atoi(numStr)`;
# default is false: such cases aren't reported by default.
check-blank: true
govet:
# report about shadowed variables
check-shadowing: false
gofmt:
# simplify code: gofmt with `-s` option, true by default
simplify: true
gocyclo:
# minimal code complexity to report, 30 by default (but we recommend 10-20)
min-complexity: 15
maligned:
# print struct with more effective memory layout or not, false by default
suggest-new: true
dupl:
# tokens count to trigger issue, 150 by default
threshold: 100
goconst:
# minimal length of string constant, 3 by default
min-len: 3
# minimal occurrences count to trigger, 3 by default
min-occurrences: 3
depguard:
list-type: blacklist
# Packages listed here will reported as error if imported
packages:
- github.com/golang/protobuf/proto
misspell:
# Correct spellings using locale preferences for US or UK.
# Default is to use a neutral variety of English.
# Setting locale to US will correct the British spelling of 'colour' to 'color'.
locale: US
lll:
# max line length, lines longer will be reported. Default is 120.
# '\t' is counted as 1 character by default, and can be changed with the tab-width option
line-length: 120
# tab width in spaces. Default to 1.
tab-width: 1
unused:
# treat code as a program (not a library) and report unused exported identifiers; default is false.
# XXX: if you enable this setting, unused will report a lot of false-positives in text editors:
# if it's called for subdir of a project it can't find funcs usages. All text editor integrations
# with golangci-lint call it on a directory with the changed file.
check-exported: false
unparam:
# call graph construction algorithm (cha, rta). In general, use cha for libraries,
# and rta for programs with main packages. Default is cha.
algo: cha
# Inspect exported functions, default is false. Set to true if no external program/library imports your code.
# XXX: if you enable this setting, unparam will report a lot of false-positives in text editors:
# if it's called for subdir of a project it can't find external interfaces. All text editor integrations
# with golangci-lint call it on a directory with the changed file.
check-exported: false
nakedret:
# make an issue if func has more lines of code than this setting and it has naked returns; default is 30
max-func-lines: 60
nolintlint:
allow-unused: false
allow-leading-space: false
allow-no-explanation: []
require-explanation: false
require-specific: true
prealloc:
# XXX: we don't recommend using this linter before doing performance profiling.
# For most programs usage of prealloc will be a premature optimization.
# Report preallocation suggestions only on simple loops that have no returns/breaks/continues/gotos in them.
# True by default.
simple: true
range-loops: true # Report preallocation suggestions on range loops, true by default
for-loops: false # Report preallocation suggestions on for loops, false by default
cyclop:
# the maximal code complexity to report
max-complexity: 20
gomoddirectives:
replace-local: true
retract-allow-no-explanation: false
exclude-forbidden: true
linters:
enable-all: true
disable-all: false
fast: false
disable:
- golint
- varcheck
- ifshort
- structcheck
- deadcode
# - nosnakecase
- interfacer
- maligned
- scopelint
- exhaustivestruct
- testpackage
- promlinter
- nonamedreturns
- makezero
- gofumpt
- nlreturn
- thelper
# Start from the standard set (errcheck, govet, ineffassign, staticcheck,
# unused) and add a few low-noise, high-value linters on top.
default: standard
enable:
- bodyclose
- misspell
- unconvert
- usestdlibvars
settings:
errcheck:
exclude-functions:
- (*encoding/json.Encoder).Encode
- (net/http.ResponseWriter).Write
- fmt.Fprintf
- fmt.Fprint
- fmt.Fprintln
misspell:
locale: US
staticcheck:
checks:
- all
# Deprecations are tracked separately; some (e.g. gRPC dial options)
# are kept intentionally for compatibility. Migrate them on their own.
- -SA1019
# Initialism/naming convention (Id->ID, Http->HTTP, ...). The remaining
# offenders are exported identifiers (e.g. web.Id, web.DefaultId) whose
# rename is a breaking API change; not worth it in a lint-bootstrap pass.
- -ST1003
exclusions:
# Use golangci-lint's built-in sensible exclusions and skip generated code.
generated: lax
presets:
- comments
- common-false-positives
- legacy
- std-error-handling
rules:
# The protobuf code generator is a port of upstream protoc-gen-go and
# keeps its structure; don't flag its unused legacy helpers.
- path: cmd/protoc-gen-micro/generator/
linters:
- unused
# Tests are held to a looser standard.
- path: _test\.go
linters:
- bodyclose
- errcheck
# Demo/harness code: fire-and-forget calls are fine and `_ =` noise hurts
# readability of examples.
- path: (^|/)(examples|internal/harness)/
linters:
- errcheck
# Can be considered to be enabled
- gochecknoinits
- gochecknoglobals # RIP
- dogsled
- wrapcheck
- paralleltest
- ireturn
- gomnd
- goerr113
- exhaustruct
- containedctx
- godox
- forcetypeassert
- gci
- lll
issues:
# List of regexps of issue texts to exclude, empty list by default.
# But independently from this option we use default exclude patterns,
# it can be disabled by `exclude-use-default: false`. To list all
# excluded by default patterns execute `golangci-lint run --help`
# exclude:
# - package comment should be of the form "Package services ..." # revive
# - ^ST1000 # ST1000: at least one file in a package should have a package comment (stylecheck)
# exclude-rules:
# - path: internal/app/machined/pkg/system/services
# linters:
# - dupl
exclude-rules:
- path: _test\.go
linters:
- gocyclo
- dupl
- gosec
- funlen
- varnamelen
- wsl
# Independently from option `exclude` we use default exclude patterns,
# it can be disabled by this option. To list all
# excluded by default patterns execute `golangci-lint run --help`.
# Default value for this option is true.
exclude-use-default: false
# Maximum issues count per one linter. Set to 0 to disable. Default is 50.
max-issues-per-linter: 0
# Maximum count of issues with the same text. Set to 0 to disable. Default is 3.
max-same-issues: 0
# Show only new issues: if there are unstaged changes or untracked files,
# only those changes are analyzed, else only changes in HEAD~ are analyzed.
# It's a super-useful option for integration of golangci-lint into existing
# large codebase. It's not practical to fix all existing issues at the moment
# of integration: much better don't allow issues in new code.
# Default is false.
new: false
formatters:
enable:
- gofmt
+39
View File
@@ -0,0 +1,39 @@
# Repository agent instructions
These instructions apply to the entire repository.
## Pull requests from Codex tasks
When a Codex task makes repository changes and the requested outcome is a PR:
1. Keep the change focused on the assigned issue or prompt.
2. Run the relevant verification commands and capture their results
(`go build ./...`, `go test ./...`, `golangci-lint run ./...`).
3. Check `git status --short` and review the diff before finishing.
4. Create a uniquely-named branch under the `codex/` prefix (do not work on
`master`, and do not use a generic name like `work`):
```sh
git switch -c codex/<issue-number>-<short-slug>
```
5. Stage the intended files and commit on that branch.
6. Open the pull request yourself with the GitHub CLI, which is installed in the
environment and whose `origin` points at this repository, then enable
auto-merge so GitHub merges it once the required CI checks pass:
```sh
git push -u origin HEAD
gh pr create --base master --label codex \
--title "<concise title>" \
--body "<summary of the change and testing, including 'Closes #<issue>'>"
gh pr merge --squash --auto --delete-branch
```
The branch should start with `codex/` and the PR should carry the `codex`
label. Auto-merge waits for the required status checks (build, tests,
golangci-lint) — never merge a PR manually before CI is green.
Do not just say that a PR was opened, and do **not** rely on the `make_pr` tool:
in this environment `make_pr` only records the title/body and never pushes a
branch or creates a PR. The task is not complete until `gh pr create` has opened
a real pull request and printed its URL.
+24 -3
View File
@@ -7,9 +7,30 @@ calendar-based versions (YYYY.MM) for the AI-native era.
---
## [Unreleased]
## [6.0.0] - June 2026
The AI-native major release. Breaking changes are listed first; everything
else is additive. See the [v5 → v6 migration guide](internal/website/docs/guides/migration/v5-to-v6.md) — it's a small upgrade.
### Changed (breaking)
- **Module path is now `go-micro.dev/v6`.** Update imports (`go-micro.dev/v5/...``go-micro.dev/v6/...`) and `go install go-micro.dev/v6/cmd/micro@v6`.
- **TLS verification is on by default.** v5 skipped verification unless `MICRO_TLS_SECURE=true`; v6 verifies by default. `MICRO_TLS_SECURE` is removed — set `MICRO_TLS_INSECURE=true` (or call `tls.InsecureConfig()`) for self-signed/dev certs.
- **`micro.NewService(name, opts...)` is the service constructor**, symmetric with `NewAgent`/`NewFlow`. `micro.New(name, opts...)` remains as a deprecated alias; the old name-less `micro.NewService(opts...)` form is removed (pass the name positionally). Generators emit the new form.
- **JWT auth ported in-module.** The external `github.com/micro/plugins/v5/auth/jwt` (pinned to v5) is replaced by `go-micro.dev/v6/auth/jwt/token`, now on the maintained `golang-jwt/jwt/v5`; the deprecated `dgrijalva/jwt-go` dependency is dropped.
### Added
- **A2A protocol — both directions** — `gateway/a2a` exposes registered agents over the open Agent2Agent (A2A) protocol so agents on other frameworks can discover and call them: Agent Cards are generated from registry metadata (the same way the MCP gateway derives tools), and incoming tasks are translated to the agent's existing `Agent.Chat` RPC, with no per-agent code (`micro a2a serve`). The outbound `a2a.Client` calls external A2A agents by URL, wired into `flow.A2A(url)` (a workflow step) and `delegate` to an `http(s)` URL (from inside an agent). An agent can also serve A2A **directly** without a gateway via `AgentA2A(addr)` (`a2a.NewAgentHandler`), handling tasks in-process. The JSON-RPC binding includes `message/send`, `message/stream` (SSE), `tasks/get`, multi-turn continuation by `taskId`/`contextId`, best-effort push notification callbacks, `tasks/resubscribe`, `input-required` handoffs, and card discovery. (`gateway/a2a/`, `cmd/micro/a2a/`)
- **Agents (`micro.NewAgent`)** — an agent is a service with an LLM inside: it discovers its assigned services as tools, runs the model's tool loop, registers a `Chat` RPC endpoint, and is reachable like any service. `Ask` for programmatic use; `micro chat` discovers and routes to agents; `micro agent list`/`describe`. (`agent/`)
- **Plan & delegate** — two built-in agent tools added to every agent: `plan` (an ordered, store-persisted plan surfaced back in the prompt) and `delegate` (hand a self-contained subtask to a registered agent over RPC, otherwise to an ephemeral sub-agent). No harness or graph — they're plain tools. (`agent/builtin.go`, `examples/agent-plan-delegate/`)
- **Agent guardrails** — `MaxSteps` (stop on count), `LoopLimit` (stop repeated no-progress calls; on by default), and `ApproveTool` (human-in-the-loop / policy gate before each action), enforced at the one point every tool call passes through. (`agent/`, guide + blog)
- **Pluggable agent memory & custom tools** — durable store-backed conversation memory by default, swappable via `AgentMemory`; register any function as a tool with `AgentTool`.
- **Workflows (`micro.NewFlow`)** — event-driven orchestration that maps to Anthropic's workflow/agent split: an event triggers a deterministic step (or ordered durable steps), or dispatches to an agent with `FlowAgent`. (`flow/`)
- **Flow loops (`FlowLoop`)** — a flow step that runs a body step repeatedly, carrying state across passes, until a stop condition is met or a hard iteration cap is hit. Stop on a code-defined predicate (`FlowUntil`) or let the model judge it done (`FlowUntilLLM` — the supervised "Ralph" loop); `FlowLoopMax` is the guardrail that guarantees termination, and `FlowOnIteration` reports progress. (`flow/loop.go`, `examples/flow-loop/`, guide)
- **x402 payments** — opt-in per-call payments for tools via the x402 standard, with a pluggable facilitator and a consumer-side client + budget; the MCP gateway can advertise and require payment per tool. (`wrapper/x402/`, guide + blog)
- **Scoped store state** — `store.Scope(s, database, table)` returns a store handle that confines every operation to a database/table without mutating the shared store (unlike `Init(Table(...))`, which is process-global and races between co-located components). Services, agents, and flows now each keep their state in their own table (`service/{name}`, `agent/{name}`, `flow/{name}`); the service path replaces the old `Init(store.Table(name))` global mutation with a scoped handle.
- **Flow discovery & history CLI** — running flows now register in the registry as `type=flow` (and deregister on `Stop`), so they're discoverable like agents: `micro flow list` shows running flows, `micro flow runs <name>` shows a flow's durable run history from the store, and `micro agent history <name>` shows an agent's stored conversation. Live state comes from the registry; durable history from the scoped store.
- **Durable workflows** — a flow can now be an ordered list of steps (a task with stages) that is checkpointed before and after each step, so a run survives a crash and resumes where it stopped without re-running completed steps. State carries a typed payload plus a `Stage` marker; flow-level `Retry` with a per-step override; runs retained for audit unless `DeleteOnSuccess`. Step actions: `Call` (RPC), `LLM` (model turn), `Dispatch` (to an agent), or any `StepFunc`. Durability is a pluggable `Checkpoint` (store-backed by default; implement the interface for Temporal/Restate). Runnable example: `examples/flow-durable/`. Blog: "Durable Workflows" (`internal/website/blog/24.md`).
- **Agent tool-execution wrappers** — `AgentWrapTool` registers middleware around an agent's tool calls, the tool-side analogue of `client.CallWrapper`/`server.HandlerWrapper`. Use it for logging, metrics, retries, or policy; wrappers compose outermost-first and run outside the built-in guardrails. Includes a runnable example with observe + retry wrappers (`examples/agent-wrap-tool/`).
- **Agent platform showcase** — full platform example (Users, Posts, Comments, Mail) mirroring [micro/blog](https://github.com/micro/blog), demonstrating how existing microservices become agent-accessible with zero code changes (`examples/mcp/platform/`).
- **Blog post: "Your Microservices Are Already an AI Platform"** — walkthrough of agent-service interaction patterns using real-world services (`internal/website/blog/7.md`).
- **Circuit breakers for MCP gateway** — per-tool circuit breakers protect downstream services from cascading failures. Configurable max failures, open-state timeout, and half-open probing. Available via `Options.CircuitBreaker` and `--circuit-breaker` CLI flag (`gateway/mcp/circuitbreaker.go`).
@@ -26,10 +47,10 @@ calendar-based versions (YYYY.MM) for the AI-native era.
#### Developer Experience
- **`micro new` MCP templates** — `micro new myservice` generates MCP-enabled services with doc comments, `@example` tags, and `WithMCP()` wired in. Use `--no-mcp` to opt out.
- **`micro.New("name")` unified API** — single way to create services: `micro.New("greeter")` or `micro.New("greeter", micro.Address(":8080"))`. Replaces `micro.NewService()` + `service.New()` dual API.
- **`micro.NewService("name")` unified API** — single way to create services: `micro.NewService("greeter")` or `micro.NewService("greeter", micro.Address(":8080"))`. Replaces `micro.NewService()` + `service.New()` dual API.
- **`service.Handle()` simplified registration** — register handlers with `service.Handle(new(Greeter))` instead of manual `server.NewHandler` + `server.Handle`.
- **`micro.NewGroup()` modular monoliths** — run multiple services in one binary with shared lifecycle: `micro.NewGroup(users, orders).Run()`.
- **`mcp.WithMCP()` one-liner** — add MCP to any service with a single option: `micro.New("name", mcp.WithMCP(":3001"))`.
- **`mcp.WithMCP()` one-liner** — add MCP to any service with a single option: `micro.NewService("name", mcp.WithMCP(":3001"))`.
- **CRUD example** — contact book service with 6 operations, rich agent docs, and validation patterns (`examples/mcp/crud/`).
#### MCP Gateway
+20 -7
View File
@@ -34,7 +34,8 @@ micro run
```
go-micro/
├── ai/ # AI model providers (Anthropic, OpenAI)
├── agent/ # Agent abstraction (intelligent service management)
├── ai/ # AI model providers (Anthropic, OpenAI, Gemini, etc.)
├── auth/ # Authentication (JWT, no-op)
├── broker/ # Message broker (NATS, RabbitMQ)
├── cache/ # Caching (Redis)
@@ -44,6 +45,7 @@ go-micro/
├── config/ # Dynamic config (env, file, etcd, NATS)
├── errors/ # Error handling
├── events/ # Event system (NATS JetStream)
├── flow/ # Event-driven LLM orchestration
├── gateway/
│ ├── api/ # REST API gateway
│ └── mcp/ # MCP gateway (core AI integration)
@@ -98,6 +100,7 @@ Build compelling demos showing agents interacting with go-micro services in real
- Kubernetes Operator with CRDs
### Recently Completed
- **Agent Plan & Delegate** - Two built-in agent tools: `plan` (ordered plan persisted to store-backed memory, surfaced in the prompt) and `delegate` (hand a subtask to another agent — RPC to a registered agent, else an ephemeral sub-agent with isolated context). Added automatically to every agent; no harness or graph. (`agent/builtin.go`, `examples/agent-plan-delegate/`)
- **`micro new` MCP Templates** - Scaffolds MCP-enabled services with doc comments, `@example` tags, `WithMCP()`. `--no-mcp` to opt out.
- **CRUD Example** - Contact book service with 6 operations, rich agent docs (`examples/mcp/crud/`)
- **Migration Guide** - "Add MCP to Existing Services" guide with 3 approaches
@@ -122,20 +125,30 @@ Build compelling demos showing agents interacting with go-micro services in real
| CLI Entry | `cmd/micro/main.go` |
| MCP CLI | `cmd/micro/mcp/` |
| Server (run/server) | `cmd/micro/server/server.go` |
| Roadmap | `internal/docs/ROADMAP_2026.md` |
| Status | `internal/docs/CURRENT_STATUS_SUMMARY.md` |
| Roadmap | `ROADMAP.md` (full: `internal/website/docs/roadmap.md`) |
| Status | `CHANGELOG.md` |
| Changelog | `CHANGELOG.md` |
| Docs Site | `internal/website/docs/` |
## Roadmap & Status Documents
- **[ROADMAP.md](ROADMAP.md)** - General framework roadmap
- **[internal/docs/ROADMAP_2026.md](internal/docs/ROADMAP_2026.md)** - AI-native era roadmap with business model
- **[internal/docs/CURRENT_STATUS_SUMMARY.md](internal/docs/CURRENT_STATUS_SUMMARY.md)** - Quick status overview
- **[internal/docs/PROJECT_STATUS_2026.md](internal/docs/PROJECT_STATUS_2026.md)** - Detailed technical status
- **[ROADMAP.md](ROADMAP.md)** - the single, current roadmap (agentic development + DX). Full version at `internal/website/docs/roadmap.md`.
- **[CHANGELOG.md](CHANGELOG.md)** - what shipped and when (the source of truth for status).
- **[internal/docs/IMPLEMENTATION_SUMMARY.md](internal/docs/IMPLEMENTATION_SUMMARY.md)** - Implementation notes
- **[CHANGELOG.md](CHANGELOG.md)** - What changed and when
## Coordination with Codex
Go Micro is maintained by two AI tools — **Claude Code** (you) and **Codex** (its playbook is [CODEX.md](CODEX.md)) — plus the human maintainer, who routes work and owns every merge. To work side by side without collisions:
- **Lanes / branches.** You work on `claude/*` branches; Codex on `codex/*`. Never push to Codex's branch, and never have both agents committing the same branch at once.
- **Base PRs on `master`; don't stack on Codex's in-flight branch.** If that base squash-merges, your commit gets orphaned (this happened — the #3007 fixes had to be re-landed). If the code you need isn't merged yet, wait for it, then branch off `master`. To improve an *open* Codex PR, fix it in place (once Codex is done with the branch, or via an `@codex` comment on the PR) rather than a separate stacked PR.
- **One concern per PR.** Single-purpose PRs; don't bundle (e.g.) a feature with a docs change.
- **Cross-review.** Review Codex's PRs before merge — mechanical fixes you can land yourself (based on `master`), but design/scope/positioning calls go to the human; don't silently rewrite Codex's intent. Codex reviews yours via `@codex review`.
- **Dispatching Codex.** Start a task by commenting `@codex <instruction>` on an issue/PR (that issue/PR is its context). `@codex review` is reserved for review; any other instruction starts a *task*. It's consequential (spends a Codex task slot, pushes commits) and **serial** (one task at a time) — so dispatch one task at a time, only on the human's go-ahead, and never write a literal `@codex` in a comment unless you intend to trigger it (write "Codex" in prose otherwise).
- **CI is the gate.** `go build`, `go test`, `golangci-lint` (blocking), and `make harness` must pass before merge. `internal/harness/` and `examples/` are excluded from errcheck; everything else gets the full set.
- **Backlog = GitHub issues**, each a scoped, self-contained brief with acceptance criteria.
## Contributing
See [CONTRIBUTING.md](CONTRIBUTING.md) for full guidelines. Key points:
+197
View File
@@ -0,0 +1,197 @@
# Codex Maintainer Playbook
Go Micro has six months of Codex access through OpenAI's Codex for Open Source
program. Use it to increase maintainer throughput without changing the project's
bar for review, tests, or design taste.
## Operating principles
1. **Humans set direction; Codex accelerates execution.** Maintainers choose the
issue, constraints, and acceptance criteria. Codex drafts, investigates, and
verifies.
2. **Small, reviewable changes win.** Prefer focused PRs that can be understood
in one sitting over large speculative rewrites.
3. **Keep the contract green.** Every Codex-assisted change should preserve the
CLI-first getting-started flow, the harnesses, `make test`, and `make lint`.
4. **Document while coding.** If behavior changes, ask Codex to update examples,
guides, and release notes in the same branch.
5. **No blind merges.** Codex output is treated like any contributor output:
reviewed by a maintainer, backed by tests, and checked for public API impact.
## Coordination with Claude Code
Go Micro is maintained by two AI tools — **Codex** (you) and **Claude Code** (its guide is [CLAUDE.md](CLAUDE.md)) — plus the human maintainer, who routes work and owns every merge.
- **Lanes / branches.** You work on `codex/*` branches; Claude Code on `claude/*`. Never push to a branch the other owns, and never have both agents on one branch at once.
- **Base PRs on `master`.** Don't stack a PR on another agent's in-flight branch — if that base squash-merges, your changes can be orphaned. If the code you need isn't merged yet, wait, then branch off `master`. To improve a PR that hasn't merged, push to that PR's branch rather than opening a separate stacked PR — keep the change one mergeable unit.
- **One concern per PR.** Keep each PR single-purpose so a reviewer can read it in one sitting; don't bundle unrelated changes (e.g. a feature plus a docs rebrand).
- **Cross-review before merge.** Claude Code reviews your PRs; you review its with `@codex review`. A fresh pass from the other model catches what the author misses.
- **Dispatch.** Maintainers (or Claude Code) start your tasks with `@codex <instruction>` on the relevant issue/PR — that's your context. `@codex review` is review; any other instruction is a *task*. You run one task at a time: take the current one to a clean, green PR before the next is dispatched.
- **CI is the gate.** `go build`, `go test`, `golangci-lint` (blocking), and `make harness` must pass; never merge red. `internal/harness/` and `examples/` are excluded from errcheck; everything else gets the full set.
- **Backlog = GitHub issues**, each a scoped brief with acceptance criteria.
## Best uses
### 1. PR review and triage
- Summarize a PR: changed surface area, public API impact, tests added or missing.
- Ask for targeted review passes: concurrency, cancellation, security, backwards
compatibility, docs drift, and examples.
- Convert review findings into small patch suggestions or issue comments.
### 2. Issue reproduction
- Turn bug reports into failing tests or runnable reproduction scripts.
- Minimize flakes by isolating registry, broker, store, transport, and AI-provider
dependencies behind deterministic fakes where possible.
- Attach the exact command that reproduces the failure to the issue.
### 3. Release support
- Draft changelog entries from merged commits, grouped by feature, fix, docs, and
compatibility notes.
- Check that `README.md`, `ROADMAP.md`, website docs, examples, and `CHANGELOG.md`
agree before tagging.
- Run dry-run release commands and summarize blockers.
### 4. Docs and examples
- Keep the 0→1 path current: scaffold, run, call, chat, inspect.
- Keep the 0→hero example current: a realistic multi-agent system that exercises
agents, services, flows, MCP, A2A, and observability.
- Add runnable examples for new primitives before adding broad prose.
### 5. Hardening backlog
Use Codex to break roadmap items into small PRs, especially:
- cross-provider conformance scenarios for all supported AI providers;
- timeout, cancellation, retry, and rate-limit behavior;
- durable agent loops on top of the existing checkpoint model;
- streaming across `ai.Stream` and A2A;
- agent run metadata mapped to OpenTelemetry spans.
## Suggested weekly loop
1. Pick one maintenance lane: reviews, bugs, release prep, docs, or hardening.
2. Ask Codex for a branch-sized plan with acceptance criteria and test commands.
3. Have Codex implement the smallest valuable slice.
4. Run the relevant checks locally and in CI.
5. Review the diff as maintainer-owned code, then merge or send it back.
6. Record any recurring prompt, check, or failure mode in this playbook.
## First two weeks
Do not start with a giant feature. Start by making Codex pay rent on maintenance
work that is already on the roadmap and easy to review.
### Day 1: set up the review loop
1. Pick three recent PRs or commits: one feature, one bug fix, and one docs-only
change.
2. Ask Codex to review each using the PR review template below.
3. Compare Codex findings with maintainer judgment. Keep the checks that found
real issues; delete the noisy ones.
4. Turn the final review prompt into a saved project note or issue comment
template.
Success means Codex can produce a useful first-pass review in under ten minutes
without blocking a maintainer on false positives.
### Days 2-3: make bugs reproducible
1. Pick one open bug or flaky area.
2. Ask Codex for a failing test only. Do not allow a fix in the first pass.
3. Review the test for whether it captures the real contract.
4. In a second branch, ask Codex to fix the failure with the smallest patch.
Success means every accepted bug fix starts with a regression test or deterministic
harness case.
### Days 4-5: audit the getting-started contract
Run through the 0→1 path from a clean checkout and ask Codex to patch only the
first broken or confusing step. The target is not new prose; it is a runnable
path that works exactly as documented.
Candidate checks:
```sh
make test
make harness
make lint
go run ./examples/hello-world
go run ./internal/harness/universe
```
### Week 2: choose one roadmap slice
Pick one hardening item and break it into PRs that each land independently. The
best first slice is usually test infrastructure, not product code.
Recommended order:
1. **Provider conformance skeleton**: define one deterministic agent scenario and
gate real-provider runs on credentials.
2. **Cancellation audit**: trace `context.Context` propagation through one package
at a time.
3. **Docs drift audit**: compare `README.md`, `ROADMAP.md`, website docs, and
examples for one shipped feature.
4. **Release checklist dry run**: have Codex build a release-blocker list from the
diff since the previous tag.
## Standing task queue
Keep Codex busy on tasks with clear acceptance criteria:
| Priority | Task | Acceptance criteria |
| --- | --- | --- |
| P0 | PR first-pass review | Summary, risks, required changes, and exact verification commands. |
| P0 | Bug reproduction | A failing test or harness case committed before the fix. |
| P0 | 0→1 docs check | Fresh-checkout commands work as written or a patch fixes the first break. |
| P1 | Cross-provider conformance | One scenario runs against fakes by default and real providers when keys exist. |
| P1 | Cancellation hardening | Tests prove timeout/cancel behavior for the touched package. |
| P1 | Release audit | Changelog, docs, examples, and migration notes agree before tagging. |
| P2 | Example polish | Example is runnable, linked from docs, and covered by a lightweight check. |
## What not to use Codex for yet
- Broad rewrites without a failing test, benchmark, or public design note.
- Public API changes before a maintainer writes the compatibility story.
- Large generated docs that nobody has run.
- Provider-specific behavior that is not checked against the shared `ai.Model`
contract.
## Prompt templates
### PR review
```text
Review this PR for Go Micro. Focus on public API compatibility, cancellation and
context propagation, concurrency safety, tests, and docs drift. Return: summary,
risks, required changes, optional improvements, and exact commands to verify.
```
### Bug reproduction
```text
Reproduce this issue in the smallest Go test or harness change possible. Do not
fix it yet. Explain the failing path and provide the exact command that fails.
```
### Branch implementation
```text
Implement the smallest branch that satisfies this issue. Keep the API compatible
unless explicitly required, update docs/examples when behavior changes, and run
`make test`, `make harness`, and `make lint` or explain any environment blocker.
```
### Release audit
```text
Audit this release branch. Compare CHANGELOG, README, ROADMAP, website docs, and
examples against the diff since the last tag. List inconsistencies, missing
migration notes, and checks to run before tagging.
```
+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 lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
.PHONY: test test-race test-coverage harness provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
# Default target
help:
@@ -18,6 +18,8 @@ help:
@echo " make test-race - Run tests with race detector"
@echo " make test-coverage - Run tests with coverage"
@echo " make lint - Run linter"
@echo " make harness - Run deterministic end-to-end harnesses"
@echo " make provider-conformance - Run harnesses against configured live providers"
@echo " make fmt - Format code"
@echo " make install-tools - Install development tools"
@echo " make proto - Generate protobuf code"
@@ -40,6 +42,18 @@ test-coverage:
go tool cover -html=coverage.out -o coverage.html
@echo "Coverage report: coverage.html"
# Run the end-to-end harnesses (deterministic, mock LLM — no API key).
# The universe harness exits non-zero on assertion failure.
harness:
go run ./internal/harness/universe
go run ./internal/harness/agent-flow
go run ./internal/harness/plan-delegate # 0→hero: services + agents + flow + plan/delegate
# Run the same harnesses against every configured live provider. Providers
# without API keys are skipped; configured providers must pass.
provider-conformance:
go run ./internal/harness/provider-conformance
# Run linter
lint:
golangci-lint run
+351 -377
View File
@@ -1,454 +1,428 @@
# 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/v5?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)
Go Micro is a framework for distributed systems development.
Go Micro is an **agent harness** and service framework for Go.
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.
## Sponsors
<a href="https://go-micro.dev/blog/3"><img src="https://upload.wikimedia.org/wikipedia/commons/7/78/Anthropic_logo.svg" height="26" /></a>
&nbsp;&nbsp;&nbsp;&nbsp;
&nbsp;&nbsp;
<a href="https://go-micro.dev/blog/29"><img src="https://upload.wikimedia.org/wikipedia/commons/4/4d/OpenAI_Logo.svg" height="26" /></a>
&nbsp;&nbsp;
<a href="https://go-micro.dev/blog/8"><img src="https://www.atlascloud.ai/logo.svg" height="26" /></a>
## Overview
**Want to support Go Micro and see your logo here?** [Become a sponsor](https://discord.gg/WeMU5AGxD) — reach out on Discord.
<img src="internal/website/images/generated/hero.png" alt="Go Micro microservices architecture" width="100%" />
## Commercial Support
Go Micro provides the core requirements for distributed systems development including RPC and Event driven communication.
The Go Micro philosophy is sane defaults with a pluggable architecture. We provide defaults to get you started quickly
but everything can be easily swapped out.
Running Go Micro in production, or building on it and want help? Paid **support, consulting, training, and retainers** are available directly from the maintainer — and they're what keep the project maintained. See [**Support**](SUPPORT.md) for the tiers, or [open a request](https://github.com/micro/go-micro/issues/new?template=commercial_support.md).
## Features
## Contents
Go Micro abstracts away the details of distributed systems. Here are the main features.
- [Quick Start](#quick-start)
- [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)
- [Features](#features)
- [CLI](#cli)
- [Multi-Service Projects](#multi-service-projects)
- [Data Model](#data-model)
- [AI Providers](#ai-providers)
- [Examples](#examples)
- [Commercial Support](#commercial-support)
- [Docs](#docs)
- **Authentication** - Auth is built in as a first class citizen. Authentication and authorization enable secure
zero trust networking by providing every service an identity and certificates. This additionally includes rule
based access control.
## Quick Start
- **Dynamic Config** - Load and hot reload dynamic config from anywhere. The config interface provides a way to load application
level config from any source such as env vars, file, etcd. You can merge the sources and even define fallbacks.
- **Data Storage** - A simple data store interface to read, write and delete records. It includes support for many storage backends
in the plugins repo. State and persistence becomes a core requirement beyond prototyping and Micro looks to build that into the framework.
- **Data Model** - A typed data model layer with CRUD operations, queries, and multiple backends (memory, SQLite, Postgres). Define Go
structs with tags and get type-safe Create/Read/Update/Delete/List/Count operations. Accessible via `service.Model()` alongside
`service.Client()` and `service.Server()` for a complete service experience: call services, handle requests, save and query data.
- **Service Discovery** - Automatic service registration and name resolution. Service discovery is at the core of micro service
development. When service A needs to speak to service B it needs the location of that service. The default discovery mechanism is
multicast DNS (mdns), a zeroconf system.
- **Load Balancing** - Client side load balancing built on service discovery. Once we have the addresses of any number of instances
of a service we now need a way to decide which node to route to. We use random hashed load balancing to provide even distribution
across the services and retry a different node if there's a problem.
- **Message Encoding** - Dynamic message encoding based on content-type. The client and server will use codecs along with content-type
to seamlessly encode and decode Go types for you. Any variety of messages could be encoded and sent from different clients. The client
and server handle this by default. This includes protobuf and json by default.
- **RPC Client/Server** - RPC based request/response with support for bidirectional streaming. We provide an abstraction for synchronous
communication. A request made to a service will be automatically resolved, load balanced, dialled and streamed.
- **Async Messaging** - PubSub is built in as a first class citizen for asynchronous communication and event driven architectures.
Event notifications are a core pattern in micro service development. The default messaging system is a HTTP event message broker.
- **MCP Integration** - An MCP gateway you can integrate as a library, server or CLI command which automatically exposes services
as tools for agents or other AI applications. Every service/endpoint get's converted into a callable tool.
- **Multi-Service Binaries** - Run multiple services in a single process with isolated state per service. Start as a modular monolith,
split into separate deployments when you need independent scaling. Each service gets its own server, client, and store while sharing
the registry and broker for inter-service communication.
- **Pluggable Interfaces** - Go Micro makes use of Go interfaces for each distributed system abstraction. Because of this these interfaces
are pluggable and allows Go Micro to be runtime agnostic. You can plugin any underlying technology.
## Getting Started
To make use of Go Micro
Install the CLI:
```bash
go get go-micro.dev/v5@v5.16.0
# Binary (no Go required)
curl -fsSL https://go-micro.dev/install.sh | sh
# Or with Go
go install go-micro.dev/v6/cmd/micro@v6
```
Create a service and register a handler
### Fastest start — no API key
Scaffold a service, run it, call it:
```bash
micro new helloworld
cd helloworld
micro run
```
Then in another terminal:
```bash
curl -X POST http://localhost:8080/api/helloworld/Helloworld.Call \
-H 'Content-Type: application/json' -d '{"name":"World"}'
```
### 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:
```bash
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ...
micro run --prompt "a task management system with categories" --provider anthropic
```
The AI designs the architecture, you review it, then it generates handlers with real business logic, compiles them, and starts them:
```
Services:
● task — Task management with status tracking
● project — Project organization
Generate? [Y/n]
Micro
Services:
● task
● project
Agents:
◆ agent
```
Then talk to your services from the console:
```
> Create a project called Launch, then add three tasks to it
→ project_Project_Create({"name":"Launch"})
← {"record":{"id":"p1..."},"success":true}
→ task_Task_Create({"title":"Design specs","project_id":"p1..."})
→ task_Task_Create({"title":"Write code","project_id":"p1..."})
→ task_Task_Create({"title":"Ship it","project_id":"p1..."})
Created project Launch and added three tasks to it.
```
When you need a capability that doesn't exist, the agent generates a new service mid-conversation:
```
> I need to track shipping. Create a shipment for order 123 to London.
⚡ generating shipping service...
✓ shipping
→ shipping_Shipping_Create({"order_id":"123","destination":"London"})
← {"record":{"id":"xyz...","status":"pending"}}
Created shipment for order 123 going to London.
```
Edit the generated code by hand at any time — re-running preserves your changes. [Read more](https://go-micro.dev/blog/13).
## Why an Agent Harness
The first wave of agent frameworks helped developers put a model in a loop. The next problem is operating that loop: connecting it to real tools, scoping what it can touch, preserving state, routing work to specialists, recovering from failures, observing what happened, and letting other agents call it. That is harness work.
Go Micro's answer is to make the harness the same thing you already deploy:
- **Tools are services** — endpoint metadata becomes tool schema; RPC executes the call.
- **Agents are services** — they register, discover, load-balance, and expose `Agent.Chat`.
- **Workflows are durable code paths** — use flows when the path is known; dispatch to agents when it is not.
- **Safety lives at execution** — `MaxSteps`, `LoopLimit`, `ApproveTool`, and tool wrappers run where actions happen.
- **Interop is built in** — MCP for tools, A2A for agents, x402 for paid tools.
Use Go Micro when the agent has to operate a system, not just answer a prompt.
## Writing Services
Under the hood, a service is a struct with methods. Doc comments and `@example` tags become tool descriptions for AI agents automatically.
```go
package main
import (
"go-micro.dev/v5"
"context"
"go-micro.dev/v6"
)
type Request struct {
Name string `json:"name"`
Name string `json:"name"`
}
type Response struct {
Message string `json:"message"`
Message string `json:"message"`
}
type Say struct{}
func (h *Say) Hello(ctx context.Context, req *Request, rsp *Response) error {
rsp.Message = "Hello " + req.Name
return nil
}
func main() {
// create the service
service := micro.New("helloworld")
// register handler
service.Handle(new(Say))
// run the service
service.Run()
}
```
Set a fixed address
```go
service := micro.New("helloworld", micro.Address(":8080"))
```
Call it via curl
```bash
curl -XPOST \
-H 'Content-Type: application/json' \
-H 'Micro-Endpoint: Say.Hello' \
-d '{"name": "alice"}' \
http://localhost:8080
```
## MCP & AI Agents
<img src="internal/website/images/generated/mcp-agent.png" alt="AI agent calling microservices via MCP" width="100%" />
Go Micro is designed for an **agent-first** workflow. Every service you build automatically becomes a tool that AI agents can discover and use via the [Model Context Protocol (MCP)](https://modelcontextprotocol.io/).
- **[🤖 Agent Playground](https://go-micro.dev/docs/mcp.html)** — Chat with your services through an interactive AI agent at `/agent`
- **[🔧 MCP Tools Registry](https://go-micro.dev/docs/mcp.html)** — Browse all services exposed as AI-callable tools at `/mcp/tools`
- **[📖 MCP Documentation](https://go-micro.dev/docs/mcp.html)** — Full guide to MCP integration, auth, and scopes
### Services as Tools
Write a normal Go Micro service and it's instantly available as an MCP tool:
```go
// SayHello greets a person by name.
// Hello greets a person by name.
// @example {"name": "Alice"}
func (g *GreeterService) SayHello(ctx context.Context, req *HelloRequest, rsp *HelloResponse) error {
func (h *Say) Hello(ctx context.Context, req *Request, rsp *Response) error {
rsp.Message = "Hello " + req.Name
return nil
}
func main() {
service := micro.NewService("greeter")
service.Handle(new(Say))
service.Run()
}
```
Run with `micro run` and the agent playground and MCP tools registry are ready:
Run it and everything is accessible — REST, gRPC, MCP, agent playground:
```bash
micro run
# Agent Playground: http://localhost:8080/agent
# MCP Tools: http://localhost:8080/mcp/tools
# Dashboard: http://localhost:8080
# API: http://localhost:8080/api/{service}/{method}
# Agent: http://localhost:8080/agent
# MCP Tools: http://localhost:8080/mcp/tools
```
Use `micro mcp serve` for local AI tools like Claude Code, or connect any MCP-compatible agent to the HTTP endpoint.
### micro chat
For an interactive terminal session that lets you talk to your services through an LLM:
You can also scaffold a service from a template:
```bash
ANTHROPIC_API_KEY=sk-ant-... micro chat --provider anthropic
> list all users
> create an order for product 42
micro new helloworld
micro new contacts --template crud
```
`micro chat` discovers every service in the registry, exposes each endpoint as a tool, and lets the model orchestrate calls. The same building blocks (`ai/tools`) work from your own services:
## Building Agents
An Agent is a service with an LLM inside it. It has a proto-defined `Agent.Chat` RPC endpoint, registers in the registry, and is callable like any service:
```go
import "go-micro.dev/v5/ai/tools"
set := tools.New(service.Registry())
discovered, _ := set.Discover()
m := ai.New("anthropic",
ai.WithAPIKey(key),
ai.WithToolHandler(set.Handler(service.Client())),
agent := micro.NewAgent("task-mgr",
micro.AgentServices("task", "project"),
micro.AgentPrompt("You manage tasks and projects. You understand deadlines and priorities."),
micro.AgentProvider("anthropic"),
)
resp, _ := m.Generate(ctx, &ai.Request{
Prompt: userInput,
Tools: discovered,
})
agent.Run()
```
See the [MCP guide](https://go-micro.dev/docs/mcp.html) for authentication, scopes, and advanced usage.
## Multi-Service Binaries
Run multiple services in a single binary — start as a modular monolith, split into separate deployments later when you actually need to.
The agent discovers its services from the registry, scopes its tools to their endpoints, and maintains conversation memory in the store. It registers itself so `micro chat` and other agents can find it.
```go
users := micro.New("users", micro.Address(":9001"))
orders := micro.New("orders", micro.Address(":9002"))
// Programmatic interaction
resp, _ := agent.Ask(ctx, "What tasks are overdue?")
fmt.Println(resp.Reply)
```
Multiple agents coordinate via RPC — each is a service with an `Agent.Chat` endpoint. `micro chat` routes to the right one.
```bash
micro agent list # list registered agents
micro call task-mgr Agent.Chat '{"message": "What tasks are overdue?"}'
```
### Plan & Delegate
Every agent gets two built-in harness capabilities, exposed as tools — no extra setup or separate graph runtime:
- **`plan`** — for multi-step work, the agent records an ordered plan in its store-backed memory and stays oriented across turns.
- **`delegate`** — the agent hands a self-contained subtask to another agent. If a registered agent already owns the relevant services, the hand-off goes over RPC to that agent; otherwise a focused, short-lived sub-agent is created for the subtask with its own isolated context.
This keeps intelligence distributed: an agent doesn't need to know *how* to do everything, only *who* does. See [examples/agent-plan-delegate](examples/agent-plan-delegate/).
```go
// A sub-agent is just an agent — created with New, talked to with Ask.
// delegate-first: reuse a registered agent, or spin up a focused one.
resp, _ := agent.Ask(ctx, "Plan the launch, create the tasks, and have comms notify the owner.")
```
### Batteries included, pluggable
Just as a service composes pluggable abstractions (registry, broker, store), an agent composes a **model**, **memory**, and **tools** — sane defaults out of the box, each swappable.
```go
agent := micro.NewAgent("assistant",
micro.AgentProvider("anthropic"), // model — swap the provider
micro.AgentCompactMemory(40, 12), // memory — durable, summarized, recallable
micro.AgentTool("weather", "Get the weather for a city",
map[string]any{"city": map[string]any{"type": "string"}},
func(ctx context.Context, in map[string]any) (string, error) {
return getWeather(in["city"].(string)) // tools beyond your services — any function
}),
micro.AgentMaxSteps(8), // guardrails
)
```
**Memory** is durable and store-backed by default (Postgres, NATS KV, or file), so an agent picks up where it left off after a restart — or supply your own with `AgentMemory`. Long-running agents can opt into `AgentCompactMemory(maxMessages, keepRecent)`: older turns are collapsed into a deterministic summary, recent turns stay verbatim, and relevant archived turns are recalled on future asks without replaying the whole conversation. **Tools** are your services automatically, plus any function you register with `AgentTool`.
### Paid tools (x402)
Every endpoint is an AI-callable tool — and it can be a *paid* tool. Go Micro supports [x402](https://x402.org), the HTTP 402 payment standard for agents, so a tool can require a stablecoin payment and an agent can settle it autonomously. It's opt-in and carries no crypto in the framework: verification is delegated to a pluggable facilitator (Coinbase, Alchemy, self-hosted), so Base and Solana are just different facilitators.
```bash
# Charge for tool calls at the MCP gateway (off unless you set a pay-to address)
micro mcp serve --x402_pay_to 0xYourAddress --x402_network solana --x402_amount 10000
# Per-tool amounts via a config file
micro mcp serve --x402_config x402.json
```
See the [Payments (x402) guide](internal/website/docs/guides/x402-payments.md).
### Reachable by other agents (A2A)
Within a Go Micro system, agents reach each other over RPC. To make them reachable by agents on *other* frameworks, Go Micro speaks the [Agent2Agent (A2A) protocol](https://a2a-protocol.org). The A2A gateway discovers your agents from the registry, generates an Agent Card for each from its metadata — the same way the MCP gateway derives tools from service endpoints — and translates incoming A2A tasks to the agent's `Agent.Chat` RPC. No per-agent code: register an agent and it's reachable over A2A.
```bash
micro a2a serve --address :4000 # gateway: expose every registered agent over A2A
micro a2a list # agents and their Agent Card URLs
```
Or skip the gateway entirely — an agent can serve its own A2A endpoint directly, handling tasks in-process:
```go
micro.NewAgent("task-mgr", micro.AgentServices("task"), micro.AgentA2A(":4000"))
```
It works both ways. To call an agent on another framework, an `a2a.Client` is wired into the two places that hand off work: `flow.A2A(url)` as a workflow step (the cross-framework `Dispatch`), and `delegate` to an `http(s)` URL from inside an agent.
MCP exposes your services as tools; A2A exposes your agents as agents. See the [A2A guide](internal/website/docs/guides/a2a-protocol.md).
## Features
### AI
| Feature | Details |
|---------|---------|
| Agents | `micro.NewAgent()` — intelligent layer that manages services |
| Plan & delegate | Built-in agent tools — plan multi-step work, delegate subtasks to other agents |
| Pluggable memory | Durable store-backed conversation memory by default; swap with `AgentMemory` |
| Custom tools | `AgentTool` — give an agent any function as a tool, beyond its services |
| Guardrails | `MaxSteps` (stop on count), `LoopLimit` (stop repeated no-progress calls), `ApproveTool` (human-in-the-loop) |
| Tool middleware | `AgentWrapTool` — wrap tool execution for logging, metrics, or retries (like client/server wrappers) |
| Workflows | `micro.NewFlow()` — event-driven; one step, ordered durable steps, or triggers an agent |
| Durable execution | Checkpointed flow steps survive a crash and resume where they stopped; store-backed by default, pluggable backend |
| 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 |
| Interactive console | `micro run` includes a chat console for talking to services |
| Service generation | `micro run --prompt` — describe a system, get running services |
### Framework
| Feature | Details |
|---------|---------|
| Service registry | mDNS (default), Consul, etcd |
| RPC client/server | gRPC transport, load balancing, streaming |
| Pub/sub events | NATS, RabbitMQ, HTTP broker |
| Key-value store | File (bbolt), Postgres, NATS KV |
| Typed model layer | CRUD + queries, SQLite/Postgres backends |
| Everything swappable | All abstractions are Go interfaces |
### Developer experience & deployment
| Feature | Details |
|---------|---------|
| Hot reload | `micro run` watches files, rebuilds on change |
| Templates | `micro new --template crud/pubsub/api` |
| One-command deploy | `micro deploy user@server` — SSH + systemd, no Docker |
## CLI
| Command | Purpose |
|---------|---------|
| `micro run --prompt "..."` | Generate services + agent, start with interactive console |
| `micro run` | Dev mode: hot reload, gateway, interactive console |
| `micro run -d` | Detached mode (no console) |
| `micro chat` | Standalone chat (when not using micro run) |
| `micro agent list` | List registered agents |
| `micro new myservice` | Scaffold a service |
| `micro call service endpoint '{}'` | Call a service or agent from the CLI |
| `micro build` | Compile production binaries |
| `micro deploy user@server` | Deploy via SSH + systemd |
## Multi-Service Projects
Run multiple services together:
```go
users := micro.NewService("users", micro.Address(":9001"))
orders := micro.NewService("orders", micro.Address(":9002"))
users.Handle(new(Users))
orders.Handle(new(Orders))
// Run all services together with shared lifecycle
g := micro.NewGroup(users, orders)
g.Run()
```
Each service gets its own server, client, store, and cache while sharing the registry, broker, and transport — so they can discover and call each other within the same process.
See the [multi-service example](examples/multi-service/) for a working demo.
## Data Model
Go Micro includes a typed data model layer for persistence. Define a struct, tag a key field, and get type-safe CRUD and query operations backed by memory, SQLite, or Postgres.
```go
import (
"go-micro.dev/v5/model"
"go-micro.dev/v5/model/sqlite"
)
// Define your data type
type User struct {
ID string `json:"id" model:"key"`
Name string `json:"name"`
Email string `json:"email" model:"index"`
Age int `json:"age"`
}
```
Register your types and use the model:
```go
service := micro.New("users")
// Register and use the service's model backend
db := service.Model()
db.Register(&User{})
// CRUD operations
db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com", Age: 30})
user := &User{}
db.Read(ctx, "1", user)
user.Name = "Alice Smith"
db.Update(ctx, user)
db.Delete(ctx, "1", &User{})
```
Query with filters, ordering, and pagination:
```go
var results []*User
// Find users by field
db.List(ctx, &results, model.Where("email", "alice@example.com"))
// Complex queries
db.List(ctx, &results,
model.WhereOp("age", ">=", 18),
model.OrderDesc("name"),
model.Limit(10),
model.Offset(20),
)
count, _ := users.Count(ctx, model.Where("age", 30))
```
Swap backends with an option:
```go
// Development: in-memory (default)
service := micro.New("users")
// Production: SQLite or Postgres
db, _ := sqlite.New(model.WithDSN("file:app.db"))
service := micro.New("users", micro.Model(db))
```
Every service gets `Client()`, `Server()`, and `Model()` — call services, handle requests, and save data all from the same interface.
## Examples
Check out [/examples](examples/) for runnable code:
- [hello-world](examples/hello-world/) - Basic RPC service
- [grpc-interop](examples/grpc-interop/) - Call go-micro from any gRPC client
- [web-service](examples/web-service/) - HTTP REST API
- [multi-service](examples/multi-service/) - Multiple services in one binary
- [mcp](examples/mcp/) - MCP integration with AI agents
See [all examples](examples/README.md) for more.
## Protobuf
Install the code generator and see usage in the docs:
```bash
go install go-micro.dev/v5/cmd/protoc-gen-micro@v5.16.0
```
> **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.
Docs: [`internal/website/docs/getting-started.md`](internal/website/docs/getting-started.md)
## Command Line
Install the CLI:
```
go install go-micro.dev/v5/cmd/micro@v5.16.0
```
> **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.
### Quick Start
```bash
micro new helloworld # Create a new service
cd helloworld
micro run # Run with API gateway and hot reload
```
Then open http://localhost:8080 to see your service and call it from the browser.
### Development Workflow
| Stage | Command | Purpose |
|-------|---------|---------|
| **Develop** | `micro run` | Local dev with hot reload and API gateway |
| **Build** | `micro build` | Compile production binaries |
| **Deploy** | `micro deploy` | Push to a remote Linux server via SSH + systemd |
| **Dashboard** | `micro server` | Optional production web UI with JWT auth |
### micro run
`micro run` starts your services with:
- **Web Dashboard** - Browse and call services at `/`
- **Agent Playground** - AI chat with MCP tools at `/agent`
- **API Explorer** - Browse endpoints and schemas at `/api`
- **API Gateway** - HTTP to RPC proxy at `/api/{service}/{method}` (no auth in dev mode)
- **MCP Tools** - Services as AI tools at `/mcp/tools`
- **Health Checks** - Aggregated health at `/health`
- **Hot Reload** - Auto-rebuild on file changes
> **Note:** `micro run` and `micro server` use a unified gateway architecture. See [Gateway Architecture](cmd/micro/README.md#gateway-architecture) for details.
```bash
micro run # Gateway on :8080
micro run --address :3000 # Custom gateway port
micro run --no-gateway # Services only
micro run --env production # Use production environment
```
### Configuration
For multi-service projects, create a `micro.mu` file:
Or use a `micro.mu` config file:
```
service users
path ./users
port 8081
service posts
path ./posts
port 8082
service orders
path ./orders
depends users
env development
DATABASE_URL sqlite://./dev.db
```
The gateway runs on :8080 by default, so services should use other ports.
## Data Model
### Deployment
Deploy to any Linux server with systemd:
```bash
# On your server (one-time setup)
curl -fsSL https://go-micro.dev/install.sh | sh
sudo micro init --server
# From your laptop
micro deploy user@your-server
```
The deploy command:
1. Builds binaries for Linux
2. Copies via SSH to the server
3. Sets up systemd services
4. Verifies services are healthy
Optionally run `micro server` on the deployed machine for a production web dashboard with JWT auth, user management, and API explorer.
Manage deployed services:
```bash
micro status --remote user@server # Check status
micro logs --remote user@server # View logs
micro logs myservice --remote user@server -f # Follow specific service
```
No Docker required. No Kubernetes. Just systemd.
See [internal/website/docs/deployment.md](internal/website/docs/deployment.md) for full deployment guide.
See [cmd/micro/README.md](cmd/micro/README.md) for full CLI documentation.
Docs: [`internal/website/docs`](internal/website/docs)
Package reference: https://pkg.go.dev/go-micro.dev/v5
**User Guides:**
- [Getting Started](internal/website/docs/getting-started.md)
- [Data Model](internal/website/docs/model.md)
- [MCP & AI Agents](internal/website/docs/mcp.md)
- [AI Provider Integration](internal/website/docs/guides/ai-provider-guide.md)
- [Plugins Overview](internal/website/docs/plugins.md)
- [Learn by Example](internal/website/docs/examples/index.md)
- [Deployment Guide](internal/website/docs/deployment.md)
**Architecture & Performance:**
- [Performance Considerations](internal/website/docs/performance.md)
- [Reflection Usage & Philosophy](internal/website/docs/REFLECTION-EVALUATION-SUMMARY.md)
**Security:**
- [TLS Security Migration](internal/website/docs/TLS_SECURITY_UPDATE.md)
- [Security Migration Guide](internal/website/docs/SECURITY_MIGRATION.md)
## Supported AI Providers
Go Micros `ai` package gives every provider the same interface: `Init`, `Generate`, `Stream`, and functional options. Swap providers with a single import.
| Provider | Import | Default Model |
|----------|--------|---------------|
| **Anthropic** | `go-micro.dev/v5/ai/anthropic` | `claude-sonnet-4-20250514` |
| **Google Gemini** | `go-micro.dev/v5/ai/gemini` | `gemini-2.5-flash` |
| **Groq** | `go-micro.dev/v5/ai/groq` | `llama-3.3-70b-versatile` |
| **Mistral** | `go-micro.dev/v5/ai/mistral` | `mistral-large-latest` |
| **OpenAI** | `go-micro.dev/v5/ai/openai` | `gpt-4o` |
| **Together AI** | `go-micro.dev/v5/ai/together` | `Llama-3.3-70B-Instruct-Turbo` |
| **Atlas Cloud** | `go-micro.dev/v5/ai/atlascloud` | `llama-3.3-70b` |
Any provider that exposes an OpenAI-compatible API can also be used directly:
Typed persistence with CRUD and queries:
```go
m := ai.New("openai",
ai.WithAPIKey("your-key"),
ai.WithBaseURL("https://api.yourprovider.com"),
)
type User struct {
ID string `json:"id" model:"key"`
Name string `json:"name"`
Email string `json:"email" model:"index"`
}
db := service.Model()
db.Register(&User{})
db.Create(ctx, &User{ID: "1", Name: "Alice", Email: "alice@example.com"})
var results []*User
db.List(ctx, &results, model.Where("email", "alice@example.com"))
```
**Want to add your platform?** See the [AI Provider Integration Guide](internal/website/docs/guides/ai-provider-guide.md) for how to implement `ai.Model` and submit a PR. We welcome both code contributions and sponsorships from AI infrastructure companies — reach out via a [GitHub issue](https://github.com/micro/go-micro/issues).
Backends: memory (default), SQLite, Postgres.
## Adopters
## AI Providers
- [Sourse](https://sourse.eu) - Work in the field of earth observation, including embedded Kubernetes running onboard aircraft, and weve built a mission management SaaS platform using Go Micro.
Swap providers with a single import — same interface everywhere:
| Provider | Default Model |
|----------|---------------|
| Anthropic | `claude-sonnet-4-20250514` |
| OpenAI | `gpt-4o` |
| 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` |
```go
m := ai.New("anthropic", ai.WithAPIKey(key))
resp, _ := m.Generate(ctx, &ai.Request{Prompt: "hello"})
```
## Examples
- [hello-world](examples/hello-world/) — Basic RPC service
- [multi-service](examples/multi-service/) — Multiple services in one binary
- [mcp](examples/mcp/) — MCP integration with AI agents
- [agent-plan-delegate](examples/agent-plan-delegate/) — Agent planning and multi-agent delegation
- [grpc-interop](examples/grpc-interop/) — Call go-micro from any gRPC client
See [all examples](examples/README.md).
## Docs
- [Getting Started](internal/website/docs/getting-started.md)
- [AI Integration](internal/website/docs/ai-integration.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)
- [Agent Guardrails](internal/website/docs/guides/agent-guardrails.md)
- [Payments (x402)](internal/website/docs/guides/x402-payments.md)
- [MCP & AI Agents](internal/website/docs/mcp.md)
- [Data Model](internal/website/docs/model.md)
- [Deployment](internal/website/docs/deployment.md)
- [Plugins](internal/website/docs/plugins.md)
Package reference: https://pkg.go.dev/go-micro.dev/v6
+57 -160
View File
@@ -1,182 +1,79 @@
# Go Micro Roadmap
This roadmap outlines the planned features and improvements for Go Micro. Community feedback and contributions are welcome!
Go Micro is an **agent harness** and service framework for Go. A harness is the
runtime around an agent — the tools, memory, guardrails, workflows, state,
discovery, and protocols it needs to operate a system rather than just answer a
prompt. An agent is a distributed system — it discovers services, calls them,
holds state, and recovers from failure — so the harness is the runtime services
already have, and building an agent is building a service. The roadmap has two
jobs: make **agentic development** excellent, and make the **developer experience**
around it excellent.
> **See [internal/docs/ROADMAP_2026.md](internal/docs/ROADMAP_2026.md) for the AI-Native Era roadmap** focused on MCP integration, agent-first development, and business sustainability. This document covers general framework improvements.
The full, current roadmap lives at **[go-micro.dev/docs/roadmap](https://go-micro.dev/docs/roadmap)**
([source](internal/website/docs/roadmap.md)). The highlights:
## Current Focus (Q1 2026) - COMPLETE
## Where we are (v6)
### Documentation & Developer Experience
- [x] Modernize documentation structure
- [x] Add learn-by-example guides
- [x] Update issue templates
- [x] MCP integration documentation
- [x] Agent playground and MCP tools registry
- [ ] Create video tutorials
- [ ] Interactive documentation site
- [ ] Plugin discovery dashboard
Services, agents (`plan`/`delegate`, guardrails, memory, tool middleware), durable
flows, the MCP and A2A gateways (both directions, including A2A streaming,
push notifications, and multi-turn continuation), x402 paid tools, secure by
default.
### AI & Model Integration
- [x] AI package with provider abstraction (`ai.Model` interface)
- [x] Anthropic Claude provider (`ai/anthropic`)
- [x] OpenAI GPT provider (`ai/openai`)
- [x] Tool execution with auto-calling support
- [x] Streaming support via `ai.Stream`
## Principles
### Observability
- [ ] OpenTelemetry native support
- [ ] Auto-instrumentation for handlers
- [ ] Metrics export standardization
- [ ] Distributed tracing examples
- [ ] Integration with popular observability platforms
1. Build into what people run, never a separate product (no hosted platform, no
enterprise edition, no VC).
2. CLI-first — the CLI is the experience; UI must earn its place, never bloat.
3. The getting-started flow is a contract: *0→1* (scaffold → run → call) and
*0→hero* (a working multi-agent system) must always work and are verified on
every change.
4. Interaction matters as much as running — chatting with agents, inspecting runs
and history, end to end.
5. Battle-tested: works across every provider, fails safely, observable.
### Developer Tools
- [x] `micro run` with hot reload and unified gateway
- [x] `micro deploy` with SSH + systemd deployment
- [x] `micro mcp` command suite (serve, list, test, docs, export)
- [ ] `micro dev` with enhanced hot reload
- [ ] Service templates (`micro new --template`)
- [ ] Better error messages with suggestions
- [ ] Debug tooling improvements
- [ ] VS Code extension for Go Micro
## Now — hardening
## Q2 2026
- **Cross-provider conformance** — the same agent scenario across all seven
providers, gated on keys, on a schedule.
- **Failure & resilience** — timeouts, rate limits, cancellation, deadline/context
propagation, retry/backoff.
- **Getting-started contract** — define and CI-verify the 0→1 and 0→hero flows.
### Production Readiness
- [x] Health check standardization
- [x] Graceful shutdown improvements
- [ ] Resource cleanup best practices
- [ ] Load testing framework integration
- [ ] Performance benchmarking suite
## Next — agentic depth
### Cloud Native
- [ ] Kubernetes operator
- [ ] Helm charts for common setups
- [ ] Service mesh integration guides (Istio, Linkerd)
- [ ] Cloud provider quickstarts (AWS, GCP, Azure)
- [ ] Multi-cluster patterns
- **Durable agent loop** — resume a long run via `Checkpoint` (flows already do).
- **Streaming** — broaden provider-backed `ai.Stream` coverage and keep chat/A2A streaming end to end.
- **Agent observability** — `RunInfo` → OpenTelemetry spans.
### Security
- [x] Bearer token authentication for MCP
- [x] Per-tool scope enforcement
- [x] Audit logging
- [x] Rate limiting
- [ ] mTLS by default option
- [ ] Secret management integration (Vault, AWS Secrets Manager)
- [ ] RBAC improvements
- [ ] Security audit and hardening
- [ ] CVE scanning and response process
## Later
## Q3 2026
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
richer A2A live-stream reconnection (`tasks/resubscribe`) and `input-required`
handoffs.
### Plugin Ecosystem
- [ ] Plugin marketplace/registry
- [ ] Plugin quality standards
- [ ] Community plugin contributions
- [ ] Plugin compatibility matrix
- [ ] Auto-discovery of available plugins
## Developer experience (ongoing)
### Streaming & Async
- [ ] Improved streaming support
- [x] Server-sent events (SSE) support (via MCP gateway)
- [ ] WebSocket plugin
- [ ] Event sourcing patterns
- [ ] CQRS examples
- A seamless CLI inner loop (scaffold → run → chat → inspect → deploy); UI
discipline (trim what isn't great); a maintained real-world example that doubles
as the 0→hero reference; docs kept in lockstep with the code.
### Testing
- [ ] Mock generation tooling
- [ ] Integration test helpers
- [ ] Contract testing support
- [ ] Chaos engineering examples
- [ ] E2E testing framework
## How it's sustained
## Q4 2026
The framework is the product, funded by sponsorship from those who run it — not a
hosted service, enterprise tier, or venture funding. See
[the v6 story](https://go-micro.dev/blog/27).
### Performance
- [ ] Connection pooling optimizations
- [ ] Zero-allocation paths
- [ ] gRPC performance improvements
- [ ] Caching strategies guide
- [ ] Performance profiling tools
## Contributing & feedback
### Developer Productivity
- [ ] Code generation improvements
- [ ] Better IDE support
- [ ] Debugging tools
- [ ] Migration automation tools
- [ ] Upgrade helpers
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
`make lint`.
### Community
- [ ] Regular blog posts and case studies
- [ ] Community spotlight program
- [ ] Contribution rewards
- [ ] Monthly community calls
- [ ] Conference presence
## Version support
## Long-term Vision
- **v6** — active development (current).
- **v5** — security fixes only.
- **v4 and earlier** — end of life.
### Core Framework
- Maintain backward compatibility (Go Micro v5+)
- Progressive disclosure of complexity
- Best-in-class developer experience
- Production-grade reliability
- Comprehensive plugin ecosystem
### Ecosystem Goals
- 100+ production deployments documented
- 50+ community plugins
- Active contributor community
- Regular releases (monthly patches, quarterly features)
- Comprehensive benchmarks vs alternatives
### Differentiation
- **Batteries included, fully swappable** - Start simple, scale complex
- **Zero-config local development** - No infrastructure required to start
- **AI-native by default** - Every service is an MCP tool automatically
- **Plugin ecosystem in-repo** - No version compatibility hell
- **Progressive complexity** - Learn as you grow
- **Cloud-native first** - Built for Kubernetes and containers
## Contributing
We welcome contributions to any roadmap items! See [CONTRIBUTING.md](CONTRIBUTING.md) for guidelines.
### High Priority Areas
1. Documentation improvements (guides, tutorials)
2. Multi-protocol MCP support (WebSocket, gRPC)
3. Agent SDK integrations (LlamaIndex, AutoGPT)
4. OpenTelemetry integration
5. Kubernetes operator and Helm charts
### How to Contribute
- Pick an item from the roadmap
- Open an issue to discuss approach
- Submit a PR with implementation
- Help review others' contributions
## Feedback
Have suggestions for the roadmap?
- Open a [feature request](.github/ISSUE_TEMPLATE/feature_request.md)
- Start a discussion in GitHub Discussions
- Comment on existing roadmap issues
## Version Compatibility
We follow semantic versioning:
- Major versions (v5 → v6): Breaking changes
- Minor versions (v5.3 → v5.4): New features, backward compatible
- Patch versions (v5.3.0 → v5.3.1): Bug fixes, no API changes
## Support Timeline
- v5: Active development (current)
- v4: Security fixes only (until v6 release)
- v3: End of life
---
Last updated: March 2026
This roadmap is subject to change based on community needs and priorities.
Major versions (v5 → v6) carry breaking changes; minors are backward-compatible.
See the [v5 → v6 migration guide](https://go-micro.dev/docs/guides/migration/v5-to-v6).
+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/jwTYuUVAGh
- Join Discord: https://discord.gg/WeMU5AGxD
- Email: support@go-micro.dev
+29
View File
@@ -0,0 +1,29 @@
# Support
Go Micro is free and open source. There are two ways to get help: the community, and commercial support.
## Community support (free)
- **Documentation** — https://go-micro.dev/docs
- **Examples** — https://github.com/micro/go-micro/tree/master/examples
- **Bugs & features** — https://github.com/micro/go-micro/issues
- **Questions** — open a [Question](https://github.com/micro/go-micro/issues/new?template=question.md) issue
Community support is best-effort, from maintainers and contributors, with no response-time guarantees.
## Commercial support
If you're running Go Micro in production — or building agents and services on it and want a hand — paid support and consulting are available directly from the maintainer. This is what keeps the project maintained.
| Tier | For | What you get | How |
|------|-----|--------------|-----|
| **Community** | Everyone | Docs, examples, issues — best-effort | Free |
| **Sponsor** | Individuals & companies who rely on Go Micro | Back ongoing development; your name/logo in the README and on the site; a voice in priorities | [GitHub Sponsors](https://github.com/sponsors/asim) |
| **Support** | Teams running Go Micro in production | Priority responses, a direct line to the maintainer, prioritized bug fixes, upgrade & integration help | [Open a request](#get-in-touch) |
| **Consulting** | Teams building on Go Micro | Hands-on integration, architecture & agent-design review, training & onboarding, sponsored features | [Open a request](#get-in-touch) |
Recurring amounts are set on the [Sponsors page](https://github.com/sponsors/asim); support and consulting are scoped and quoted per engagement.
## Get in touch
Open a [**Commercial Support / Consulting**](https://github.com/micro/go-micro/issues/new?template=commercial_support.md) request — tell us what you're building, what you need, and your timeline, and we'll follow up. For anything you'd rather not discuss in public, become a [sponsor](https://github.com/sponsors/asim) and message privately.
+480
View File
@@ -0,0 +1,480 @@
// Package agent provides the Agent abstraction for Go Micro.
//
// An Agent is a service with an LLM inside it. It registers a Chat
// RPC endpoint, discovers its assigned services' tools, and
// orchestrates them intelligently.
//
// agent := micro.NewAgent("task-mgr",
// micro.AgentServices("task"),
// micro.AgentPrompt("You manage tasks."),
// micro.AgentProvider("anthropic"),
// )
// agent.Run()
package agent
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"sync"
"github.com/google/uuid"
pb "go-micro.dev/v6/agent/proto"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/server"
"go-micro.dev/v6/store"
_ "go-micro.dev/v6/ai/anthropic"
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
// Agent is the interface for an AI agent that manages services.
type Agent interface {
Name() string
Init(...Option)
Options() Options
Ask(ctx context.Context, message string) (*Response, error)
Stream(ctx context.Context, message string) (ai.Stream, error)
Run() error
Stop() error
String() string
}
// Response is what an agent returns from Chat.
type Response struct {
Reply string
ToolCalls []ai.ToolCall
Agent string
// RunID correlates this Ask with tool calls, trace spans, and the
// persisted run timeline. ParentID is set when this response belongs
// to a delegated sub-agent run.
RunID string
ParentID string
}
type agentImpl struct {
opts Options
model ai.Model
tools *ai.Tools
mem Memory
server server.Server
mu sync.Mutex
// ephemeral marks a short-lived sub-agent created by delegation.
// Ephemeral agents run with an isolated context: they load and
// persist no history, and have no built-in tools (so they cannot
// plan or re-delegate).
ephemeral bool
// steps counts tool executions in the current Ask, for MaxSteps.
steps int
// calls counts identical tool calls (name+args) in the current Ask,
// for LoopLimit.
calls map[string]int
// runID correlates the tool calls of the current Ask; parentRunID is
// the run that delegated to this one (set on ephemeral sub-agents).
// Both are surfaced to tool wrappers via ai.RunInfo on the context.
runID string
parentRunID string
// pause records a guardrail approval pause raised during the current
// Ask. The model provider only sees a refused tool result; the agent
// converts it into a durable paused run instead of completing the run.
pause *approvalPause
// currentRun points at the checkpoint record for the Ask currently
// holding mu. Tool execution updates it so resumed runs can reuse
// completed tool results without replaying side effects.
currentRun *flow.Run
}
// New creates a new Agent.
func New(opts ...Option) Agent {
return &agentImpl{
opts: newOptions(opts...),
}
}
// newEphemeral creates a short-lived sub-agent for a delegated subtask.
// It shares the parent's provider, model, and infrastructure but runs
// with an isolated context: it loads and persists no history and has no
// built-in tools (so it can neither plan nor re-delegate). Returns the
// concrete type because ephemeral is an internal construction detail,
// not a public option.
func newEphemeral(opts ...Option) *agentImpl {
return &agentImpl{
opts: newOptions(opts...),
ephemeral: true,
}
}
func (a *agentImpl) Name() string {
return a.opts.Name
}
func (a *agentImpl) Init(opts ...Option) {
for _, o := range opts {
o(&a.opts)
}
a.setup()
}
func (a *agentImpl) Options() Options {
return a.opts
}
func (a *agentImpl) String() string {
return "agent"
}
func (a *agentImpl) setup() {
var modelOpts []ai.Option
modelOpts = append(modelOpts, ai.WithAPIKey(a.opts.APIKey))
if a.opts.Model != "" {
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
}
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
modelOpts = append(modelOpts, ai.WithToolHandler(a.toolHandler()))
a.model = ai.New(a.opts.Provider, modelOpts...)
if a.model != nil {
a.model = a.tracedModel(a.model)
}
// Memory is pluggable. Use the configured one, otherwise the default
// store-backed memory — except ephemeral sub-agents, which keep an
// isolated, non-persistent context.
switch {
case a.opts.Memory != nil:
a.mem = a.opts.Memory
case a.ephemeral:
a.mem = NewInMemory(a.opts.HistoryLimit)
case a.opts.MemoryCompaction.MaxMessages > 0:
a.mem = NewCompactingMemory(a.stateStore(), "history", a.opts.MemoryCompaction.MaxMessages, a.opts.MemoryCompaction.KeepRecent)
default:
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
}
}
// stateStore returns the agent's own state store, scoped to its name so
// memory and plan live in their own table ("agent/{name}") rather than a
// shared global one. The scoped handle injects the database/table per
// operation without mutating the underlying store.
func (a *agentImpl) stateStore() store.Store {
s := a.opts.Store
if s == nil {
s = store.DefaultStore
}
return store.Scope(s, "agent", a.opts.Name)
}
// Ask sends a message and returns the agent's response.
// This is the programmatic API for direct use.
func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error) {
return a.ask(ctx, message, a.parentRunID)
}
// Stream sends a message and returns a streaming model response. Tool-calling
// agent runs still use Ask; Stream is for chat turns where immediate token
// delivery is more important than tool orchestration.
func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
toolList, err := a.discoverTools()
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
a.mem.Add("user", message)
return a.model.Stream(ctx, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: a.mem.Messages(),
})
}
// Pending returns checkpointed agent runs that have not completed. It mirrors
// flow.Pending for startup recovery loops that drain durable agent work.
func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, fmt.Errorf("agent pending: unsupported agent implementation %T", ag)
}
return a.pending(ctx)
}
func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Response, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil)
}
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run) (*Response, error) {
toolList, err := a.discoverTools()
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
a.mem.Add("user", message)
a.steps = 0
a.calls = map[string]int{}
a.pause = nil
// Correlate this run's tool calls and surface lineage to wrappers.
a.runID = runID
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
RunID: a.runID,
ParentID: parentRunID,
Agent: a.opts.Name,
})
run := a.newCheckpointRun(runID, message, parentRunID, existing)
a.currentRun = &run
defer func() { a.currentRun = nil }()
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
ctx, endRun := a.startRun(ctx, message)
defer func() { endRun(err) }()
messages := a.mem.Messages()
if recall, ok := a.mem.(MemoryRecall); ok && a.opts.MemoryRecallLimit > 0 {
if recalled := recall.Recall(message, a.opts.MemoryRecallLimit); len(recalled) > 0 {
messages = append([]ai.Message{{
Role: "system",
Content: "Relevant recalled memory follows; use it as durable prior context without assuming the whole conversation was replayed.",
}}, append(recalled, messages...)...)
}
}
resp, err := ai.GenerateWithRetry(ctx, a.model, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: messages,
}, ai.GeneratePolicy{
Timeout: a.opts.ModelTimeout,
MaxAttempts: a.opts.ModelMaxAttempts,
Backoff: a.opts.ModelRetryBackoff,
})
if err != nil {
run.Status = "failed"
run.Steps[0].Status = "failed"
run.Steps[0].Error = err.Error()
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
_ = a.saveRun(ctx, run)
return nil, err
}
if a.pause != nil && a.opts.Checkpoint != nil {
run.Status = "paused"
run.State.Stage = agentApprovalStep
run.State.Data = []byte(message)
if a.pause.Tool == toolHumanInput {
run.State.Stage = agentInputStep
_ = run.State.Set(inputPause{OriginalMessage: message, Prompt: a.pause.Message})
}
run.Steps[0].Status = "paused"
run.Steps[0].Error = a.pause.Message
run.Steps[0].Result = a.pause.Tool
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
return nil, fmt.Errorf("agent run %s paused for approval: %s", run.ID, a.pause.Message)
}
if resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
}
if resp.Answer != "" {
a.mem.Add("assistant", resp.Answer)
}
reply := resp.Reply
if resp.Answer != "" {
if reply != "" {
reply += "\n\n"
}
reply += resp.Answer
}
res := &Response{
Reply: reply,
ToolCalls: resp.ToolCalls,
Agent: a.opts.Name,
RunID: a.runID,
ParentID: parentRunID,
}
run.Status = "done"
run.State.Stage = ""
if b, marshalErr := json.Marshal(res); marshalErr == nil {
run.State.Data = b
}
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "done"
run.Steps[0].Attempts++
run.Steps[0].Result = reply
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
return res, nil
}
// Chat implements the proto AgentHandler interface for RPC.
// @example {"message": "What tasks are overdue?"}
func (a *agentImpl) Chat(ctx context.Context, req *pb.ChatRequest, rsp *pb.ChatResponse) error {
resp, err := a.ask(ctx, req.Message, req.ParentId)
if err != nil {
return err
}
rsp.Reply = resp.Reply
rsp.Agent = resp.Agent
rsp.RunId = resp.RunID
rsp.ParentId = resp.ParentID
for _, tc := range resp.ToolCalls {
input, _ := json.Marshal(tc.Input)
rsp.ToolCalls = append(rsp.ToolCalls, &pb.ToolCall{
Id: tc.ID,
Name: tc.Name,
Input: string(input),
Result: tc.Result,
})
}
return nil
}
// Run starts the agent as a service with a Chat RPC endpoint.
func (a *agentImpl) Run() error {
if a.model == nil {
a.setup()
}
a.server = server.NewServer(
server.Name(a.opts.Name),
server.Address(a.opts.Address),
server.Registry(a.opts.Registry),
server.Metadata(map[string]string{
"type": "agent",
"services": strings.Join(a.opts.Services, ","),
}),
)
_ = pb.RegisterAgentHandler(a.server, a)
if err := a.server.Start(); err != nil {
return fmt.Errorf("failed to start agent: %w", err)
}
fmt.Printf("Agent %s registered (manages: %s)\n", a.opts.Name, strings.Join(a.opts.Services, ", "))
// Optionally serve the agent directly over the A2A protocol, calling
// Ask in-process — no separate gateway needed to be queried by URL.
if a.opts.A2AAddress != "" {
card := a2a.Card(a.opts.Name, "http://localhost"+a.opts.A2AAddress, "", a.opts.Services)
handler := a2a.NewAgentStreamHandler(card, func(ctx context.Context, text string) (string, error) {
resp, err := a.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
}, a.Stream)
go func() {
if err := http.ListenAndServe(a.opts.A2AAddress, handler); err != nil {
fmt.Printf("agent %s A2A server: %v\n", a.opts.Name, err)
}
}()
fmt.Printf("Agent %s serving A2A on %s\n", a.opts.Name, a.opts.A2AAddress)
}
ch := make(chan struct{})
<-ch
return nil
}
func (a *agentImpl) Stop() error {
if a.server != nil {
return a.server.Stop()
}
return nil
}
func (a *agentImpl) discoverTools() ([]ai.Tool, error) {
all, err := a.tools.Discover()
if err != nil {
return nil, err
}
var scoped []ai.Tool
for _, t := range all {
if strings.HasPrefix(t.OriginalName, a.opts.Name+".") {
continue
}
if len(a.opts.Services) == 0 {
scoped = append(scoped, t)
continue
}
for _, svc := range a.opts.Services {
if strings.HasPrefix(t.OriginalName, svc+".") {
scoped = append(scoped, t)
break
}
}
}
// Developer-registered custom tools (WithTool).
for i := range a.opts.tools {
scoped = append(scoped, a.opts.tools[i].def)
}
// Expose the agent's own capabilities (plan, delegate) as tools.
// Ephemeral sub-agents don't get them.
if !a.ephemeral {
scoped = append(scoped, builtinTools()...)
}
return scoped, nil
}
func (a *agentImpl) buildPrompt() string {
var base string
switch {
case a.opts.Prompt != "":
base = a.opts.Prompt
case len(a.opts.Services) > 0:
base = fmt.Sprintf("You are the %s agent. You manage these services: %s. Use the available tools to fulfill requests.",
a.opts.Name, strings.Join(a.opts.Services, ", "))
default:
base = fmt.Sprintf("You are the %s agent. Use the available tools to fulfill requests.", a.opts.Name)
}
// Keep the agent oriented: surface its saved plan, if any.
if !a.ephemeral {
if plan := a.loadPlan(); plan != "" {
base += "\n\nYour current plan (update it with the plan tool as you make progress):\n" + plan
}
}
return base
}
+137
View File
@@ -0,0 +1,137 @@
package agent
import (
"context"
"testing"
pb "go-micro.dev/v6/agent/proto"
"go-micro.dev/v6/ai"
)
func TestNew(t *testing.T) {
a := New(
Name("test-agent"),
Services("task", "project"),
Prompt("You manage tasks."),
Provider("anthropic"),
)
if a.Name() != "test-agent" {
t.Errorf("Name() = %q, want %q", a.Name(), "test-agent")
}
opts := a.Options()
if opts.Provider != "anthropic" {
t.Errorf("Provider = %q, want %q", opts.Provider, "anthropic")
}
if len(opts.Services) != 2 {
t.Fatalf("Services = %v, want 2 items", opts.Services)
}
if opts.Services[0] != "task" || opts.Services[1] != "project" {
t.Errorf("Services = %v, want [task project]", opts.Services)
}
if opts.Prompt != "You manage tasks." {
t.Errorf("Prompt = %q, want %q", opts.Prompt, "You manage tasks.")
}
if opts.HistoryLimit != 50 {
t.Errorf("HistoryLimit = %d, want 50", opts.HistoryLimit)
}
}
func TestChatResponseIncludesRunIDs(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("chat-run"))
var rsp pb.ChatResponse
if err := a.Chat(context.Background(), &pb.ChatRequest{Message: "hello"}, &rsp); err != nil {
t.Fatalf("Chat: %v", err)
}
if rsp.RunId == "" {
t.Fatal("Chat response RunId is empty")
}
if rsp.Agent != "chat-run" {
t.Errorf("Agent = %q, want chat-run", rsp.Agent)
}
if rsp.ParentId != "" {
t.Errorf("ParentId = %q, want empty", rsp.ParentId)
}
}
func TestChatRequestParentIDPropagatesToResponse(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
info, ok := ai.RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from model context")
}
if info.ParentID != "flow-run-123" {
t.Fatalf("RunInfo.ParentID = %q, want flow-run-123", info.ParentID)
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("chat-child"))
var rsp pb.ChatResponse
if err := a.Chat(context.Background(), &pb.ChatRequest{Message: "hello", ParentId: "flow-run-123"}, &rsp); err != nil {
t.Fatalf("Chat: %v", err)
}
if rsp.ParentId != "flow-run-123" {
t.Errorf("ParentId = %q, want flow-run-123", rsp.ParentId)
}
}
func TestBuildPrompt(t *testing.T) {
// Custom prompt
a := New(Name("test"), Prompt("custom prompt")).(*agentImpl)
if got := a.buildPrompt(); got != "custom prompt" {
t.Errorf("buildPrompt() = %q, want %q", got, "custom prompt")
}
// Auto-generated prompt with services
a = New(Name("test"), Services("task", "project")).(*agentImpl)
got := a.buildPrompt()
if got == "" {
t.Error("buildPrompt() returned empty")
}
if !contains(got, "task") || !contains(got, "project") {
t.Errorf("buildPrompt() = %q, should mention services", got)
}
// Auto-generated prompt without services
a = New(Name("test")).(*agentImpl)
got = a.buildPrompt()
if !contains(got, "test") {
t.Errorf("buildPrompt() = %q, should mention agent name", got)
}
}
func TestDefaults(t *testing.T) {
a := New(Name("test"))
opts := a.Options()
if opts.Registry == nil {
t.Error("Registry should default to DefaultRegistry")
}
if opts.Client == nil {
t.Error("Client should default to DefaultClient")
}
if opts.Store == nil {
t.Error("Store should default to DefaultStore")
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (s == sub || len(s) > 0 && containsStr(s, sub))
}
func containsStr(s, sub string) bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
}
+400
View File
@@ -0,0 +1,400 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"go-micro.dev/v6/ai"
codecBytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/store"
)
// Built-in agent tools. These are not service endpoints — they are
// capabilities the agent has over itself: maintaining a plan in its
// memory, and delegating a subtask to another agent.
//
// They are plain tools, wired into the agent's tool handler alongside
// the discovered service tools. There is no separate harness or graph:
// the LLM calls them like any other tool.
const (
toolPlan = "plan"
toolDelegate = "delegate"
toolHumanInput = "request_input"
)
// builtinTools returns the tool definitions exposed to the model in
// addition to the agent's scoped service tools.
func builtinTools() []ai.Tool {
return []ai.Tool{
{
Name: toolPlan,
OriginalName: toolPlan,
Description: "Record or update your plan as an ordered list of steps before doing multi-step work. " +
"Call this whenever the plan changes. The plan is saved to your memory and shown back to you on later turns.",
Properties: map[string]any{
"steps": map[string]any{
"type": "array",
"description": "Ordered plan steps. Each step has a 'task' (string) and a " +
"'status' (one of: pending, in_progress, done).",
},
},
},
{
Name: toolHumanInput,
OriginalName: toolHumanInput,
Description: "Pause this agent run when you need missing information, a decision, or other human input before you can continue. " +
"The run is checkpointed as input-required and can be resumed with the human response without losing completed tool history.",
Properties: map[string]any{
"prompt": map[string]any{
"type": "string",
"description": "The specific question, decision, or instruction needed from the human operator.",
},
},
},
{
Name: toolDelegate,
OriginalName: toolDelegate,
Description: "Delegate a self-contained subtask to another agent. If 'to' names an agent that already " +
"manages the relevant services, that agent handles it; otherwise a focused sub-agent is created for the " +
"subtask. The sub-agent works in an isolated context and returns only its result. Use this to keep your " +
"own context focused and to let domain experts handle their own services.",
Properties: map[string]any{
"task": map[string]any{
"type": "string",
"description": "The subtask to delegate, described completely and self-contained.",
},
"to": map[string]any{
"type": "string",
"description": "Optional. The agent or service name best suited to the subtask, or the URL of an external agent that speaks the A2A protocol.",
},
},
},
}
}
// Builtins returns the built-in agent tools (plan, delegate) together
// with a handler for them, so the same capabilities can be wired into a
// tool loop that isn't a running Agent — for example the `micro chat`
// fallback. The handler's third return value is false when the name is
// not a built-in, so callers can fall through to their own tools.
//
// Configure it with the same options as an Agent (Name, Provider,
// WithStore, WithRegistry, WithClient, ...); these back plan's memory
// and delegate's RPC/sub-agent behavior.
func Builtins(opts ...Option) (tools []ai.Tool, handle func(name string, input map[string]any) (result any, content string, ok bool)) {
a := &agentImpl{opts: newOptions(opts...)}
handle = func(name string, input map[string]any) (any, string, bool) {
switch name {
case toolPlan:
r := a.handlePlan(ai.ToolCall{Name: name, Input: input})
return r.Value, r.Content, true
case toolHumanInput:
r := a.handleHumanInput(ai.ToolCall{Name: name, Input: input})
return r.Value, r.Content, true
case toolDelegate:
r := a.handleDelegate(context.Background(), ai.ToolCall{Name: name, Input: input})
return r.Value, r.Content, true
}
return nil, "", false
}
return builtinTools(), handle
}
// toolHandler returns the agent's tool-call handler, composed as a stack
// of wrappers around a base handler — the same middleware shape as
// client/server wrappers. The base executes the call (custom tools,
// delegate, or RPC); the built-in guardrails wrap it; developer wrappers
// (WrapTool) wrap those, outermost, so they observe every call and its
// result including guardrail refusals. Ephemeral sub-agents get the bare
// service handler so they can neither plan nor re-delegate (which
// prevents runaway recursion).
func (a *agentImpl) toolHandler() ai.ToolHandler {
if a.ephemeral {
return a.tools.Handler()
}
// Innermost first: base, then guardrails (approve → loop → step →
// plan), then developer wrappers outermost. Wrapping reverses order,
// so the result runs plan → step → loop → approve → checkpoint → base.
h := a.baseHandler()
h = a.checkpointToolWrap(h)
h = a.approveWrap(h)
h = a.loopWrap(h)
h = a.stepWrap(h)
h = a.planWrap(h)
h = contextWrap(h)
h = a.traceTool(h)
for i := len(a.opts.wrappers) - 1; i >= 0; i-- {
h = a.opts.wrappers[i](h)
}
return h
}
// contextWrap stops tool execution promptly when the Ask context has
// already been canceled or its deadline has expired. This keeps guardrail
// bookkeeping and side-effecting tools from running after the caller has
// abandoned the agent run.
func contextWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
select {
case <-ctx.Done():
return errResult(call.ID, ctx.Err().Error())
default:
}
return next(ctx, call)
}
}
// 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 {
rpc := a.tools.Handler()
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
for i := range a.opts.tools {
if a.opts.tools[i].def.Name == call.Name {
out, err := a.opts.tools[i].handler(ctx, call.Input)
if err != nil {
return errResult(call.ID, err.Error())
}
return ai.ToolResult{ID: call.ID, Value: out, Content: out}
}
}
if call.Name == toolHumanInput {
return a.handleHumanInput(call)
}
if call.Name == toolDelegate {
return a.handleDelegate(ctx, call)
}
return rpc(ctx, call)
}
}
// planWrap handles the plan tool inline. plan is internal bookkeeping,
// not an action — it is never counted, loop-checked, or gated.
func (a *agentImpl) planWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if call.Name == toolPlan {
return a.handlePlan(call)
}
return next(ctx, call)
}
}
// stepWrap bounds the number of actions per Ask (MaxSteps).
func (a *agentImpl) stepWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.MaxSteps > 0 {
a.steps++
if a.steps > a.opts.MaxSteps {
return refused(call.ID, ai.RefusedMaxSteps, fmt.Sprintf(
"step limit reached (%d). Do not call any more tools; stop and summarize what you have so far.",
a.opts.MaxSteps))
}
}
return next(ctx, call)
}
}
// loopWrap stops the agent repeating an identical action that makes no
// progress (which the step count alone won't catch).
func (a *agentImpl) loopWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.LoopLimit > 0 {
if a.calls == nil {
a.calls = map[string]int{}
}
args, _ := json.Marshal(call.Input)
fp := call.Name + ":" + string(args)
a.calls[fp]++
if a.calls[fp] > a.opts.LoopLimit {
return refused(call.ID, ai.RefusedLoop, fmt.Sprintf(
"loop detected: you have already called %q with the same arguments %d times and the result will not change. Stop repeating it — try a different approach, or finish with what you have.",
call.Name, a.opts.LoopLimit))
}
}
return next(ctx, call)
}
}
// approveWrap gates each action before it runs (ApproveTool).
type approvalPause struct {
Tool string
Message string
}
type inputPause struct {
OriginalMessage string `json:"original_message"`
Prompt string `json:"prompt"`
}
func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.Approve != nil {
if ok, reason := a.opts.Approve(call.Name, call.Input); !ok {
msg := "tool call was not approved"
if reason != "" {
msg += ": " + reason
}
a.pause = &approvalPause{Tool: call.Name, Message: msg}
return refused(call.ID, ai.RefusedApproval, msg)
}
}
return next(ctx, call)
}
}
// handlePlan persists the supplied plan to the agent's memory and
// echoes it back so the model can see the stored state.
func (a *agentImpl) handlePlan(call ai.ToolCall) ai.ToolResult {
data, err := json.Marshal(call.Input)
if err != nil {
return errResult(call.ID, "invalid plan: "+err.Error())
}
_ = a.stateStore().Write(&store.Record{Key: planKey, Value: data})
return ai.ToolResult{ID: call.ID, Value: call.Input, Content: string(data)}
}
// 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)
prompt = strings.TrimSpace(prompt)
if prompt == "" {
prompt = "human input required"
}
a.pause = &approvalPause{Tool: toolHumanInput, Message: prompt}
return refused(call.ID, ai.RefusedApproval, "input-required: "+prompt)
}
// handleDelegate hands a subtask to another agent. Delegate-first:
// if 'to' names a registered agent, it is called via RPC. Otherwise an
// ephemeral sub-agent is created with a fresh, isolated context, asked
// the subtask, and its reply returned.
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.ToolResult {
input := call.Input
task, _ := input["task"].(string)
if task == "" {
return errResult(call.ID, "task is required")
}
to, _ := input["to"].(string)
// An external agent on another framework, addressed by A2A URL.
if strings.HasPrefix(to, "http://") || strings.HasPrefix(to, "https://") {
reply, err := a2a.NewClient(to).Send(ctx, task)
if err != nil {
return errResult(call.ID, "delegate to A2A agent "+to+": "+err.Error())
}
out := map[string]any{"agent": to, "reply": reply}
b, _ := json.Marshal(out)
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
}
// Delegate-first: an existing agent that owns the domain handles it.
if to != "" && a.isAgent(to) {
reply, err := a.callAgentRPC(ctx, to, task)
if err != nil {
return errResult(call.ID, "delegate to agent "+to+": "+err.Error())
}
out := map[string]any{"agent": to, "reply": reply}
b, _ := json.Marshal(out)
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
}
// Otherwise create a focused, ephemeral sub-agent. Fresh context:
// it loads no history and persists none.
var svcs []string
if to != "" {
svcs = []string{to}
}
sub := newEphemeral(
Name(a.opts.Name+".sub"),
Services(svcs...),
Prompt("You are a sub-agent handling a single delegated subtask. "+
"Complete it using the available tools and report the result concisely."),
Provider(a.opts.Provider),
Model(a.opts.Model),
APIKey(a.opts.APIKey),
WithRegistry(a.opts.Registry),
WithClient(a.opts.Client),
WithStore(a.opts.Store),
TraceProvider(a.opts.TraceProvider),
)
// Record lineage so the sub-agent's tool calls carry this run as parent.
sub.parentRunID = a.runID
resp, err := sub.Ask(ctx, task)
if err != nil {
return errResult(call.ID, "sub-agent: "+err.Error())
}
out := map[string]any{"reply": resp.Reply}
b, _ := json.Marshal(out)
return ai.ToolResult{ID: call.ID, Value: out, Content: string(b)}
}
// isAgent reports whether name resolves to a registered agent (a
// service advertising type=agent in its metadata).
func (a *agentImpl) isAgent(name string) bool {
if a.opts.Registry == nil {
return false
}
recs, err := a.opts.Registry.GetService(name)
if err != nil || len(recs) == 0 {
return false
}
if recs[0].Metadata != nil && recs[0].Metadata["type"] == "agent" {
return true
}
for _, n := range recs[0].Nodes {
if n.Metadata != nil && n.Metadata["type"] == "agent" {
return true
}
}
return false
}
// callAgentRPC calls another agent's Agent.Chat endpoint and returns
// its reply.
func (a *agentImpl) callAgentRPC(ctx context.Context, name, msg string) (string, error) {
body, _ := json.Marshal(map[string]string{"message": msg})
req := a.opts.Client.NewRequest(name, "Agent.Chat", &codecBytes.Frame{Data: body})
var rsp codecBytes.Frame
if err := a.opts.Client.Call(ctx, req, &rsp); err != nil {
return "", err
}
var out struct {
Reply string `json:"reply"`
}
if err := json.Unmarshal(rsp.Data, &out); err != nil {
return "", err
}
return out.Reply, nil
}
// planKey is the record key for an agent's plan within its scoped store.
const planKey = "plan"
// loadPlan returns the stored plan as a JSON string, or "" if none.
func (a *agentImpl) loadPlan() string {
recs, err := a.stateStore().Read(planKey)
if err != nil || len(recs) == 0 {
return ""
}
return string(recs[0].Value)
}
func errResult(id, msg string) ai.ToolResult {
m := map[string]string{"error": msg}
b, _ := json.Marshal(m)
return ai.ToolResult{ID: id, Value: m, Content: string(b)}
}
// refused is an error result a guardrail returns, tagged with a structured
// reason (ai.Refused*) so a tool wrapper can react to it without parsing
// the message.
func refused(id, reason, msg string) ai.ToolResult {
r := errResult(id, msg)
r.Refused = reason
return r
}
+167
View File
@@ -0,0 +1,167 @@
package agent
import (
"encoding/json"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
func TestBuiltinTools(t *testing.T) {
tools := builtinTools()
if len(tools) != 3 {
t.Fatalf("builtinTools() = %d tools, want 3", len(tools))
}
names := map[string]bool{}
for _, tl := range tools {
names[tl.Name] = true
}
if !names[toolPlan] || !names[toolDelegate] || !names[toolHumanInput] {
t.Errorf("builtin tools = %v, want plan, request_input, and delegate", names)
}
}
func TestHandlePlanPersists(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), WithStore(mem)).(*agentImpl)
steps := map[string]any{
"steps": []any{
map[string]any{"task": "gather requirements", "status": "done"},
map[string]any{"task": "write code", "status": "in_progress"},
},
}
content := a.handlePlan(ai.ToolCall{Name: "plan", Input: steps}).Content
if content == "" {
t.Fatal("handlePlan returned empty content")
}
// The plan must be retrievable from memory.
got := a.loadPlan()
if got == "" {
t.Fatal("loadPlan() returned empty after handlePlan")
}
var decoded map[string]any
if err := json.Unmarshal([]byte(got), &decoded); err != nil {
t.Fatalf("stored plan is not valid JSON: %v", err)
}
if _, ok := decoded["steps"]; !ok {
t.Errorf("stored plan missing steps: %s", got)
}
}
func TestPlanShowsInPrompt(t *testing.T) {
mem := store.NewMemoryStore()
a := New(Name("planner"), Prompt("base prompt"), WithStore(mem)).(*agentImpl)
if got := a.buildPrompt(); got != "base prompt" {
t.Errorf("buildPrompt() with no plan = %q, want %q", got, "base prompt")
}
a.handlePlan(ai.ToolCall{Name: "plan", Input: map[string]any{"steps": []any{map[string]any{"task": "do it", "status": "pending"}}}})
got := a.buildPrompt()
if got == "base prompt" {
t.Error("buildPrompt() should include the plan once one is saved")
}
if !containsStr(got, "do it") {
t.Errorf("buildPrompt() = %q, should contain the saved plan", got)
}
}
func TestDiscoverToolsIncludesBuiltins(t *testing.T) {
reg := registry.NewMemoryRegistry()
a := New(Name("a"), WithRegistry(reg), WithStore(store.NewMemoryStore())).(*agentImpl)
a.setup()
tools, err := a.discoverTools()
if err != nil {
t.Fatalf("discoverTools: %v", err)
}
// No services registered, so the only tools should be the builtins.
if len(tools) != len(builtinTools()) {
t.Fatalf("discoverTools() = %d tools, want %d builtins", len(tools), len(builtinTools()))
}
}
func TestEphemeralAgentHasNoBuiltins(t *testing.T) {
reg := registry.NewMemoryRegistry()
a := New(Name("a.sub"), WithRegistry(reg), WithStore(store.NewMemoryStore())).(*agentImpl)
a.ephemeral = true
a.setup()
tools, err := a.discoverTools()
if err != nil {
t.Fatalf("discoverTools: %v", err)
}
if len(tools) != 0 {
t.Errorf("ephemeral agent discoverTools() = %d tools, want 0", len(tools))
}
}
func TestBuiltinsAccessor(t *testing.T) {
mem := store.NewMemoryStore()
tools, handle := Builtins(
Name("chat"),
WithStore(mem),
WithRegistry(registry.NewMemoryRegistry()),
)
if len(tools) != 3 {
t.Fatalf("Builtins() returned %d tools, want 3", len(tools))
}
// A name that isn't a built-in falls through (ok == false).
if _, _, ok := handle("not_a_builtin", nil); ok {
t.Error("handle(non-builtin) ok = true, want false")
}
// plan is handled and persisted under the configured name.
_, content, ok := handle(toolPlan, map[string]any{
"steps": []any{map[string]any{"task": "x", "status": "pending"}},
})
if !ok {
t.Fatal("handle(plan) ok = false, want true")
}
if content == "" {
t.Fatal("handle(plan) returned empty content")
}
scoped := store.Scope(mem, "agent", "chat")
if recs, err := scoped.Read(planKey); err != nil || len(recs) == 0 {
t.Errorf("plan not persisted in the agent's scoped store: err=%v recs=%d", err, len(recs))
}
}
func TestIsAgent(t *testing.T) {
reg := registry.NewMemoryRegistry()
// A plain service.
if err := reg.Register(&registry.Service{
Name: "task",
Nodes: []*registry.Node{{Id: "task-1", Address: "127.0.0.1:0"}},
}); err != nil {
t.Fatalf("register service: %v", err)
}
// An agent (advertises type=agent).
if err := reg.Register(&registry.Service{
Name: "task-mgr",
Metadata: map[string]string{"type": "agent"},
Nodes: []*registry.Node{{Id: "task-mgr-1", Address: "127.0.0.1:0"}},
}); err != nil {
t.Fatalf("register agent: %v", err)
}
a := New(Name("root"), WithRegistry(reg)).(*agentImpl)
if a.isAgent("task") {
t.Error("isAgent(task) = true, want false (plain service)")
}
if !a.isAgent("task-mgr") {
t.Error("isAgent(task-mgr) = false, want true (agent)")
}
if a.isAgent("nonexistent") {
t.Error("isAgent(nonexistent) = true, want false")
}
}
+218
View File
@@ -0,0 +1,218 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
)
const (
agentAskStep = "ask"
agentApprovalStep = "approval"
agentInputStep = "input-required"
)
func (a *agentImpl) newCheckpointRun(runID, message, parentRunID string, existing *flow.Run) flow.Run {
now := time.Now()
run := flow.Run{
ID: runID,
ParentID: parentRunID,
Flow: a.opts.Name,
State: flow.State{Stage: agentAskStep, Data: []byte(message)},
Steps: []flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}},
Status: "running",
Started: now,
Updated: now,
}
if existing != nil {
run = *existing
run.Status = "running"
run.State.Stage = agentAskStep
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "in_progress"
run.Steps[0].Error = ""
run.Steps[0].Result = ""
}
return run
}
func (a *agentImpl) saveRun(ctx context.Context, run flow.Run) error {
if a.opts.Checkpoint == nil {
return nil
}
if err := a.opts.Checkpoint.Save(ctx, run); err != nil {
return fmt.Errorf("agent %s checkpoint save: %w", a.opts.Name, err)
}
return nil
}
// Resume returns the response for a checkpointed agent run. Completed runs are
// returned from the checkpoint without calling the model or replaying tool
// calls; failed or in-progress runs continue from the saved input message.
func Resume(ctx context.Context, ag Agent, runID string) (*Response, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, fmt.Errorf("agent resume: unsupported agent implementation %T", ag)
}
return a.resume(ctx, runID)
}
func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error) {
if a.opts.Checkpoint == nil {
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
}
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("agent run %s not found", runID)
}
if run.Status == "paused" {
if run.State.Stage == agentInputStep {
return nil, fmt.Errorf("agent run %s is input-required; resume with ResumeInput", runID)
}
run.Status = "running"
run.State.Stage = agentAskStep
}
if run.Status == "done" {
var resp Response
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
return nil, fmt.Errorf("agent run %s response decode: %w", runID, err)
}
return &resp, nil
}
message := string(run.State.Data)
parentID := run.ParentID
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, run.ID, message, parentID, &run)
}
// ResumeInput resumes a checkpointed agent run that paused via the built-in
// request_input tool. The supplied input is appended to the original request so
// the same run can continue with durable checkpoint and completed tool history.
func ResumeInput(ctx context.Context, ag Agent, runID, input string) (*Response, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, fmt.Errorf("agent resume input: unsupported agent implementation %T", ag)
}
return a.resumeInput(ctx, runID, input)
}
func (a *agentImpl) resumeInput(ctx context.Context, runID, input string) (*Response, error) {
if a.opts.Checkpoint == nil {
return nil, fmt.Errorf("agent %s has no checkpoint configured", a.opts.Name)
}
run, ok, err := a.opts.Checkpoint.Load(ctx, runID)
if err != nil {
return nil, err
}
if !ok {
return nil, fmt.Errorf("agent run %s not found", runID)
}
if run.Status != "paused" || run.State.Stage != agentInputStep {
return nil, fmt.Errorf("agent run %s is not waiting for human input", runID)
}
var p inputPause
if err := run.State.Scan(&p); err != nil {
return nil, fmt.Errorf("agent run %s input state decode: %w", runID, err)
}
message := p.OriginalMessage
if message == "" {
message = string(run.State.Data)
}
message += "\n\nHuman input: " + input
run.Status = "running"
run.State.Stage = agentAskStep
run.State.Data = []byte(message)
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, run.ID, message, run.ParentID, &run)
}
func (a *agentImpl) pending(ctx context.Context) ([]flow.Run, error) {
if a.opts.Checkpoint == nil {
return nil, nil
}
runs, err := a.opts.Checkpoint.List(ctx)
if err != nil {
return nil, err
}
out := runs[:0]
for _, run := range runs {
if run.Flow == a.opts.Name && run.Status != "done" {
out = append(out, run)
}
}
return out, nil
}
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 {
return next(ctx, call)
}
name := toolCheckpointName(call)
if rec, ok := findStep(a.currentRun.Steps, name); ok && rec.Status == "done" {
return ai.ToolResult{ID: call.ID, Value: rec.Result, Content: rec.Result}
}
idx := upsertStep(&a.currentRun.Steps, flow.StepRecord{Name: name, Status: "in_progress"})
_ = a.saveRun(ctx, *a.currentRun)
res := next(ctx, call)
a.currentRun.Steps[idx].Attempts++
if res.Refused != "" {
a.currentRun.Steps[idx].Status = "failed"
a.currentRun.Steps[idx].Error = res.Content
_ = a.saveRun(ctx, *a.currentRun)
return res
}
a.currentRun.Steps[idx].Status = "done"
a.currentRun.Steps[idx].Result = res.Content
a.currentRun.Steps[idx].Error = ""
_ = a.saveRun(ctx, *a.currentRun)
return res
}
}
func toolCheckpointName(call ai.ToolCall) string {
b, _ := json.Marshal(call.Input)
return "tool:" + call.Name + ":" + string(b)
}
func findStep(steps []flow.StepRecord, name string) (flow.StepRecord, bool) {
for _, step := range steps {
if step.Name == name {
return step, true
}
}
return flow.StepRecord{}, false
}
func upsertStep(steps *[]flow.StepRecord, rec flow.StepRecord) int {
for i := range *steps {
if (*steps)[i].Name == rec.Name {
(*steps)[i].Status = rec.Status
(*steps)[i].Error = rec.Error
return i
}
}
if len(*steps) == 0 || (*steps)[0].Name != agentAskStep {
*steps = append([]flow.StepRecord{{Name: agentAskStep, Status: "in_progress"}}, (*steps)...)
}
*steps = append(*steps, rec)
return len(*steps) - 1
}
+246
View File
@@ -0,0 +1,246 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/store"
)
func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "durable-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
return &ai.Response{Reply: "done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("durable-agent"), WithCheckpoint(cp))
resp, err := a.Ask(ctx, "finish the work")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if calls != 1 {
t.Fatalf("model calls after Ask = %d, want 1", calls)
}
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
t.Fatal("Resume of a completed run replayed the model")
return nil, nil
}
resumed, err := Resume(ctx, a, resp.RunID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resumed.Reply != "done" {
t.Fatalf("resumed reply = %q, want done", resumed.Reply)
}
if resumed.RunID != resp.RunID {
t.Fatalf("resumed run id = %q, want %q", resumed.RunID, resp.RunID)
}
if calls != 1 {
t.Fatalf("model calls after Resume = %d, want 1", calls)
}
}
func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "tool-resume-agent")
toolRuns := 0
first := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.charge", Input: map[string]any{"order": "42"}})
if res.Content != "charged" {
t.Fatalf("tool result = %q, want charged", res.Content)
}
}
if first {
first = false
return nil, errors.New("model connection dropped after tool")
}
return &ai.Response{Reply: "finished from checkpoint"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("tool-resume-agent"), WithCheckpoint(cp),
WithTool("external.charge", "charge once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "charged", nil
}))
_, err := a.Ask(ctx, "charge order 42")
if err == nil {
t.Fatal("Ask succeeded, want simulated failure")
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1", len(runs))
}
resp, err := Resume(ctx, a, runs[0].ID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resp.Reply != "finished from checkpoint" {
t.Fatalf("Resume reply = %q", resp.Reply)
}
if toolRuns != 1 {
t.Fatalf("tool executions after Resume = %d, want completed tool was not replayed", toolRuns)
}
}
func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "pending-agent")
run := flow.Run{ID: "run-1", Flow: "pending-agent", Status: "failed", State: flow.State{Stage: agentAskStep, Data: []byte("retry me")}}
if err := cp.Save(ctx, run); err != nil {
t.Fatalf("Save: %v", err)
}
a := newTestAgent(Name("pending-agent"), WithCheckpoint(cp))
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 || runs[0].ID != "run-1" {
t.Fatalf("Pending = %#v, want run-1", runs)
}
}
func TestHumanInputPauseResumesSameRunWithInput(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "input-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if calls == 1 {
if opts.ToolHandler != nil {
opts.ToolHandler(ctx, ai.ToolCall{ID: "input-1", Name: toolHumanInput, Input: map[string]any{"prompt": "Which region should I deploy to?"}})
}
return &ai.Response{Reply: "waiting"}, nil
}
if !strings.Contains(req.Prompt, "Human input: us-east-1") {
t.Fatalf("resumed prompt = %q, want human input", req.Prompt)
}
return &ai.Response{Reply: "deploying to us-east-1"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("input-agent"), WithCheckpoint(cp))
_, err := a.Ask(ctx, "deploy the service")
if err == nil {
t.Fatal("Ask succeeded, want input-required pause")
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 || runs[0].Status != "paused" || runs[0].State.Stage != agentInputStep {
t.Fatalf("paused runs = %#v, want one input-required run", runs)
}
var pause inputPause
if err := runs[0].State.Scan(&pause); err != nil {
t.Fatalf("Scan pause: %v", err)
}
if pause.OriginalMessage != "deploy the service" || pause.Prompt != "Which region should I deploy to?" {
t.Fatalf("pause = %#v", pause)
}
if _, err := Resume(ctx, a, runs[0].ID); err == nil || !strings.Contains(err.Error(), "ResumeInput") {
t.Fatalf("Resume input-required err = %v, want guidance", err)
}
resp, err := ResumeInput(ctx, a, runs[0].ID, "us-east-1")
if err != nil {
t.Fatalf("ResumeInput: %v", err)
}
if resp.RunID != runs[0].ID || resp.Reply != "deploying to us-east-1" {
t.Fatalf("response = %#v", resp)
}
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" {
t.Fatalf("resumed run status = %q, want done", loaded.Status)
}
}
func TestApprovalDenialPausesCheckpointedRunAndResumeContinues(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "approval-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if opts.ToolHandler != nil {
opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.approve", Input: map[string]any{"id": "42"}})
}
return &ai.Response{Reply: "model saw approval result"}, nil
}
defer func() { fakeGen = nil }()
approved := false
a := newTestAgent(Name("approval-agent"), WithCheckpoint(cp),
WithTool("external.approve", "guarded external action", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
return approved, "waiting for operator"
}))
_, err := a.Ask(ctx, "send the guarded update")
if err == nil {
t.Fatal("Ask succeeded, want paused approval error")
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1: %#v", len(runs), runs)
}
if runs[0].Status != "paused" || runs[0].State.Stage != agentApprovalStep {
t.Fatalf("run status/stage = %s/%s, want paused/%s", runs[0].Status, runs[0].State.Stage, agentApprovalStep)
}
if got := string(runs[0].State.Data); got != "send the guarded update" {
t.Fatalf("paused run data = %q", got)
}
approved = true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-2", Name: "external.approve", Input: map[string]any{"id": "42"}})
if res.Refused != "" {
t.Fatalf("resumed call was refused: %#v", res)
}
}
return &ai.Response{Reply: "done after approval"}, nil
}
resp, err := Resume(ctx, a, runs[0].ID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resp.Reply != "done after approval" {
t.Fatalf("Resume reply = %q", resp.Reply)
}
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" {
t.Fatalf("resumed run status = %q, want done", loaded.Status)
}
if calls != 2 {
t.Fatalf("model calls = %d, want 2", calls)
}
}
+158
View File
@@ -0,0 +1,158 @@
package agent
import (
"context"
"errors"
"fmt"
"os"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
type conformanceProvider struct {
name string
model string
key string
live bool
}
func TestAgentProviderConformanceMatrix(t *testing.T) {
providers := []conformanceProvider{
{name: "fake"},
{name: "openai", key: "OPENAI_API_KEY", model: "GO_MICRO_CONFORMANCE_OPENAI_MODEL", live: true},
{name: "anthropic", key: "ANTHROPIC_API_KEY", model: "GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL", live: true},
{name: "atlascloud", key: "ATLASCLOUD_API_KEY", model: "GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL", live: true},
{name: "gemini", key: "GEMINI_API_KEY", model: "GO_MICRO_CONFORMANCE_GEMINI_MODEL", live: true},
{name: "groq", key: "GROQ_API_KEY", model: "GO_MICRO_CONFORMANCE_GROQ_MODEL", live: true},
{name: "mistral", key: "MISTRAL_API_KEY", model: "GO_MICRO_CONFORMANCE_MISTRAL_MODEL", live: true},
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
}
for _, provider := range providers {
provider := provider
t.Run(provider.name, func(t *testing.T) {
runAgentConformanceScenario(t, provider)
})
}
}
func runAgentConformanceScenario(t *testing.T, provider conformanceProvider) {
t.Helper()
if provider.live {
if os.Getenv(provider.key) == "" {
t.Skipf("%s not set; skipping live %s conformance", provider.key, provider.name)
}
if os.Getenv("GO_MICRO_AGENT_CONFORMANCE_LIVE") == "" {
t.Skipf("GO_MICRO_AGENT_CONFORMANCE_LIVE not set; skipping live %s conformance", provider.name)
}
} else {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if req.Prompt == "" {
return nil, errors.New("missing prompt")
}
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" {
return nil, fmt.Errorf("missing user history: %+v", req.Messages)
}
if len(req.Tools) == 0 {
return nil, errors.New("missing tools")
}
if opts.ToolHandler == nil {
return nil, errors.New("missing tool handler")
}
res := opts.ToolHandler(ctx, ai.ToolCall{
ID: "fake-call-1",
Name: "conformance_echo",
Input: map[string]any{"value": "agent-conformance"},
})
if res.Content == "" {
return nil, errors.New("empty tool result")
}
return &ai.Response{
Reply: "used conformance_echo",
Answer: res.Content,
ToolCalls: []ai.ToolCall{{ID: "fake-call-1", Name: "conformance_echo", Input: map[string]any{"value": "agent-conformance"}, Result: res.Content}},
}, nil
}
defer func() { fakeGen = nil }()
}
var sawTool bool
var sawRunInfo bool
agentOpts := []Option{
Name("conformance-" + provider.name),
Provider(provider.name),
APIKey(os.Getenv(provider.key)),
Prompt("You are a conformance test agent. Use the conformance_echo tool exactly once with input {\"value\":\"agent-conformance\"}, then answer with the tool result."),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(8)),
ModelCallTimeout(45 * time.Second),
WithTool("conformance_echo", "Echo a conformance value and return a deterministic marker.", map[string]any{
"value": map[string]any{"type": "string", "description": "value to echo"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
info, ok := ai.RunInfoFrom(ctx)
if !ok {
return "", errors.New("missing run info")
}
if info.RunID == "" || info.Agent != "conformance-"+provider.name {
return "", fmt.Errorf("unexpected run info: %+v", info)
}
sawRunInfo = true
if input["value"] != "agent-conformance" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"agent-conformance-ok"}`, nil
}),
}
if provider.model != "" {
if model := os.Getenv(provider.model); model != "" {
agentOpts = append(agentOpts, Model(model))
}
}
a := New(agentOpts...)
resp, err := a.Ask(context.Background(), "Run the provider conformance check.")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if resp.RunID == "" {
t.Fatal("RunID is empty")
}
if resp.Agent != "conformance-"+provider.name {
t.Fatalf("Agent = %q", resp.Agent)
}
if !sawTool {
t.Fatal("provider did not request the conformance tool")
}
if !sawRunInfo {
t.Fatal("tool did not receive RunInfo")
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") && !strings.Contains(resp.Reply, "agent-conformance") {
t.Fatalf("reply %q does not include conformance marker", resp.Reply)
}
}
func TestAgentProviderConformanceFakeError(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return nil, errors.New("conformance provider failure")
}
defer func() { fakeGen = nil }()
a := New(
Name("conformance-error"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
)
_, err := a.Ask(context.Background(), "fail deterministically")
if err == nil || !strings.Contains(err.Error(), "conformance provider failure") {
t.Fatalf("Ask error = %v, want conformance provider failure", err)
}
}
+92
View File
@@ -0,0 +1,92 @@
package agent
import (
"context"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
// toolContent runs a tool call through a handler and returns the content
// shown to the model — the part these tests assert on.
func toolContent(h ai.ToolHandler, name string, input map[string]any) string {
return h(context.Background(), ai.ToolCall{Name: name, Input: input}).Content
}
// MaxSteps refuses tool calls once the per-Ask limit is exceeded; plan
// is bookkeeping and is never counted.
func TestMaxStepsStopsActions(t *testing.T) {
a := newTestAgent(Name("limited"), MaxSteps(2))
h := a.toolHandler()
// plan must not consume a step.
a.steps = 0
toolContent(h, toolPlan, map[string]any{"steps": []any{}})
if a.steps != 0 {
t.Fatalf("plan consumed a step: steps=%d", a.steps)
}
// First two actions are allowed (they fall through to RPC, which
// fails harmlessly — we only care they weren't refused by the limit).
for i := 1; i <= 2; i++ {
content := toolContent(h, "demo_Svc_Do", map[string]any{})
if strings.Contains(content, "step limit") {
t.Fatalf("action %d wrongly hit the step limit", i)
}
}
// Third action exceeds MaxSteps(2) and must be refused.
content := toolContent(h, "demo_Svc_Do", map[string]any{})
if !strings.Contains(content, "step limit") {
t.Errorf("third action should hit the step limit; got %q", content)
}
}
// ApproveTool blocks an action when the hook denies it, and the denial
// reason is surfaced to the model.
func TestApproveToolBlocks(t *testing.T) {
var sawTool string
a := newTestAgent(Name("gated"),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
sawTool = tool
return false, "needs sign-off"
}),
)
content := toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
if sawTool != "demo_Svc_Do" {
t.Errorf("approver saw %q, want demo_Svc_Do", sawTool)
}
if !strings.Contains(content, "not approved") || !strings.Contains(content, "needs sign-off") {
t.Errorf("blocked call should surface the reason; got %q", content)
}
}
// A denying approver must not gate the internal plan tool.
func TestApproveToolDoesNotGatePlan(t *testing.T) {
mem := store.NewMemoryStore()
a := New(
Name("gated"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(mem),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
return false, "deny everything"
}),
).(*agentImpl)
a.setup()
content := toolContent(a.toolHandler(), toolPlan, map[string]any{
"steps": []any{map[string]any{"task": "x", "status": "pending"}},
})
if strings.Contains(content, "not approved") {
t.Errorf("plan must not be gated by ApproveTool; got %q", content)
}
if recs, _ := store.Scope(mem, "agent", "gated").Read(planKey); len(recs) == 0 {
t.Error("plan should have been persisted despite the denying approver")
}
}
+224
View File
@@ -0,0 +1,224 @@
package agent
import (
"context"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/client"
codecBytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
// fakeGen drives the fake provider's Generate. Tests set it and reset
// it with a deferred cleanup. Tests in this package are not parallel,
// so a package-level hook is safe.
var fakeGen func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error)
type fakeModel struct{ opts ai.Options }
func (m *fakeModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *fakeModel) Options() ai.Options { return m.opts }
func (m *fakeModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
if fakeGen != nil {
return fakeGen(ctx, m.opts, req)
}
return &ai.Response{Reply: "ok"}, nil
}
func (m *fakeModel) Stream(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (ai.Stream, error) {
return nil, nil
}
func (m *fakeModel) String() string { return "fake" }
func init() {
ai.Register("fake", func(opts ...ai.Option) ai.Model {
m := &fakeModel{}
_ = m.Init(opts...)
return m
})
}
// fakeClient embeds the default client (so NewRequest works) and
// overrides Call with a test-supplied function.
type fakeClient struct {
client.Client
callFn func(ctx context.Context, req client.Request, rsp interface{}) error
}
func (c *fakeClient) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
return c.callFn(ctx, req, rsp)
}
func newTestAgent(opts ...Option) *agentImpl {
base := []Option{
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
}
a := New(append(base, opts...)...).(*agentImpl)
a.setup()
return a
}
// The model is offered the plan and delegate tools, and calling the
// plan tool persists the plan to memory.
func TestAskExposesAndRunsPlan(t *testing.T) {
var sawPlan, sawDelegate bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
for _, tl := range req.Tools {
switch tl.Name {
case toolPlan:
sawPlan = true
case toolDelegate:
sawDelegate = true
}
}
// Simulate the model recording a plan.
if opts.ToolHandler != nil {
opts.ToolHandler(context.Background(), ai.ToolCall{
Name: toolPlan,
Input: map[string]any{
"steps": []any{map[string]any{"task": "step one", "status": "pending"}},
},
})
}
return &ai.Response{Answer: "done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("worker"))
resp, err := a.Ask(context.Background(), "do some multi-step work")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if !sawPlan || !sawDelegate {
t.Errorf("model should be offered plan and delegate tools: plan=%v delegate=%v", sawPlan, sawDelegate)
}
if resp.Reply == "" {
t.Error("Ask returned empty reply")
}
if plan := a.loadPlan(); !strings.Contains(plan, "step one") {
t.Errorf("plan tool result not persisted; loadPlan() = %q", plan)
}
}
// Delegating with no matching agent creates an ephemeral sub-agent with
// a fresh, isolated context (no builtin tools) and returns its reply.
func TestDelegateEphemeral(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if strings.Contains(req.SystemPrompt, "sub-agent") {
for _, tl := range req.Tools {
if tl.Name == toolPlan || tl.Name == toolDelegate {
t.Errorf("ephemeral sub-agent must not have builtin tool %q", tl.Name)
}
}
return &ai.Response{Reply: "subtask complete"}, nil
}
return &ai.Response{Reply: "parent"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("root"))
content := a.handleDelegate(context.Background(), ai.ToolCall{Name: "delegate", Input: map[string]any{"task": "summarize the report"}}).Content
if !strings.Contains(content, "subtask complete") {
t.Errorf("delegate should return the sub-agent's reply; got %q", content)
}
}
// Delegating to a name that resolves to a registered agent goes over
// RPC to that agent rather than spawning a sub-agent.
func TestDelegateToRegisteredAgent(t *testing.T) {
reg := registry.NewMemoryRegistry()
if err := reg.Register(&registry.Service{
Name: "comms",
Metadata: map[string]string{"type": "agent"},
Nodes: []*registry.Node{{Id: "comms-1", Address: "127.0.0.1:0"}},
}); err != nil {
t.Fatalf("register agent: %v", err)
}
var calledService, calledEndpoint string
fc := &fakeClient{Client: client.DefaultClient}
fc.callFn = func(ctx context.Context, req client.Request, rsp interface{}) error {
calledService, calledEndpoint = req.Service(), req.Endpoint()
frame := rsp.(*codecBytes.Frame)
frame.Data = []byte(`{"reply":"notified alice","agent":"comms"}`)
return nil
}
// fakeGen guards against the ephemeral path being taken by mistake.
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
t.Error("delegate to a registered agent must not spawn a sub-agent")
return &ai.Response{}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("root"), WithRegistry(reg), WithClient(fc))
content := a.handleDelegate(context.Background(), ai.ToolCall{Name: "delegate", Input: map[string]any{"task": "notify alice", "to": "comms"}}).Content
if calledService != "comms" || calledEndpoint != "Agent.Chat" {
t.Errorf("expected RPC to comms Agent.Chat, got %s %s", calledService, calledEndpoint)
}
if !strings.Contains(content, "notified alice") {
t.Errorf("delegate-first result missing agent reply; got %q", content)
}
}
// Delegate requires a task.
func TestDelegateRequiresTask(t *testing.T) {
a := newTestAgent(Name("root"))
content := a.handleDelegate(context.Background(), ai.ToolCall{Name: "delegate", Input: map[string]any{}}).Content
if !strings.Contains(content, "error") {
t.Errorf("delegate with no task should error; got %q", content)
}
}
func TestCompactingMemorySummarizesAndRecallsArchivedContext(t *testing.T) {
var sawSummary, sawRecall bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
for _, msg := range req.Messages {
text := msg.Content.(string)
if strings.Contains(text, "Conversation memory summary") && strings.Contains(text, "alpha project") {
sawSummary = true
}
if strings.Contains(text, "alpha project budget is 42") {
sawRecall = true
}
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("memory"), CompactMemory(4, 2), MemoryRecallLimit(3))
turns := []string{
"alpha project budget is 42",
"beta project owner is sam",
"gamma project deadline is monday",
"delta project status is green",
"epsilon project risk is low",
}
for _, turn := range turns {
if _, err := a.Ask(context.Background(), turn); err != nil {
t.Fatalf("Ask(%q): %v", turn, err)
}
}
if got := len(a.mem.Messages()); got > 4 {
t.Fatalf("compacted memory retained %d messages, want <= 4", got)
}
if _, err := a.Ask(context.Background(), "what was the alpha budget?"); err != nil {
t.Fatalf("Ask recall: %v", err)
}
if !sawSummary {
t.Error("model request did not include a deterministic compacted summary")
}
if !sawRecall {
t.Error("model request did not recall archived matching context")
}
}
+71
View File
@@ -0,0 +1,71 @@
package agent
import (
"strings"
"testing"
)
// Repeating the same tool call with the same arguments is refused once it
// exceeds LoopLimit, and the model is told to change approach.
func TestLoopDetectionStopsRepeats(t *testing.T) {
a := newTestAgent(Name("looper"), LoopLimit(3))
h := a.toolHandler()
// First 3 identical calls are allowed (they fall through to RPC,
// which fails harmlessly — we only care they weren't refused as loops).
for i := 1; i <= 3; i++ {
content := toolContent(h, "demo_Svc_Do", map[string]any{"q": "x"})
if strings.Contains(content, "loop detected") {
t.Fatalf("call %d wrongly flagged as a loop", i)
}
}
// The 4th identical call is refused as a loop.
content := toolContent(h, "demo_Svc_Do", map[string]any{"q": "x"})
if !strings.Contains(content, "loop detected") {
t.Errorf("4th identical call should be refused as a loop; got %q", content)
}
}
// Different arguments are not a loop, even past the limit.
func TestLoopDetectionAllowsDistinctCalls(t *testing.T) {
a := newTestAgent(Name("distinct"), LoopLimit(2))
h := a.toolHandler()
for i := 0; i < 5; i++ {
content := toolContent(h, "demo_Svc_Do", map[string]any{"q": i}) // distinct args each time
if strings.Contains(content, "loop detected") {
t.Fatalf("distinct call %d wrongly flagged as a loop", i)
}
}
}
// LoopLimit(0) disables detection.
func TestLoopDetectionDisabled(t *testing.T) {
a := newTestAgent(Name("noloop"), LoopLimit(0))
h := a.toolHandler()
for i := 0; i < 6; i++ {
content := toolContent(h, "demo_Svc_Do", map[string]any{"q": "same"})
if strings.Contains(content, "loop detected") {
t.Fatalf("loop detection should be disabled with LoopLimit(0)")
}
}
}
// It defaults on (lenient) so repeated identical calls are caught without
// any configuration.
func TestLoopDetectionDefaultOn(t *testing.T) {
a := New(Name("d"), Provider("fake")).(*agentImpl)
a.setup()
if a.opts.LoopLimit <= 0 {
t.Fatalf("LoopLimit should default on, got %d", a.opts.LoopLimit)
}
h := a.toolHandler()
var lastContent string
for i := 0; i < a.opts.LoopLimit+1; i++ {
lastContent = toolContent(h, "demo_Svc_Do", map[string]any{})
}
if !strings.Contains(lastContent, "loop detected") {
t.Errorf("default loop detection should catch repeated calls; got %q", lastContent)
}
}
+274
View File
@@ -0,0 +1,274 @@
package agent
import (
"encoding/json"
"fmt"
"sort"
"strings"
"sync"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
)
// Memory is an agent's conversation memory. Like the rest of the
// framework it is pluggable: the default is store-backed and durable
// across restarts, but any implementation can be supplied with
// WithMemory — in-process, a database, or a semantic/vector store.
type Memory interface {
// Add appends a message to the conversation.
Add(role, content string)
// Messages returns the retained conversation, oldest first.
Messages() []ai.Message
// Clear resets the conversation.
Clear()
}
// MemoryCompaction configures deterministic, store-backed context compaction
// for the default memory implementation. When the retained conversation grows
// past MaxMessages, older turns are collapsed into a summary message while the
// newest KeepRecent turns stay verbatim for provider-neutral continuity.
type MemoryCompaction struct {
MaxMessages int
KeepRecent int
}
// MemoryRecall is implemented by memory backends that can retrieve durable
// prior context relevant to a new turn without replaying every stored message.
type MemoryRecall interface {
Recall(query string, limit int) []ai.Message
}
// NewMemory returns the default store-backed memory: an in-process
// conversation buffer (truncated to limit) that persists to the store
// under key, so an agent picks up where it left off after a restart.
// A nil store or empty key yields non-persistent memory.
func NewMemory(s store.Store, key string, limit int) Memory {
m := &storeMemory{store: s, key: key, hist: ai.NewHistory(limit)}
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,
// and lets callers recall relevant prior turns with Recall.
func NewCompactingMemory(s store.Store, key string, maxMessages, keepRecent int) Memory {
if keepRecent <= 0 {
keepRecent = maxMessages / 2
}
if keepRecent < 1 {
keepRecent = 1
}
m := &storeMemory{
store: s,
key: key,
// Use an unlimited buffer here; compaction, not truncation, decides
// what remains in active context so a summary can preserve older turns.
hist: ai.NewHistory(0),
compaction: MemoryCompaction{
MaxMessages: maxMessages,
KeepRecent: keepRecent,
},
}
m.load()
m.compact()
return m
}
// NewInMemory returns conversation memory that is not persisted.
func NewInMemory(limit int) Memory {
return &storeMemory{hist: ai.NewHistory(limit)}
}
// 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
}
func (m *storeMemory) Add(role, content string) {
m.mu.Lock()
m.hist.Add(role, content)
m.mu.Unlock()
m.compact()
m.save()
}
func (m *storeMemory) Messages() []ai.Message {
m.mu.Lock()
defer m.mu.Unlock()
return m.hist.Messages()
}
func (m *storeMemory) Clear() {
m.mu.Lock()
m.hist.Reset()
m.archive = nil
m.mu.Unlock()
m.save()
}
// 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.
func (m *storeMemory) Recall(query string, limit int) []ai.Message {
m.mu.Lock()
defer m.mu.Unlock()
if limit <= 0 {
limit = 5
}
terms := recallTerms(query)
type match struct {
msg ai.Message
score int
index int
}
matches := make([]match, 0, len(m.archive))
for i := len(m.archive) - 1; i >= 0; i-- {
msg := m.archive[i]
if score := recallScore(msg, terms); score > 0 {
matches = append(matches, match{msg: msg, score: score, index: i})
}
}
sort.SliceStable(matches, func(i, j int) bool {
if matches[i].score != matches[j].score {
return matches[i].score > matches[j].score
}
return matches[i].index > matches[j].index
})
if len(matches) > limit {
matches = matches[:limit]
}
out := make([]ai.Message, 0, len(matches))
for _, match := range matches {
out = append(out, match.msg)
}
return out
}
func (m *storeMemory) load() {
if m.store == nil || m.key == "" {
return
}
recs, err := m.store.Read(m.key)
if err != nil || len(recs) == 0 {
return
}
var state memoryState
if err := json.Unmarshal(recs[0].Value, &state); err != nil {
var msgs []ai.Message
if err := json.Unmarshal(recs[0].Value, &msgs); err != nil {
return
}
state.Messages = msgs
}
m.mu.Lock()
m.archive = state.Archive
for _, msg := range state.Messages {
m.hist.Add(msg.Role, msg.Content)
}
m.mu.Unlock()
}
func (m *storeMemory) save() {
if m.store == nil || m.key == "" {
return
}
m.mu.Lock()
data, err := json.Marshal(memoryState{
Messages: m.hist.Messages(),
Archive: m.archive,
})
m.mu.Unlock()
if err != nil {
return
}
_ = m.store.Write(&store.Record{Key: m.key, Value: data})
}
func (m *storeMemory) compact() {
if m.compaction.MaxMessages <= 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
msgs := m.hist.Messages()
if len(msgs) <= m.compaction.MaxMessages {
return
}
keep := m.compaction.KeepRecent
if keep <= 0 || keep >= m.compaction.MaxMessages {
keep = m.compaction.MaxMessages - 1
}
if keep < 1 {
keep = 1
}
cut := len(msgs) - keep
older := msgs[:cut]
recent := msgs[cut:]
m.archive = append(m.archive, older...)
summary := ai.Message{
Role: "system",
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(older)),
}
m.hist.Reset()
m.hist.Add(summary.Role, summary.Content)
for _, msg := range recent {
m.hist.Add(msg.Role, msg.Content)
}
}
func summarizeMessages(msgs []ai.Message) string {
var b strings.Builder
for i, msg := range msgs {
if i > 0 {
b.WriteString(" | ")
}
fmt.Fprintf(&b, "%s: %s", msg.Role, compactText(fmt.Sprint(msg.Content), 120))
}
return b.String()
}
func compactText(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if max > 0 && len(s) > max {
return s[:max] + "…"
}
return s
}
func recallScore(msg ai.Message, terms []string) int {
text := strings.ToLower(fmt.Sprint(msg.Content))
score := 0
for _, term := range terms {
if strings.Contains(text, term) {
score++
}
}
return score
}
func recallTerms(query string) []string {
seen := map[string]bool{}
var terms []string
for _, term := range strings.Fields(strings.ToLower(query)) {
term = strings.Trim(term, ".,!?;:\"'()[]{}")
if len(term) < 3 || seen[term] {
continue
}
seen[term] = true
terms = append(terms, term)
}
return terms
}
type memoryState struct {
Messages []ai.Message `json:"messages"`
Archive []ai.Message `json:"archive,omitempty"`
}
+154
View File
@@ -0,0 +1,154 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
func TestStoreMemoryPersists(t *testing.T) {
st := store.NewMemoryStore()
m := NewMemory(st, "agent/x/history", 10)
m.Add("user", "hello")
m.Add("assistant", "hi there")
// A fresh memory over the same store/key restores the conversation.
reloaded := NewMemory(st, "agent/x/history", 10)
if got := len(reloaded.Messages()); got != 2 {
t.Fatalf("restored %d messages, want 2", got)
}
}
func TestInMemoryNotPersisted(t *testing.T) {
m := NewInMemory(10)
m.Add("user", "x")
if got := len(m.Messages()); got != 1 {
t.Fatalf("got %d messages, want 1", got)
}
if got := len(NewInMemory(10).Messages()); got != 0 {
t.Errorf("a separate in-memory should be empty, got %d", got)
}
}
func TestMemoryClearPersists(t *testing.T) {
st := store.NewMemoryStore()
m := NewMemory(st, "agent/y/history", 10)
m.Add("user", "x")
m.Clear()
if got := len(m.Messages()); got != 0 {
t.Errorf("after Clear got %d messages, want 0", got)
}
if got := len(NewMemory(st, "agent/y/history", 10).Messages()); got != 0 {
t.Errorf("cleared state should persist, reload got %d", got)
}
}
func TestWithMemoryUsed(t *testing.T) {
custom := NewInMemory(5)
a := New(
Name("z"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(custom),
).(*agentImpl)
a.setup()
if a.mem != custom {
t.Error("WithMemory should make the agent use the supplied memory")
}
}
func TestCompactingMemoryRecallRanksSpecificMatches(t *testing.T) {
m := NewCompactingMemory(store.NewMemoryStore(), "agent/rank/history", 3, 1).(MemoryRecall)
writer := m.(Memory)
writer.Add("user", "alpha budget is 42")
writer.Add("assistant", "noted")
writer.Add("user", "beta budget is 7")
writer.Add("assistant", "noted")
writer.Add("user", "alpha owner is sam")
recalled := m.Recall("alpha budget", 2)
if len(recalled) == 0 {
t.Fatal("expected recalled messages")
}
if got := recalled[0].Content.(string); !strings.Contains(got, "alpha budget is 42") {
t.Fatalf("top recall = %q, want alpha budget match", got)
}
}
func TestCompactingMemoryArchivePersistsAndReloads(t *testing.T) {
st := store.NewMemoryStore()
m := NewCompactingMemory(st, "agent/reload/history", 3, 1)
m.Add("user", "alpha budget is 42")
m.Add("assistant", "noted")
m.Add("user", "beta budget is 7")
m.Add("assistant", "noted")
reloaded := NewCompactingMemory(st, "agent/reload/history", 3, 1)
recall, ok := reloaded.(MemoryRecall)
if !ok {
t.Fatal("compacting memory should support recall")
}
recalled := recall.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)
}
}
// A custom tool is offered to the model and dispatched to its handler.
func TestWithToolExposedAndDispatched(t *testing.T) {
var got map[string]any
a := newTestAgent(Name("calc-agent"),
WithTool("calc", "adds two numbers",
map[string]any{
"a": map[string]any{"type": "number"},
"b": map[string]any{"type": "number"},
},
func(ctx context.Context, input map[string]any) (string, error) {
got = input
return `{"sum":3}`, nil
}))
tools, err := a.discoverTools()
if err != nil {
t.Fatalf("discoverTools: %v", err)
}
found := false
for _, tl := range tools {
if tl.Name == "calc" {
found = true
}
}
if !found {
t.Fatal("custom tool 'calc' was not offered to the model")
}
content := toolContent(a.toolHandler(), "calc", map[string]any{"a": 1.0, "b": 2.0})
if got == nil {
t.Fatal("custom tool handler was not called")
}
if !strings.Contains(content, "sum") {
t.Errorf("custom tool result not returned: %q", content)
}
}
// A custom tool returning an error surfaces it to the model.
func TestWithToolError(t *testing.T) {
a := newTestAgent(Name("err-agent"),
WithTool("boom", "always fails", nil,
func(ctx context.Context, input map[string]any) (string, error) {
return "", errors.New("kaboom")
}))
content := toolContent(a.toolHandler(), "boom", nil)
if !strings.Contains(content, "kaboom") {
t.Errorf("tool error not surfaced: %q", content)
}
}
+297
View File
@@ -0,0 +1,297 @@
package agent
import (
"context"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/client"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
"go.opentelemetry.io/otel/trace"
)
// Option configures an Agent.
type Option func(*Options)
// ApproveFunc decides whether an agent may execute a tool call before it
// runs. Returning false blocks the call; the reason is shown to the
// model so it can adapt. Use it for human-in-the-loop approval or policy
// checks. It is called for actions (service tools and delegate), not for
// the internal plan tool.
type ApproveFunc func(tool string, input map[string]any) (approved bool, reason string)
// ToolFunc handles a custom tool call. Return the result as a string
// (often JSON); return an error to report failure back to the model.
type ToolFunc func(ctx context.Context, input map[string]any) (string, error)
// customTool is a developer-registered tool beyond the agent's services.
type customTool struct {
def ai.Tool
handler ToolFunc
}
// Options holds agent configuration.
type Options struct {
Name string
Services []string
Prompt string
Provider string
Model string
APIKey string
Address string
Registry registry.Registry
Client client.Client
Store store.Store
HistoryLimit int
// ModelTimeout bounds each provider Generate call (0 disables).
ModelTimeout time.Duration
// ModelMaxAttempts bounds provider Generate attempts including the first
// call. Default 1 — retries are opt-in (enable with ModelRetry). A Generate
// runs the whole tool-execution turn, so auto-retrying it would re-run
// already-executed, possibly side-effecting tool calls; keep it explicit.
ModelMaxAttempts int
// ModelRetryBackoff is the base delay between transient provider failures
// (grows exponentially per attempt when retries are enabled).
ModelRetryBackoff time.Duration
// Memory is the agent's conversation memory. Nil = the default
// store-backed memory (durable across restarts).
Memory Memory
// MemoryCompaction enables deterministic compaction/retrieval on the
// default store-backed memory. Custom Memory implementations can expose
// retrieval by implementing MemoryRecall.
MemoryCompaction MemoryCompaction
// MemoryRecallLimit bounds recalled archived turns injected into a model
// request (0 disables recall injection).
MemoryRecallLimit int
// Checkpoint persists agent Ask runs so callers can resume by run id
// after a restart without replaying a run that already completed.
Checkpoint flow.Checkpoint
// MaxSteps bounds the number of tool executions per Ask (0 =
// unbounded). Once exceeded, further tool calls are refused and the
// model is told to stop and summarize. A stopping condition.
MaxSteps int
// LoopLimit bounds how many times the agent may call the same tool
// with the same arguments in one Ask before the call is refused as a
// no-progress loop (0 = disabled). Catches the agent repeating an
// identical action — which MaxSteps only bounds by total count.
LoopLimit int
// Approve gates each action before it runs. Nil = allow all.
Approve ApproveFunc
// A2AAddress, if set, makes Run serve this agent over the A2A protocol
// on that address directly (no separate gateway), e.g. ":4000".
A2AAddress string
// TraceProvider enables OpenTelemetry spans for agent runs, model calls,
// and tool calls. Nil disables instrumentation.
TraceProvider trace.TracerProvider
// tools are developer-registered custom tools (see WithTool).
tools []customTool
// wrappers are developer-registered tool-execution wrappers
// (see WrapTool), applied outside the built-in guardrails.
wrappers []ai.ToolWrapper
}
func newOptions(opts ...Option) Options {
o := Options{
Registry: registry.DefaultRegistry,
Client: client.DefaultClient,
Store: store.DefaultStore,
HistoryLimit: 50,
ModelTimeout: 30 * time.Second,
ModelMaxAttempts: 1, // retries opt-in via ModelRetry (see field doc)
ModelRetryBackoff: 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,
}
for _, opt := range opts {
opt(&o)
}
return o
}
// Name sets the agent name.
func Name(n string) Option {
return func(o *Options) { o.Name = n }
}
// Services sets which services this agent manages.
func Services(names ...string) Option {
return func(o *Options) { o.Services = names }
}
// Prompt sets the system prompt.
func Prompt(p string) Option {
return func(o *Options) { o.Prompt = p }
}
// Provider sets the LLM provider.
func Provider(p string) Option {
return func(o *Options) { o.Provider = p }
}
// Model sets the LLM model name.
func Model(m string) Option {
return func(o *Options) { o.Model = m }
}
// APIKey sets the API key for the LLM provider.
func APIKey(k string) Option {
return func(o *Options) { o.APIKey = k }
}
// 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.
func Address(addr string) Option {
return func(o *Options) { o.Address = addr }
}
// WithRegistry sets the service registry.
func WithRegistry(r registry.Registry) Option {
return func(o *Options) { o.Registry = r }
}
// WithClient sets the RPC client.
func WithClient(c client.Client) Option {
return func(o *Options) { o.Client = c }
}
// WithStore sets the store for agent memory.
func WithStore(s store.Store) Option {
return func(o *Options) { o.Store = s }
}
// HistoryLimit sets the max conversation messages to retain.
func HistoryLimit(n int) Option {
return func(o *Options) { o.HistoryLimit = n }
}
// MaxSteps bounds tool executions per Ask (0 = unbounded). A stopping
// condition: beyond the limit, tool calls are refused and the model is
// told to stop and summarize.
func MaxSteps(n int) Option {
return func(o *Options) { o.MaxSteps = n }
}
// ApproveTool sets a human-in-the-loop / policy hook called before each
// action (service tools and delegate). Returning false blocks the call.
func ApproveTool(fn ApproveFunc) Option {
return func(o *Options) { o.Approve = fn }
}
// LoopLimit sets how many times the agent may repeat the same tool call
// (same name and arguments) in one Ask before it is refused as a
// no-progress loop. 0 disables loop detection.
func LoopLimit(n int) Option {
return func(o *Options) { o.LoopLimit = n }
}
// ModelCallTimeout sets the timeout for each provider Generate call.
func ModelCallTimeout(d time.Duration) Option {
return func(o *Options) { o.ModelTimeout = d }
}
// ModelRetry sets the provider retry budget and backoff for transient failures.
func ModelRetry(maxAttempts int, backoff time.Duration) Option {
return func(o *Options) {
o.ModelMaxAttempts = maxAttempts
o.ModelRetryBackoff = 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;
// this adds a second, A2A-native HTTP endpoint that calls it in-process.
func WithA2A(addr string) Option {
return func(o *Options) { o.A2AAddress = addr }
}
// WithMemory sets the agent's conversation memory. The default is
// store-backed memory keyed by agent name; supply your own to use an
// in-process, database, or semantic store.
func WithMemory(m Memory) Option {
return func(o *Options) { o.Memory = m }
}
// 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
// turns are injected into matching future asks.
func CompactMemory(maxMessages, keepRecent int) Option {
return func(o *Options) {
o.MemoryCompaction = MemoryCompaction{MaxMessages: maxMessages, KeepRecent: keepRecent}
if o.MemoryRecallLimit == 0 {
o.MemoryRecallLimit = 5
}
}
}
// MemoryRecallLimit sets how many archived turns a memory backend may inject
// into a model request for the current Ask. Use 0 to disable retrieval.
func MemoryRecallLimit(n int) Option {
return func(o *Options) { o.MemoryRecallLimit = n }
}
// WithCheckpoint sets the durability backend for agent Ask runs. The
// Checkpoint interface is shared with flow so services, agents, and workflows
// can use one execution history backend. When set, each Ask is saved as a
// single-step run keyed by run id; Resume returns a completed run's persisted
// response instead of calling the model again.
func WithCheckpoint(c flow.Checkpoint) Option {
return func(o *Options) { o.Checkpoint = c }
}
// WrapTool registers a tool-execution wrapper, the tool-side analog of
// a client/server middleware wrapper. Each wrapper takes the next handler
// and returns a new one; code before the next(...) call runs before the
// tool executes, code after runs after. Use it for logging, metrics,
// retries, or custom policy. Wrappers run outside the built-in guardrails
// (MaxSteps, LoopLimit, ApproveTool), so they observe every call and its
// result, including refusals. Multiple wrappers compose outermost-first.
//
// micro.NewAgent("worker", micro.AgentWrapTool(
// func(next ai.ToolHandler) ai.ToolHandler {
// return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
// res := next(ctx, call)
// log.Printf("id=%s tool=%s", call.ID, call.Name)
// return res
// }
// }))
func WrapTool(w ...ai.ToolWrapper) Option {
return func(o *Options) {
o.wrappers = append(o.wrappers, w...)
}
}
// WithTool registers a custom tool the agent can call, beyond the
// services it discovers — a local function, an external API, anything.
// properties is the JSON-schema map for the tool's parameters.
func WithTool(name, description string, properties map[string]any, handler ToolFunc) Option {
return func(o *Options) {
o.tools = append(o.tools, customTool{
def: ai.Tool{
Name: name,
OriginalName: name,
Description: description,
Properties: properties,
},
handler: handler,
})
}
}
// TraceProvider enables OpenTelemetry tracing for agent runs. The persisted
// run timeline is recorded even when TraceProvider is nil; trace/span IDs are
// added only when a provider is configured.
func TraceProvider(tp trace.TracerProvider) Option {
return func(o *Options) { o.TraceProvider = tp }
}
+429
View File
@@ -0,0 +1,429 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"sort"
"strings"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/codes"
"go.opentelemetry.io/otel/trace"
)
const agentInstrumentationName = "go-micro.dev/v6/agent"
const (
spanNameRun = "agent.run"
spanNameModelCall = "agent.model.call"
spanNameToolCall = "agent.tool.call"
AttrRunID = "agent.run.id"
AttrParentRunID = "agent.run.parent_id"
AttrAgentName = "agent.name"
AttrProvider = "agent.model.provider"
AttrModel = "agent.model.name"
AttrLatencyMS = "agent.latency_ms"
AttrInputTokens = "agent.tokens.input"
AttrOutputTokens = "agent.tokens.output"
AttrTotalTokens = "agent.tokens.total"
AttrToolName = "agent.tool.name"
AttrDelegate = "agent.delegate"
AttrGuardrailBlock = "agent.guardrail.block"
AttrRefusal = "agent.refusal"
)
type RunEvent struct {
Time time.Time `json:"time"`
RunID string `json:"run_id"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
Agent string `json:"agent"`
Kind string `json:"kind"`
Name string `json:"name,omitempty"`
Provider string `json:"provider,omitempty"`
Model string `json:"model,omitempty"`
LatencyMS int64 `json:"latency_ms,omitempty"`
Tokens Usage `json:"tokens,omitempty"`
Refused string `json:"refused,omitempty"`
Error string `json:"error,omitempty"`
}
type Usage = ai.Usage
// RunListOptions controls how recorded agent run summaries are returned.
// Zero values preserve the full deterministic run list.
type RunListOptions struct {
// Status, when set, keeps only runs with the matching status
// (for example "running", "done", "error", or "refused").
Status string
// TraceID, when set, keeps only runs correlated with this trace id.
// A prefix is accepted so operators can paste the shortened trace id
// printed by `micro runs`.
TraceID string
// Limit, when positive, returns the most recently updated runs up to
// the limit. Limited results are ordered newest first.
Limit int
}
// RunSummary is a compact index entry for a recorded agent run.
type RunSummary struct {
RunID string `json:"run_id"`
Agent string `json:"agent"`
ParentID string `json:"parent_id,omitempty"`
TraceID string `json:"trace_id,omitempty"`
SpanID string `json:"span_id,omitempty"`
StartedAt time.Time `json:"started_at"`
UpdatedAt time.Time `json:"updated_at"`
DurationMS int64 `json:"duration_ms,omitempty"`
Events int `json:"events"`
Status string `json:"status,omitempty"`
LastKind string `json:"last_kind,omitempty"`
LastError string `json:"last_error,omitempty"`
}
func (a *agentImpl) tracer() trace.Tracer {
return a.opts.TraceProvider.Tracer(agentInstrumentationName)
}
func (a *agentImpl) startRun(ctx context.Context, message string) (context.Context, func(error)) {
info, _ := ai.RunInfoFrom(ctx)
start := time.Now()
if a.opts.TraceProvider == nil {
a.recordRunEvent(RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
return ctx, func(err error) {
latency := time.Since(start).Milliseconds()
if err != nil {
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
return
}
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
}
}
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)))
a.recordSpanEvent(span, RunEvent{Time: start, RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "run", Name: message})
return ctx, func(err error) {
latency := time.Since(start).Milliseconds()
span.SetAttributes(attribute.Int64(AttrLatencyMS, latency))
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "error", LatencyMS: latency, Error: err.Error()})
} else {
span.SetStatus(codes.Ok, "")
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "done", LatencyMS: latency})
}
span.End()
}
}
type tracedModel struct {
ai.Model
a *agentImpl
}
func (a *agentImpl) tracedModel(m ai.Model) ai.Model { return &tracedModel{Model: m, a: a} }
func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
info, _ := ai.RunInfoFrom(ctx)
provider := m.String()
model := m.Options().Model
start := time.Now()
if m.a.opts.TraceProvider == nil {
resp, err := m.Model.Generate(ctx, req, opts...)
dur := time.Since(start).Milliseconds()
usage := ai.Usage{}
if resp != nil {
usage = resp.Usage
}
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, LatencyMS: dur, Tokens: usage}
if err != nil {
e.Error = err.Error()
}
m.a.recordRunEvent(e)
return resp, err
}
ctx, span := m.a.tracer().Start(ctx, spanNameModelCall, trace.WithAttributes(
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrProvider, provider),
attribute.String(AttrModel, model),
))
resp, err := m.Model.Generate(ctx, req, opts...)
dur := time.Since(start).Milliseconds()
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
usage := ai.Usage{}
if resp != nil {
usage = resp.Usage
attrs = appendUsage(attrs, usage)
}
span.SetAttributes(attrs...)
if err != nil {
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
} else {
span.SetStatus(codes.Ok, "")
}
span.End()
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "model", Provider: provider, Model: model, LatencyMS: dur, Tokens: usage}
if err != nil {
e.Error = err.Error()
}
m.a.recordSpanEvent(span, e)
return resp, err
}
func appendUsage(attrs []attribute.KeyValue, u ai.Usage) []attribute.KeyValue {
if u.InputTokens > 0 {
attrs = append(attrs, attribute.Int(AttrInputTokens, u.InputTokens))
}
if u.OutputTokens > 0 {
attrs = append(attrs, attribute.Int(AttrOutputTokens, u.OutputTokens))
}
if u.TotalTokens > 0 {
attrs = append(attrs, attribute.Int(AttrTotalTokens, u.TotalTokens))
}
return attrs
}
func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
info, _ := ai.RunInfoFrom(ctx)
start := time.Now()
if a.opts.TraceProvider == nil {
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resultError(res)})
return res
}
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrToolName, call.Name),
attribute.Bool(AttrDelegate, call.Name == toolDelegate),
))
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
if res.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, res.Refused))
}
span.SetAttributes(attrs...)
resErr := resultError(res)
if res.Refused != "" {
span.SetStatus(codes.Error, res.Refused)
} else if resErr != "" {
span.SetStatus(codes.Error, resErr)
} else {
span.SetStatus(codes.Ok, "")
}
span.End()
a.recordSpanEvent(span, RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "tool", Name: call.Name, LatencyMS: dur, Refused: res.Refused, Error: resErr})
return res
}
}
func resultError(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 err, _ := m["error"].(string); err != "" {
return err
}
}
return ""
}
func (a *agentImpl) recordSpanEvent(span trace.Span, e RunEvent) {
if sc := span.SpanContext(); sc.IsValid() {
e.TraceID = sc.TraceID().String()
e.SpanID = sc.SpanID().String()
}
span.AddEvent("agent."+e.Kind, trace.WithTimestamp(e.Time), trace.WithAttributes(runEventAttributes(e)...))
a.recordRunEvent(e)
}
func runEventAttributes(e RunEvent) []attribute.KeyValue {
attrs := []attribute.KeyValue{
attribute.String(AttrRunID, e.RunID),
attribute.String(AttrAgentName, e.Agent),
}
if e.ParentID != "" {
attrs = append(attrs, attribute.String(AttrParentRunID, e.ParentID))
}
if e.Name != "" {
attrs = append(attrs, attribute.String("agent.event.name", e.Name))
}
if e.Provider != "" {
attrs = append(attrs, attribute.String(AttrProvider, e.Provider))
}
if e.Model != "" {
attrs = append(attrs, attribute.String(AttrModel, e.Model))
}
if e.LatencyMS > 0 {
attrs = append(attrs, attribute.Int64(AttrLatencyMS, e.LatencyMS))
}
attrs = appendUsage(attrs, e.Tokens)
if e.Refused != "" {
attrs = append(attrs, attribute.Bool(AttrGuardrailBlock, true), attribute.String(AttrRefusal, e.Refused))
}
if e.Error != "" {
attrs = append(attrs, attribute.String("agent.error", e.Error))
}
return attrs
}
func (a *agentImpl) recordRunEvent(e RunEvent) {
if e.RunID == "" {
return
}
b, _ := json.Marshal(e)
key := fmt.Sprintf("runs/%s/%020d-%s", e.RunID, e.Time.UnixNano(), e.Kind)
_ = a.stateStore().Write(&store.Record{Key: key, Value: b})
}
// ListRunSummaries returns a deterministic summary of recorded runs for agentName.
func ListRunSummaries(s store.Store, agentName string) ([]RunSummary, error) {
return ListRunSummariesWithOptions(s, agentName, RunListOptions{})
}
// ListRunSummariesWithOptions returns summaries of recorded runs for agentName,
// optionally filtered by status and limited to the most recently updated runs.
func ListRunSummariesWithOptions(s store.Store, agentName string, opts RunListOptions) ([]RunSummary, error) {
st := store.Scope(s, "agent", agentName)
keys, err := st.List(store.ListPrefix("runs/"))
if err != nil {
return nil, err
}
runs := map[string]bool{}
for _, k := range keys {
parts := strings.Split(k, "/")
if len(parts) >= 2 && parts[1] != "" {
runs[parts[1]] = true
}
}
ids := make([]string, 0, len(runs))
for id := range runs {
ids = append(ids, id)
}
sort.Strings(ids)
summaries := make([]RunSummary, 0, len(ids))
for _, id := range ids {
events, err := LoadRunEvents(s, agentName, id)
if err != nil {
return nil, err
}
if len(events) == 0 {
continue
}
first := events[0]
last := events[len(events)-1]
summary := RunSummary{
RunID: id,
Agent: first.Agent,
ParentID: first.ParentID,
TraceID: first.TraceID,
SpanID: first.SpanID,
StartedAt: first.Time,
UpdatedAt: last.Time,
DurationMS: last.Time.Sub(first.Time).Milliseconds(),
Events: len(events),
Status: runStatus(events),
LastKind: last.Kind,
LastError: last.Error,
}
for _, e := range events {
if e.Agent != "" {
summary.Agent = e.Agent
}
if e.ParentID != "" {
summary.ParentID = e.ParentID
}
if e.TraceID != "" {
summary.TraceID = e.TraceID
}
if e.SpanID != "" {
summary.SpanID = e.SpanID
}
if e.Error != "" {
summary.LastError = e.Error
}
}
if opts.Status != "" && summary.Status != opts.Status {
continue
}
if opts.TraceID != "" && !strings.HasPrefix(summary.TraceID, opts.TraceID) {
continue
}
summaries = append(summaries, summary)
}
if opts.Limit > 0 {
sort.SliceStable(summaries, func(i, j int) bool {
return summaries[i].UpdatedAt.After(summaries[j].UpdatedAt)
})
if len(summaries) > opts.Limit {
summaries = summaries[:opts.Limit]
}
}
return summaries, nil
}
func runStatus(events []RunEvent) string {
if len(events) == 0 {
return ""
}
status := "running"
for _, e := range events {
if e.Error != "" {
status = "error"
}
if e.Refused != "" && status != "error" {
status = "refused"
}
switch e.Kind {
case "error":
status = "error"
case "done":
if status == "running" {
status = "done"
}
}
}
return status
}
func LoadRunEvents(s store.Store, agentName, runID string) ([]RunEvent, error) {
st := store.Scope(s, "agent", agentName)
keys, err := st.List(store.ListPrefix("runs/" + runID + "/"))
if err != nil {
return nil, err
}
sort.Strings(keys)
events := make([]RunEvent, 0, len(keys))
for _, k := range keys {
recs, err := st.Read(k)
if err != nil || len(recs) == 0 {
continue
}
var e RunEvent
if json.Unmarshal(recs[0].Value, &e) == nil {
events = append(events, e)
}
}
return events, nil
}
+339
View File
@@ -0,0 +1,339 @@
package agent
import (
"context"
"encoding/json"
"fmt"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/store"
"go.opentelemetry.io/otel/attribute"
"go.opentelemetry.io/otel/sdk/trace"
"go.opentelemetry.io/otel/sdk/trace/tracetest"
)
type otelTestModel struct{ opts ai.Options }
func (m *otelTestModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *otelTestModel) Options() ai.Options { return m.opts }
func (m *otelTestModel) String() string { return "oteltest" }
func (m *otelTestModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, nil
}
func (m *otelTestModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
if m.opts.ToolHandler != nil {
if strings.Contains(req.Prompt, "delegate") {
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-delegate", Name: toolDelegate, Input: map[string]any{"task": "subtask"}})
} else if !strings.Contains(req.Prompt, "subtask") {
_ = m.opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "probe", Input: map[string]any{"ok": true}})
}
}
return &ai.Response{Reply: "done", Usage: ai.Usage{InputTokens: 2, OutputTokens: 3, TotalTokens: 5}}, nil
}
func init() {
ai.Register("oteltest", func(opts ...ai.Option) ai.Model { return &otelTestModel{opts: ai.NewOptions(opts...)} })
}
func TestAgentOpenTelemetrySpans(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("runner"), Provider("oteltest"), Model("unit-model"), WithStore(st), TraceProvider(tp), WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }))
if _, err := a.Ask(context.Background(), "hello"); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
want := map[string]bool{spanNameRun: false, spanNameModelCall: false, spanNameToolCall: false}
var runID string
for _, s := range spans {
if _, ok := want[s.Name()]; ok {
want[s.Name()] = true
}
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameRun {
runID = attrs[AttrRunID]
}
}
for name, seen := range want {
if !seen {
t.Fatalf("span %s not emitted; got %d spans", name, len(spans))
}
}
if runID == "" {
t.Fatal("run span missing run id attribute")
}
var runEvents []trace.Event
for _, s := range spans {
if s.Name() == spanNameRun {
runEvents = s.Events()
break
}
}
if !spanEventHasRunInfo(runEvents, "agent.run", runID, "runner") || !spanEventHasRunInfo(runEvents, "agent.done", runID, "runner") {
t.Fatalf("run span missing run-info events: %#v", runEvents)
}
for _, s := range spans {
if s.Name() != spanNameModelCall && s.Name() != spanNameToolCall {
continue
}
attrs := spanAttributes(s.Attributes())
if attrs[AttrRunID] != runID || attrs[AttrAgentName] != "runner" {
t.Fatalf("%s missing run correlation attributes: %#v", s.Name(), attrs)
}
}
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
if err != nil {
t.Fatal(err)
}
if len(keys) == 0 {
t.Fatal("expected run events to be recorded")
}
summaries, err := ListRunSummaries(st, "runner")
if err != nil {
t.Fatal(err)
}
if len(summaries) != 1 {
t.Fatalf("got %d summaries, want 1", len(summaries))
}
if summaries[0].LastKind != "done" {
t.Fatalf("LastKind = %q, want done", summaries[0].LastKind)
}
if summaries[0].Status != "done" {
t.Fatalf("Status = %q, want done", summaries[0].Status)
}
if summaries[0].DurationMS < 0 {
t.Fatalf("DurationMS = %d, want non-negative", summaries[0].DurationMS)
}
if summaries[0].TraceID == "" || summaries[0].SpanID == "" {
t.Fatalf("summary missing trace correlation: %#v", summaries[0])
}
events, err := LoadRunEvents(st, "runner", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
if len(events) == 0 || events[0].TraceID == "" || events[0].SpanID == "" {
t.Fatalf("events missing trace correlation: %#v", events)
}
}
func spanEventHasRunInfo(events []trace.Event, name, runID, agentName string) bool {
for _, event := range events {
if event.Name != name {
continue
}
attrs := spanAttributes(event.Attributes)
if attrs[AttrRunID] == runID && attrs[AttrAgentName] == agentName {
return true
}
}
return false
}
func spanAttributes(attrs []attribute.KeyValue) map[string]string {
out := make(map[string]string, len(attrs))
for _, attr := range attrs {
out[string(attr.Key)] = fmt.Sprint(attr.Value.AsInterface())
}
return out
}
func TestAgentOpenTelemetrySpansDelegateLineage(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("conductor"), Provider("oteltest"), WithStore(st), TraceProvider(tp))
if _, err := a.Ask(context.Background(), "delegate please"); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
var parentRunID string
var delegateSpanID string
var subRunSeen bool
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameRun && attrs[AttrAgentName] == "conductor" {
parentRunID = attrs[AttrRunID]
}
}
if parentRunID == "" {
t.Fatal("parent run span missing run id")
}
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameToolCall && attrs[AttrToolName] == toolDelegate {
if attrs[AttrDelegate] != "true" || attrs[AttrRunID] != parentRunID {
t.Fatalf("delegate span missing correlation attributes: %#v", attrs)
}
delegateSpanID = s.SpanContext().SpanID().String()
}
}
if delegateSpanID == "" {
t.Fatal("delegate tool span not emitted")
}
for _, s := range spans {
attrs := spanAttributes(s.Attributes())
if s.Name() == spanNameRun && attrs[AttrAgentName] == "conductor.sub" {
if attrs[AttrParentRunID] != parentRunID {
t.Fatalf("sub-agent run parent attr = %q, want %q", attrs[AttrParentRunID], parentRunID)
}
if s.Parent().SpanID().String() != delegateSpanID {
t.Fatalf("sub-agent run parent span = %s, want delegate span %s", s.Parent().SpanID(), delegateSpanID)
}
subRunSeen = true
}
}
if !subRunSeen {
t.Fatalf("sub-agent run span not emitted; got %d spans", len(spans))
}
}
func TestAgentRunTimelineRecordsModelAndToolWithoutTraceProvider(t *testing.T) {
st := store.NewMemoryStore()
a := New(Name("runner-noop"), Provider("oteltest"), WithStore(st), WithTool("probe", "probe", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }))
if _, err := a.Ask(context.Background(), "hello"); err != nil {
t.Fatal(err)
}
keys, err := store.Scope(st, "agent", "runner-noop").List(store.ListPrefix("runs/"))
if err != nil {
t.Fatal(err)
}
if len(keys) == 0 {
t.Fatal("expected run timeline without TraceProvider")
}
summaries, err := ListRunSummaries(st, "runner-noop")
if err != nil {
t.Fatal(err)
}
if len(summaries) != 1 {
t.Fatalf("got %d summaries, want 1", len(summaries))
}
if summaries[0].Status != "done" || summaries[0].LastKind != "done" {
t.Fatalf("unexpected summary without TraceProvider: %#v", summaries[0])
}
if summaries[0].TraceID != "" || summaries[0].SpanID != "" {
t.Fatalf("unexpected trace correlation without TraceProvider: %#v", summaries[0])
}
events, err := LoadRunEvents(st, "runner-noop", summaries[0].RunID)
if err != nil {
t.Fatal(err)
}
seen := map[string]bool{"run": false, "model": false, "tool": false, "done": false}
for _, e := range events {
seen[e.Kind] = true
if e.TraceID != "" || e.SpanID != "" {
t.Fatalf("event has trace correlation without TraceProvider: %#v", e)
}
}
for kind, ok := range seen {
if !ok {
t.Fatalf("missing %s event in timeline: %#v", kind, events)
}
}
}
func TestLoadRunEventsSortsTimelineKeys(t *testing.T) {
st := store.NewMemoryStore()
scoped := store.Scope(st, "agent", "runner")
runID := "run-1"
events := []RunEvent{
{Time: time.Unix(0, 3), RunID: runID, Agent: "runner", Kind: "tool", Name: "third"},
{Time: time.Unix(0, 1), RunID: runID, Agent: "runner", Kind: "run", Name: "first"},
{Time: time.Unix(0, 2), RunID: runID, Agent: "runner", Kind: "model", Name: "second"},
}
for _, e := range events {
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
key := "runs/" + runID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
t.Fatal(err)
}
}
got, err := LoadRunEvents(st, "runner", runID)
if err != nil {
t.Fatal(err)
}
if len(got) != 3 {
t.Fatalf("got %d events, want 3", len(got))
}
for i, want := range []string{"first", "second", "third"} {
if got[i].Name != want {
t.Fatalf("event %d = %q, want %q (timeline: %#v)", i, got[i].Name, want, got)
}
}
}
func TestListRunSummaries(t *testing.T) {
st := store.NewMemoryStore()
scoped := store.Scope(st, "agent", "runner")
events := []RunEvent{
{Time: time.Unix(0, 1), RunID: "run-a", Agent: "runner", TraceID: "trace-a", SpanID: "span-a", Kind: "run", Name: "first"},
{Time: time.Unix(0, 2), RunID: "run-a", Agent: "runner", Kind: "tool", Name: "probe"},
{Time: time.Unix(0, 3), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "run", Name: "second"},
{Time: time.Unix(0, 4), RunID: "run-b", Agent: "runner", ParentID: "parent", Kind: "error", Error: "boom"},
}
for _, e := range events {
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
key := "runs/" + e.RunID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
t.Fatal(err)
}
}
got, err := ListRunSummaries(st, "runner")
if err != nil {
t.Fatal(err)
}
if len(got) != 2 {
t.Fatalf("got %d summaries, want 2: %#v", len(got), got)
}
if got[0].RunID != "run-a" || got[0].TraceID != "trace-a" || got[0].SpanID != "span-a" || got[0].Events != 2 || got[0].Status != "running" || got[0].DurationMS != 0 || got[0].LastKind != "tool" || !got[0].UpdatedAt.Equal(time.Unix(0, 2)) {
t.Fatalf("unexpected run-a summary: %#v", got[0])
}
if got[1].RunID != "run-b" || got[1].ParentID != "parent" || got[1].Events != 2 || got[1].Status != "error" || got[1].DurationMS != 0 || got[1].LastKind != "error" || got[1].LastError != "boom" {
t.Fatalf("unexpected run-b summary: %#v", got[1])
}
}
func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
st := store.NewMemoryStore()
scoped := store.Scope(st, "agent", "runner")
events := []RunEvent{
{Time: time.Unix(0, 1), RunID: "run-old", Agent: "runner", Kind: "run"},
{Time: time.Unix(0, 2), RunID: "run-old", Agent: "runner", Kind: "done"},
{Time: time.Unix(0, 3), RunID: "run-new", Agent: "runner", TraceID: "abcdef1234567890", Kind: "run"},
{Time: time.Unix(0, 4), RunID: "run-new", Agent: "runner", Kind: "error", Error: "boom"},
}
for _, e := range events {
b, err := json.Marshal(e)
if err != nil {
t.Fatal(err)
}
if err := scoped.Write(&store.Record{Key: "runs/" + e.RunID + "/" + e.Time.Format("20060102150405.000000000") + "-" + e.Kind, Value: b}); err != nil {
t.Fatal(err)
}
}
got, err := ListRunSummariesWithOptions(st, "runner", RunListOptions{Status: "error", TraceID: "abcdef", Limit: 1})
if err != nil {
t.Fatal(err)
}
if len(got) != 1 || got[0].RunID != "run-new" || got[0].Status != "error" {
t.Fatalf("filtered summaries = %#v", got)
}
}
+297
View File
@@ -0,0 +1,297 @@
// Code generated by protoc-gen-go. DO NOT EDIT.
// versions:
// protoc-gen-go v1.36.11
// protoc v3.21.12
// source: agent/proto/agent.proto
package agent
import (
protoreflect "google.golang.org/protobuf/reflect/protoreflect"
protoimpl "google.golang.org/protobuf/runtime/protoimpl"
reflect "reflect"
sync "sync"
unsafe "unsafe"
)
const (
// Verify that this generated code is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion)
// Verify that runtime/protoimpl is sufficiently up-to-date.
_ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20)
)
type ChatRequest struct {
state protoimpl.MessageState `protogen:"open.v1"`
Message string `protobuf:"bytes,1,opt,name=message,proto3" json:"message,omitempty"`
// parent_id correlates this chat with the workflow or agent run that dispatched it.
ParentId string `protobuf:"bytes,2,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ChatRequest) Reset() {
*x = ChatRequest{}
mi := &file_agent_proto_agent_proto_msgTypes[0]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ChatRequest) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ChatRequest) ProtoMessage() {}
func (x *ChatRequest) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[0]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ChatRequest.ProtoReflect.Descriptor instead.
func (*ChatRequest) Descriptor() ([]byte, []int) {
return file_agent_proto_agent_proto_rawDescGZIP(), []int{0}
}
func (x *ChatRequest) GetMessage() string {
if x != nil {
return x.Message
}
return ""
}
func (x *ChatRequest) GetParentId() string {
if x != nil {
return x.ParentId
}
return ""
}
type ChatResponse struct {
state protoimpl.MessageState `protogen:"open.v1"`
Reply string `protobuf:"bytes,1,opt,name=reply,proto3" json:"reply,omitempty"`
Agent string `protobuf:"bytes,2,opt,name=agent,proto3" json:"agent,omitempty"`
ToolCalls []*ToolCall `protobuf:"bytes,3,rep,name=tool_calls,json=toolCalls,proto3" json:"tool_calls,omitempty"`
// run_id correlates this chat response with tool calls, traces, and run history.
RunId string `protobuf:"bytes,4,opt,name=run_id,json=runId,proto3" json:"run_id,omitempty"`
// parent_id is set when this response belongs to a delegated sub-agent run.
ParentId string `protobuf:"bytes,5,opt,name=parent_id,json=parentId,proto3" json:"parent_id,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ChatResponse) Reset() {
*x = ChatResponse{}
mi := &file_agent_proto_agent_proto_msgTypes[1]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ChatResponse) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ChatResponse) ProtoMessage() {}
func (x *ChatResponse) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[1]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ChatResponse.ProtoReflect.Descriptor instead.
func (*ChatResponse) Descriptor() ([]byte, []int) {
return file_agent_proto_agent_proto_rawDescGZIP(), []int{1}
}
func (x *ChatResponse) GetReply() string {
if x != nil {
return x.Reply
}
return ""
}
func (x *ChatResponse) GetAgent() string {
if x != nil {
return x.Agent
}
return ""
}
func (x *ChatResponse) GetToolCalls() []*ToolCall {
if x != nil {
return x.ToolCalls
}
return nil
}
func (x *ChatResponse) GetRunId() string {
if x != nil {
return x.RunId
}
return ""
}
func (x *ChatResponse) GetParentId() string {
if x != nil {
return x.ParentId
}
return ""
}
type ToolCall struct {
state protoimpl.MessageState `protogen:"open.v1"`
Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"`
Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"`
Input string `protobuf:"bytes,3,opt,name=input,proto3" json:"input,omitempty"`
Result string `protobuf:"bytes,4,opt,name=result,proto3" json:"result,omitempty"`
unknownFields protoimpl.UnknownFields
sizeCache protoimpl.SizeCache
}
func (x *ToolCall) Reset() {
*x = ToolCall{}
mi := &file_agent_proto_agent_proto_msgTypes[2]
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
ms.StoreMessageInfo(mi)
}
func (x *ToolCall) String() string {
return protoimpl.X.MessageStringOf(x)
}
func (*ToolCall) ProtoMessage() {}
func (x *ToolCall) ProtoReflect() protoreflect.Message {
mi := &file_agent_proto_agent_proto_msgTypes[2]
if x != nil {
ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x))
if ms.LoadMessageInfo() == nil {
ms.StoreMessageInfo(mi)
}
return ms
}
return mi.MessageOf(x)
}
// Deprecated: Use ToolCall.ProtoReflect.Descriptor instead.
func (*ToolCall) Descriptor() ([]byte, []int) {
return file_agent_proto_agent_proto_rawDescGZIP(), []int{2}
}
func (x *ToolCall) GetId() string {
if x != nil {
return x.Id
}
return ""
}
func (x *ToolCall) GetName() string {
if x != nil {
return x.Name
}
return ""
}
func (x *ToolCall) GetInput() string {
if x != nil {
return x.Input
}
return ""
}
func (x *ToolCall) GetResult() string {
if x != nil {
return x.Result
}
return ""
}
var File_agent_proto_agent_proto protoreflect.FileDescriptor
const file_agent_proto_agent_proto_rawDesc = "" +
"\n" +
"\x17agent/proto/agent.proto\x12\x05agent\"D\n" +
"\vChatRequest\x12\x18\n" +
"\amessage\x18\x01 \x01(\tR\amessage\x12\x1b\n" +
"\tparent_id\x18\x02 \x01(\tR\bparentId\"\x9e\x01\n" +
"\fChatResponse\x12\x14\n" +
"\x05reply\x18\x01 \x01(\tR\x05reply\x12\x14\n" +
"\x05agent\x18\x02 \x01(\tR\x05agent\x12.\n" +
"\n" +
"tool_calls\x18\x03 \x03(\v2\x0f.agent.ToolCallR\ttoolCalls\x12\x15\n" +
"\x06run_id\x18\x04 \x01(\tR\x05runId\x12\x1b\n" +
"\tparent_id\x18\x05 \x01(\tR\bparentId\"\\\n" +
"\bToolCall\x12\x0e\n" +
"\x02id\x18\x01 \x01(\tR\x02id\x12\x12\n" +
"\x04name\x18\x02 \x01(\tR\x04name\x12\x14\n" +
"\x05input\x18\x03 \x01(\tR\x05input\x12\x16\n" +
"\x06result\x18\x04 \x01(\tR\x06result2:\n" +
"\x05Agent\x121\n" +
"\x04Chat\x12\x12.agent.ChatRequest\x1a\x13.agent.ChatResponse\"\x00B\x0fZ\r./proto;agentb\x06proto3"
var (
file_agent_proto_agent_proto_rawDescOnce sync.Once
file_agent_proto_agent_proto_rawDescData []byte
)
func file_agent_proto_agent_proto_rawDescGZIP() []byte {
file_agent_proto_agent_proto_rawDescOnce.Do(func() {
file_agent_proto_agent_proto_rawDescData = protoimpl.X.CompressGZIP(unsafe.Slice(unsafe.StringData(file_agent_proto_agent_proto_rawDesc), len(file_agent_proto_agent_proto_rawDesc)))
})
return file_agent_proto_agent_proto_rawDescData
}
var file_agent_proto_agent_proto_msgTypes = make([]protoimpl.MessageInfo, 3)
var file_agent_proto_agent_proto_goTypes = []any{
(*ChatRequest)(nil), // 0: agent.ChatRequest
(*ChatResponse)(nil), // 1: agent.ChatResponse
(*ToolCall)(nil), // 2: agent.ToolCall
}
var file_agent_proto_agent_proto_depIdxs = []int32{
2, // 0: agent.ChatResponse.tool_calls:type_name -> agent.ToolCall
0, // 1: agent.Agent.Chat:input_type -> agent.ChatRequest
1, // 2: agent.Agent.Chat:output_type -> agent.ChatResponse
2, // [2:3] is the sub-list for method output_type
1, // [1:2] is the sub-list for method input_type
1, // [1:1] is the sub-list for extension type_name
1, // [1:1] is the sub-list for extension extendee
0, // [0:1] is the sub-list for field type_name
}
func init() { file_agent_proto_agent_proto_init() }
func file_agent_proto_agent_proto_init() {
if File_agent_proto_agent_proto != nil {
return
}
type x struct{}
out := protoimpl.TypeBuilder{
File: protoimpl.DescBuilder{
GoPackagePath: reflect.TypeOf(x{}).PkgPath(),
RawDescriptor: unsafe.Slice(unsafe.StringData(file_agent_proto_agent_proto_rawDesc), len(file_agent_proto_agent_proto_rawDesc)),
NumEnums: 0,
NumMessages: 3,
NumExtensions: 0,
NumServices: 1,
},
GoTypes: file_agent_proto_agent_proto_goTypes,
DependencyIndexes: file_agent_proto_agent_proto_depIdxs,
MessageInfos: file_agent_proto_agent_proto_msgTypes,
}.Build()
File_agent_proto_agent_proto = out.File
file_agent_proto_agent_proto_goTypes = nil
file_agent_proto_agent_proto_depIdxs = nil
}
+79
View File
@@ -0,0 +1,79 @@
// Code generated by protoc-gen-micro. DO NOT EDIT.
// source: agent/proto/agent.proto
package agent
import (
fmt "fmt"
proto "google.golang.org/protobuf/proto"
math "math"
)
import (
context "context"
client "go-micro.dev/v6/client"
server "go-micro.dev/v6/server"
)
// Reference imports to suppress errors if they are not otherwise used.
var _ = proto.Marshal
var _ = fmt.Errorf
var _ = math.Inf
// Reference imports to suppress errors if they are not otherwise used.
var _ context.Context
var _ client.Option
var _ server.Option
// Client API for Agent service
type AgentService interface {
Chat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (*ChatResponse, error)
}
type agentService struct {
c client.Client
name string
}
func NewAgentService(name string, c client.Client) AgentService {
return &agentService{
c: c,
name: name,
}
}
func (c *agentService) Chat(ctx context.Context, in *ChatRequest, opts ...client.CallOption) (*ChatResponse, error) {
req := c.c.NewRequest(c.name, "Agent.Chat", in)
out := new(ChatResponse)
err := c.c.Call(ctx, req, out, opts...)
if err != nil {
return nil, err
}
return out, nil
}
// Server API for Agent service
type AgentHandler interface {
Chat(context.Context, *ChatRequest, *ChatResponse) error
}
func RegisterAgentHandler(s server.Server, hdlr AgentHandler, opts ...server.HandlerOption) error {
type agent interface {
Chat(ctx context.Context, in *ChatRequest, out *ChatResponse) error
}
type Agent struct {
agent
}
h := &agentHandler{hdlr}
return s.Handle(s.NewHandler(&Agent{h}, opts...))
}
type agentHandler struct {
AgentHandler
}
func (h *agentHandler) Chat(ctx context.Context, in *ChatRequest, out *ChatResponse) error {
return h.AgentHandler.Chat(ctx, in, out)
}
+35
View File
@@ -0,0 +1,35 @@
syntax = "proto3";
package agent;
option go_package = "./proto;agent";
// Agent is the RPC interface for an AI agent.
service Agent {
rpc Chat(ChatRequest) returns (ChatResponse) {}
}
message ChatRequest {
string message = 1;
// parent_id correlates this chat with the workflow or agent run that dispatched it.
string parent_id = 2;
}
message ChatResponse {
string reply = 1;
string agent = 2;
repeated ToolCall tool_calls = 3;
// run_id correlates this chat response with tool calls, traces, and run history.
string run_id = 4;
// parent_id is set when this response belongs to a delegated sub-agent run.
string parent_id = 5;
}
message ToolCall {
string id = 1;
string name = 2;
string input = 3;
string result = 4;
}
+104
View File
@@ -0,0 +1,104 @@
package agent
import (
"context"
"errors"
"strings"
"testing"
"time"
"go-micro.dev/v6/ai"
)
func TestAskCancellationAbortsPromptly(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
<-ctx.Done()
return nil, ctx.Err()
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("cancel"), ModelCallTimeout(time.Second), ModelRetry(3, time.Millisecond))
ctx, cancel := context.WithCancel(context.Background())
cancel()
start := time.Now()
_, err := a.Ask(ctx, "stop")
if !errors.Is(err, context.Canceled) {
t.Fatalf("Ask error = %v, want context canceled", err)
}
if elapsed := time.Since(start); elapsed > 100*time.Millisecond {
t.Fatalf("Ask took %s after cancellation, want prompt abort", elapsed)
}
}
func TestAskRetriesTransientErrorsThenSucceeds(t *testing.T) {
attempts := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
if attempts < 3 {
return nil, context.DeadlineExceeded
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("retry-success"), ModelRetry(3, time.Millisecond))
resp, err := a.Ask(context.Background(), "hello")
if err != nil {
t.Fatalf("Ask returned error: %v", err)
}
if resp.Reply != "ok" {
t.Fatalf("reply = %q, want ok", resp.Reply)
}
if attempts != 3 {
t.Fatalf("attempts = %d, want 3", attempts)
}
}
func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) {
attempts := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
attempts++
return nil, context.DeadlineExceeded
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("retry-fail"), ModelRetry(2, time.Millisecond))
_, err := a.Ask(context.Background(), "hello")
var retryErr *ai.RetryError
if !errors.As(err, &retryErr) {
t.Fatalf("Ask error = %T %v, want *ai.RetryError", err, err)
}
if retryErr.Attempts != 2 {
t.Fatalf("retry attempts = %d, want 2", retryErr.Attempts)
}
if attempts != 2 {
t.Fatalf("model attempts = %d, want 2", attempts)
}
}
func TestCanceledAskContextSkipsToolExecution(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
canceled, cancel := context.WithCancel(ctx)
cancel()
res := opts.ToolHandler(canceled, ai.ToolCall{ID: "call-1", Name: toolPlan, Input: map[string]any{
"steps": []any{map[string]any{"task": "should not persist", "status": "pending"}},
}})
if !strings.Contains(res.Content, context.Canceled.Error()) {
t.Fatalf("tool result = %q, want cancellation error", res.Content)
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("cancel-tools"))
if _, err := a.Ask(context.Background(), "try a canceled tool"); err != nil {
t.Fatalf("Ask: %v", err)
}
if plan := a.loadPlan(); plan != "" {
t.Fatalf("plan persisted after canceled tool context: %q", plan)
}
}
+183
View File
@@ -0,0 +1,183 @@
package agent
import (
"context"
"fmt"
"strings"
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
// A registered wrapper runs around every tool call and can observe and
// modify the result.
func TestWrapToolWraps(t *testing.T) {
var saw string
wrap := func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
saw = call.Name
res := next(ctx, call)
res.Content = "wrapped:" + res.Content
return res
}
}
a := newTestAgent(Name("wrapped"), WrapTool(wrap))
content := toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
if saw != "demo_Svc_Do" {
t.Errorf("wrapper saw %q, want demo_Svc_Do", saw)
}
if !strings.HasPrefix(content, "wrapped:") {
t.Errorf("wrapper did not modify the result; got %q", content)
}
}
// Multiple wrappers compose outermost-first: the first registered wrapper
// is the outer layer, so it runs first on the way in and last on the way
// out.
func TestWrapToolOrder(t *testing.T) {
var order []string
mk := func(tag string) ai.ToolWrapper {
return func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
order = append(order, "in:"+tag)
res := next(ctx, call)
order = append(order, "out:"+tag)
return res
}
}
}
a := newTestAgent(Name("ordered"), WrapTool(mk("a"), mk("b")))
toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
want := "in:a in:b out:b out:a"
if got := strings.Join(order, " "); got != want {
t.Errorf("wrapper order = %q, want %q", got, want)
}
}
// Wrappers run outside the built-in guardrails, so they observe a refused
// call and its refusal result rather than being short-circuited.
func TestWrapToolSeesGuardrailRefusal(t *testing.T) {
var sawResult string
wrap := func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
res := next(ctx, call)
sawResult = res.Content
return res
}
}
a := newTestAgent(Name("gated-wrap"),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
return false, "denied"
}),
WrapTool(wrap),
)
toolContent(a.toolHandler(), "demo_Svc_Do", map[string]any{})
if !strings.Contains(sawResult, "not approved") {
t.Errorf("wrapper should observe the guardrail refusal; got %q", sawResult)
}
}
// A guardrail refusal carries a structured reason a wrapper can switch on,
// so reliability tooling (e.g. loop handling) needn't parse the message.
func TestWrapToolSeesRefusedReason(t *testing.T) {
a := newTestAgent(Name("looper"), LoopLimit(2))
h := a.toolHandler()
var last ai.ToolResult
for i := 0; i < 3; i++ {
last = h(context.Background(), ai.ToolCall{ID: "x", Name: "demo_Svc_Do", Input: map[string]any{"q": "same"}})
}
if last.Refused != ai.RefusedLoop {
t.Errorf("Refused = %q, want %q", last.Refused, ai.RefusedLoop)
}
}
// ctxMock is a model that forwards the Generate context to the tool
// handler (as real providers do), so a wrapper can read ai.RunInfo.
type ctxMock struct{ opts ai.Options }
func (m *ctxMock) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *ctxMock) Options() ai.Options { return m.opts }
func (m *ctxMock) String() string { return "ctxmock" }
func (m *ctxMock) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("no stream")
}
func (m *ctxMock) Generate(ctx context.Context, _ *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
if m.opts.ToolHandler != nil {
m.opts.ToolHandler(ctx, ai.ToolCall{ID: "c1", Name: "demo_Svc_Do", Input: map[string]any{}})
}
return &ai.Response{Answer: "done"}, nil
}
// During an Ask, a wrapper sees RunInfo on the context: a correlation id
// for the run and the agent's name.
func TestWrapToolSeesRunInfo(t *testing.T) {
ai.Register("ctxmock", func(opts ...ai.Option) ai.Model {
m := &ctxMock{}
_ = m.Init(opts...)
return m
})
var got ai.RunInfo
var ok bool
a := New(
Name("runner"),
Provider("ctxmock"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WrapTool(func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
got, ok = ai.RunInfoFrom(ctx)
return next(ctx, call)
}
}),
)
resp, err := a.Ask(context.Background(), "go")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if !ok {
t.Fatal("wrapper did not see RunInfo on the context")
}
if got.Agent != "runner" {
t.Errorf("RunInfo.Agent = %q, want runner", got.Agent)
}
if got.RunID == "" {
t.Error("RunInfo.RunID is empty")
}
if resp.RunID != got.RunID {
t.Errorf("Response.RunID = %q, want wrapper RunID %q", resp.RunID, got.RunID)
}
if resp.ParentID != "" {
t.Errorf("Response.ParentID = %q, want empty", resp.ParentID)
}
}
// call.Scan decodes a tool call's input into a typed struct.
func TestToolCallScan(t *testing.T) {
call := ai.ToolCall{Input: map[string]any{"query": "hello", "limit": 5}}
var args struct {
Query string `json:"query"`
Limit int `json:"limit"`
}
if err := call.Scan(&args); err != nil {
t.Fatalf("Scan: %v", err)
}
if args.Query != "hello" || args.Limit != 5 {
t.Errorf("Scan decoded %+v, want {hello 5}", args)
}
}
+22 -5
View File
@@ -133,14 +133,15 @@ m.Init(
The model can automatically execute tool calls when provided with a tool handler:
```go
// Define a tool handler
toolHandler := func(name string, input map[string]any) (result any, content string) {
// Define a tool handler. It mirrors a go-micro RPC handler: context
// first, the call in, a result out.
toolHandler := func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
// Execute the tool and return results
switch name {
switch call.Name {
case "get_weather":
return map[string]string{"temp": "72F"}, `{"temp": "72F"}`
return ai.ToolResult{ID: call.ID, Value: map[string]string{"temp": "72F"}, Content: `{"temp": "72F"}`}
default:
return nil, `{"error": "unknown tool"}`
return ai.ToolResult{ID: call.ID, Content: `{"error": "unknown tool"}`}
}
}
@@ -187,6 +188,22 @@ type Response struct {
- `ToolCalls`: List of tools the model requested (if any)
- `Answer`: The final answer after tools are executed (only set if ToolHandler is provided)
## Provider capability matrix
The CLI can print the provider capabilities registered in the current build:
```bash
micro ai providers
```
For automation and docs generation, emit the same matrix as stable JSON:
```bash
micro ai providers --json
```
It reports support from Go Micro's provider registry, so the matrix reflects the model, image, and video interfaces available to this binary rather than external provider marketing claims.
## Supported Providers
### Anthropic Claude
+99 -36
View File
@@ -10,7 +10,7 @@ import (
"net/http"
"strings"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func init() {
@@ -77,11 +77,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
// Build initial request
apiReq := map[string]any{
"model": p.opts.Model,
"max_tokens": 4096,
"max_tokens": anthropicMaxTokens(p.opts),
"system": req.SystemPrompt,
"messages": []map[string]any{
{"role": "user", "content": req.Prompt},
},
"messages": threadAnthropicMessages(req),
}
if len(anthropicTools) > 0 {
@@ -94,48 +92,65 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
return nil, err
}
// If no tool calls, return response
if len(resp.ToolCalls) == 0 {
// If no tool calls or no handler, return as-is
if len(resp.ToolCalls) == 0 || p.opts.ToolHandler == nil {
return resp, nil
}
// If tool handler is provided, execute tools and get final answer
if p.opts.ToolHandler != nil {
var toolResults []ai.ToolResult
for _, tc := range resp.ToolCalls {
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
toolResults = append(toolResults, ai.ToolResult{
ID: tc.ID,
Content: content,
// Tool execution loop: execute tools, send results back, repeat
// until the model responds with text only (no more tool calls)
messages := append(threadAnthropicMessages(req),
map[string]any{"role": "assistant", "content": cleanContent(rawContent)},
)
pendingCalls := resp.ToolCalls
for rounds := 0; rounds < 10; rounds++ {
var toolResultBlocks []map[string]any
for i := range pendingCalls {
content := p.opts.ToolHandler(ctx, pendingCalls[i]).Content
pendingCalls[i].Result = content
toolResultBlocks = append(toolResultBlocks, map[string]any{
"type": "tool_result",
"tool_use_id": pendingCalls[i].ID,
"content": content,
})
}
// Build follow-up request with tool results
var toolResultBlocks []map[string]any
for _, tr := range toolResults {
toolResultBlocks = append(toolResultBlocks, map[string]any{
"type": "tool_result",
"tool_use_id": tr.ID,
"content": tr.Content,
})
}
messages = append(messages, map[string]any{
"role": "user",
"content": toolResultBlocks,
})
followUpReq := map[string]any{
"model": p.opts.Model,
"max_tokens": 4096,
"max_tokens": anthropicMaxTokens(p.opts),
"system": req.SystemPrompt,
"messages": []map[string]any{
{"role": "user", "content": req.Prompt},
{"role": "assistant", "content": rawContent},
{"role": "user", "content": toolResultBlocks},
},
"messages": messages,
}
if len(anthropicTools) > 0 {
followUpReq["tools"] = anthropicTools
}
// Make follow-up API call
followUpResp, _, err := p.callAPI(ctx, followUpReq)
if err == nil && followUpResp.Reply != "" {
followUpResp, followUpRaw, err := p.callAPI(ctx, followUpReq)
if err != nil {
break
}
if len(followUpResp.ToolCalls) > 0 {
resp.ToolCalls = append(resp.ToolCalls, followUpResp.ToolCalls...)
pendingCalls = followUpResp.ToolCalls
messages = append(messages, map[string]any{
"role": "assistant",
"content": cleanContent(followUpRaw),
})
continue
}
if followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
break
}
return resp, nil
@@ -143,7 +158,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
// Stream generates a streaming response (not yet implemented)
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for anthropic provider")
return nil, fmt.Errorf("%w: anthropic provider", ai.ErrStreamingUnsupported)
}
// callAPI makes an HTTP request to the Anthropic API
@@ -156,7 +171,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
// Build HTTP request
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/messages"
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
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)
}
@@ -175,7 +190,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
// Read response
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != 200 {
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
@@ -225,3 +240,51 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
return response, anthropicResp.Content, nil
}
// cleanContent strips fields from response content blocks that Anthropic
// rejects when sent back as assistant message content (e.g. "id" on text blocks).
func cleanContent(raw any) any {
blocks, ok := raw.([]struct {
Type string `json:"type"`
Text string `json:"text"`
ID string `json:"id"`
Name string `json:"name"`
Input json.RawMessage `json:"input"`
})
if !ok {
return raw
}
var cleaned []map[string]any
for _, b := range blocks {
switch b.Type {
case "text":
cleaned = append(cleaned, map[string]any{"type": "text", "text": b.Text})
case "tool_use":
var input any
_ = json.Unmarshal(b.Input, &input)
cleaned = append(cleaned, map[string]any{"type": "tool_use", "id": b.ID, "name": b.Name, "input": input})
}
}
return cleaned
}
// threadAnthropicMessages builds the Anthropic messages array from the
// conversation history (req.Messages) followed by the current prompt. The
// system prompt is sent separately via the top-level "system" field.
func threadAnthropicMessages(req *ai.Request) []map[string]any {
msgs := make([]map[string]any, 0, len(req.Messages)+1)
for _, m := range req.Messages {
msgs = append(msgs, map[string]any{"role": m.Role, "content": m.Content})
}
if req.Prompt != "" {
msgs = append(msgs, map[string]any{"role": "user", "content": req.Prompt})
}
return msgs
}
func anthropicMaxTokens(o ai.Options) int {
if o.MaxTokens > 0 {
return o.MaxTokens
}
return 8192
}
+4 -3
View File
@@ -2,9 +2,10 @@ package anthropic
import (
"context"
"errors"
"testing"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
@@ -88,7 +89,7 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
}
_, err := p.Stream(context.Background(), req)
if err == nil {
t.Error("Expected error for unimplemented streaming, got nil")
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+125 -13
View File
@@ -7,7 +7,7 @@
//
// Usage:
//
// import _ "go-micro.dev/v5/ai/atlascloud"
// import _ "go-micro.dev/v6/ai/atlascloud"
//
// m := ai.New("atlascloud",
// ai.WithAPIKey("your-api-key"),
@@ -20,6 +20,7 @@
package atlascloud
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -29,7 +30,7 @@ import (
"strings"
"time"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func init() {
@@ -91,13 +92,21 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
{"role": "user", "content": req.Prompt},
}
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,
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
if len(tools) > 0 {
apiReq["tools"] = tools
@@ -120,7 +129,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
})
for _, tc := range resp.ToolCalls {
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
content := p.opts.ToolHandler(ctx, tc).Content
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
@@ -142,8 +151,111 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
return resp, nil
}
// Stream generates a streaming response from Atlas Cloud's OpenAI-compatible
// chat completions endpoint, emitting content deltas as they arrive.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for atlascloud provider")
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,
"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, "/") + "/v1/chat/completions"
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")
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 &atlasStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
type atlasStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *atlasStream) 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
}
// Final chunk (after include_usage) carries token usage and no content.
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
continue
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *atlasStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
@@ -153,7 +265,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
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)
}
@@ -168,7 +280,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != 200 {
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
@@ -259,7 +371,7 @@ func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/generateImage"
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -273,7 +385,7 @@ func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != 200 {
if httpResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
@@ -315,7 +427,7 @@ func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts
}
func (p *Provider) pollPrediction(ctx context.Context, url string) (*ai.ImageResponse, error) {
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
@@ -395,7 +507,7 @@ func (p *Provider) GenerateVideo(ctx context.Context, req *ai.VideoRequest, opts
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/api/v1/model/generateVideo"
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -409,7 +521,7 @@ func (p *Provider) GenerateVideo(ctx context.Context, req *ai.VideoRequest, opts
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != 200 {
if httpResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
@@ -450,7 +562,7 @@ func (p *Provider) GenerateVideo(ctx context.Context, req *ai.VideoRequest, opts
}
func (p *Provider) pollVideo(ctx context.Context, url string) (*ai.VideoResponse, error) {
httpReq, err := http.NewRequestWithContext(ctx, "GET", url, nil)
httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return nil, err
}
+4 -3
View File
@@ -2,9 +2,10 @@ package atlascloud
import (
"context"
"errors"
"testing"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
@@ -88,8 +89,8 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
}
_, err := p.Stream(context.Background(), req)
if err == nil {
t.Error("Expected error for unimplemented streaming, got nil")
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+140
View File
@@ -0,0 +1,140 @@
package ai
import "sort"
// CapabilityRow is one deterministic row in a provider capability matrix.
type CapabilityRow struct {
// Provider is the registered provider name.
Provider string `json:"provider"`
Capabilities
}
// Capabilities describes the AI interfaces a provider has registered.
// It is intentionally based on package registration rather than external
// provider marketing claims, so it reflects what this build can actually use.
type Capabilities struct {
// Model reports whether ai.New can construct a chat/text model provider.
Model bool `json:"model"`
// Image reports whether ai.NewImage can construct an image model provider.
Image bool `json:"image"`
// Video reports whether ai.NewVideo can construct a video model provider.
Video bool `json:"video"`
// Stream reports whether the provider has registered end-to-end token streaming.
// Providers that only satisfy the Model interface with ErrStreamingUnsupported
// leave this false until their Stream implementation is usable.
Stream bool `json:"stream"`
}
// ProviderCapabilities reports the capabilities registered for provider.
func ProviderCapabilities(provider string) Capabilities {
_, hasModel := providers[provider]
_, hasImage := imageProviders[provider]
_, hasVideo := videoProviders[provider]
_, hasStream := streamProviders[provider]
return Capabilities{
Model: hasModel,
Image: hasImage,
Video: hasVideo,
Stream: hasStream,
}
}
// CapabilityMatrix returns a snapshot of all registered AI providers and the
// interfaces they support. The returned map is a copy and can be modified by
// callers without mutating the registry. Use CapabilityRows when rendering a
// deterministic table or report.
func CapabilityMatrix() map[string]Capabilities {
names := map[string]struct{}{}
for name := range providers {
names[name] = struct{}{}
}
for name := range imageProviders {
names[name] = struct{}{}
}
for name := range videoProviders {
names[name] = struct{}{}
}
for name := range streamProviders {
names[name] = struct{}{}
}
matrix := make(map[string]Capabilities, len(names))
for name := range names {
matrix[name] = ProviderCapabilities(name)
}
return matrix
}
// CapabilityRows returns a deterministic capability support matrix for every
// registered AI provider. It is the ordered form of CapabilityMatrix, intended
// for CLIs, docs generators, and conformance reports that need stable output.
func CapabilityRows() []CapabilityRow {
names := RegisteredProviders("")
rows := make([]CapabilityRow, 0, len(names))
for _, name := range names {
rows = append(rows, CapabilityRow{
Provider: name,
Capabilities: ProviderCapabilities(name),
})
}
return rows
}
// RegisterStream records that provider has a usable Stream implementation.
// Providers should call this from init alongside Register once Stream returns
// chunks instead of ErrStreamingUnsupported.
func RegisterStream(provider string) {
streamProviders[provider] = struct{}{}
}
var streamProviders = make(map[string]struct{})
// RegisteredProviders returns the registered provider names in sorted order.
// kind may be "model", "image", "video", "stream", or empty for the union of all
// provider registries.
func RegisteredProviders(kind string) []string {
names := map[string]struct{}{}
add := func(registry any) {
switch r := registry.(type) {
case map[string]NewFunc:
for name := range r {
names[name] = struct{}{}
}
case map[string]NewImageFunc:
for name := range r {
names[name] = struct{}{}
}
case map[string]NewVideoFunc:
for name := range r {
names[name] = struct{}{}
}
case map[string]struct{}:
for name := range r {
names[name] = struct{}{}
}
}
}
switch kind {
case "model":
add(providers)
case "stream":
add(streamProviders)
case "image":
add(imageProviders)
case "video":
add(videoProviders)
default:
add(providers)
add(imageProviders)
add(videoProviders)
}
out := make([]string, 0, len(names))
for name := range names {
out = append(out, name)
}
sort.Strings(out)
return out
}
+95
View File
@@ -0,0 +1,95 @@
package ai_test
import (
"reflect"
"testing"
"go-micro.dev/v6/ai"
_ "go-micro.dev/v6/ai/anthropic"
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
func TestRegisteredProviders(t *testing.T) {
got := ai.RegisteredProviders("")
want := []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders() = %#v, want %#v", got, want)
}
got = ai.RegisteredProviders("image")
want = []string{"atlascloud", "openai"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(image) = %#v, want %#v", got, want)
}
got = ai.RegisteredProviders("video")
want = []string{"atlascloud"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(video) = %#v, want %#v", got, want)
}
got = ai.RegisteredProviders("stream")
want = []string{"openai"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
}
func TestCapabilityRows(t *testing.T) {
got := ai.CapabilityRows()
want := []ai.CapabilityRow{
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true}},
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true}},
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
{Provider: "groq", Capabilities: ai.Capabilities{Model: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true}},
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: true}},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("CapabilityRows() = %#v, want %#v", got, want)
}
}
func TestCapabilityMatrix(t *testing.T) {
matrix := ai.CapabilityMatrix()
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"} {
caps, ok := matrix[provider]
if !ok {
t.Fatalf("CapabilityMatrix missing %q", provider)
}
if !caps.Model {
t.Fatalf("CapabilityMatrix(%s).Model = false, want true", provider)
}
}
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true}) {
t.Fatalf("ProviderCapabilities(openai) = %#v", caps)
}
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true}) {
t.Fatalf("ProviderCapabilities(atlascloud) = %#v", caps)
}
if caps := ai.ProviderCapabilities("missing"); caps != (ai.Capabilities{}) {
t.Fatalf("ProviderCapabilities(missing) = %#v", caps)
}
}
func TestRegisterStream(t *testing.T) {
ai.RegisterStream("test-stream")
if caps := ai.ProviderCapabilities("test-stream"); caps != (ai.Capabilities{Stream: true}) {
t.Fatalf("ProviderCapabilities(test-stream) = %#v", caps)
}
got := ai.RegisteredProviders("stream")
want := []string{"openai", "test-stream"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
}
+22
View File
@@ -0,0 +1,22 @@
// Package flow is maintained for backward compatibility.
// The canonical import is go-micro.dev/v6/flow.
package flow
import "go-micro.dev/v6/flow"
// Re-export types for backward compatibility.
type Flow = flow.Flow
type Options = flow.Options
type Option = flow.Option
type Result = flow.Result
var New = flow.New
var Trigger = flow.Trigger
var Prompt = flow.Prompt
var SystemPrompt = flow.SystemPrompt
var Provider = flow.Provider
var APIKey = flow.APIKey
var Model = flow.Model
var BaseURL = flow.BaseURL
var HistoryLimit = flow.HistoryLimit
var OnResult = flow.OnResult
+6 -6
View File
@@ -2,7 +2,7 @@
//
// Usage:
//
// import _ "go-micro.dev/v5/ai/gemini"
// import _ "go-micro.dev/v6/ai/gemini"
//
// m := ai.New("gemini",
// ai.WithAPIKey("your-api-key"),
@@ -18,7 +18,7 @@ import (
"net/http"
"strings"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func init() {
@@ -101,7 +101,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
if p.opts.ToolHandler != nil {
var resultParts []map[string]any
for _, tc := range resp.ToolCalls {
result, _ := p.opts.ToolHandler(tc.Name, tc.Input)
result := p.opts.ToolHandler(ctx, tc).Value
resultParts = append(resultParts, map[string]any{
"functionResponse": map[string]any{
"name": tc.Name,
@@ -135,7 +135,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for gemini provider")
return nil, fmt.Errorf("%w: gemini provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, []map[string]any, error) {
@@ -147,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
apiURL := strings.TrimRight(p.opts.BaseURL, "/") +
"/v1beta/models/" + p.opts.Model + ":generateContent"
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
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)
}
@@ -162,7 +162,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != 200 {
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
+4 -3
View File
@@ -2,9 +2,10 @@ package gemini
import (
"context"
"errors"
"testing"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
@@ -88,8 +89,8 @@ func TestProvider_Stream_NotImplemented(t *testing.T) {
}
_, err := p.Stream(context.Background(), req)
if err == nil {
t.Error("Expected error for unimplemented streaming, got nil")
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+6 -6
View File
@@ -5,7 +5,7 @@
//
// Usage:
//
// import _ "go-micro.dev/v5/ai/groq"
// import _ "go-micro.dev/v6/ai/groq"
//
// m := ai.New("groq",
// ai.WithAPIKey("your-api-key"),
@@ -21,7 +21,7 @@ import (
"net/http"
"strings"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func init() {
@@ -99,7 +99,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
"tool_calls": rawMessage["tool_calls"],
})
for _, tc := range resp.ToolCalls {
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
content := p.opts.ToolHandler(ctx, tc).Content
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
@@ -119,7 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for groq provider")
return nil, fmt.Errorf("%w: groq provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
@@ -129,7 +129,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
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)
}
@@ -144,7 +144,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != 200 {
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
+4 -3
View File
@@ -2,9 +2,10 @@ package groq
import (
"context"
"errors"
"testing"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
@@ -40,8 +41,8 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
func TestProvider_Stream_NotImplemented(t *testing.T) {
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
t.Error("expected error")
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+28 -75
View File
@@ -1,92 +1,45 @@
package ai
import "context"
// History accumulates conversation messages and feeds them into
// Generate calls. It handles the bookkeeping of adding user prompts,
// assistant replies, and tool call/result pairs so callers don't
// have to manage the message list manually.
// History is a convenience for accumulating conversation messages
// with automatic truncation. Use it to build Request.Messages for
// multi-turn conversations.
//
// When the message count exceeds Limit, the oldest messages (after
// the system prompt) are dropped. Set Limit to 0 for unlimited.
// hist := ai.NewHistory(50)
// hist.Add("user", "hello")
// resp, _ := m.Generate(ctx, &ai.Request{Messages: hist.Messages(), Prompt: "next"})
// hist.Add("assistant", resp.Reply)
type History struct {
messages []Message
systemPrompt string
limit int
messages []Message
limit int
}
// NewHistory creates an empty History. limit controls the maximum
// number of messages retained (0 = unlimited). The system prompt is
// always preserved regardless of truncation.
func NewHistory(systemPrompt string, limit int) *History {
return &History{
systemPrompt: systemPrompt,
limit: limit,
}
// number of messages retained (0 = unlimited).
func NewHistory(limit int) *History {
return &History{limit: limit}
}
// Generate sends a user prompt through the model with the full
// conversation history. The user message, assistant reply, and any
// tool call/result pairs are appended to the history automatically.
func (h *History) Generate(ctx context.Context, m Model, prompt string, tools []Tool) (*Response, error) {
h.add("user", prompt)
resp, err := m.Generate(ctx, &Request{
Prompt: prompt,
SystemPrompt: h.systemPrompt,
Tools: tools,
Messages: h.snapshot(),
})
if err != nil {
return nil, err
}
// Record the assistant's reply.
if resp.Reply != "" {
h.add("assistant", resp.Reply)
}
// Record tool calls and results so subsequent turns have context.
for _, tc := range resp.ToolCalls {
h.add("assistant", tc)
}
if resp.Answer != "" {
h.add("assistant", resp.Answer)
}
return resp, nil
}
// Messages returns a copy of the current message history.
func (h *History) Messages() []Message {
return h.snapshot()
}
// Len returns the number of messages in the history.
func (h *History) Len() int {
return len(h.messages)
}
// Reset clears all messages but keeps the system prompt and limit.
func (h *History) Reset() {
h.messages = nil
}
func (h *History) add(role string, content any) {
// Add appends a message and truncates if over limit.
func (h *History) Add(role string, content any) {
h.messages = append(h.messages, Message{Role: role, Content: content})
h.truncate()
}
func (h *History) truncate() {
if h.limit <= 0 || len(h.messages) <= h.limit {
return
if h.limit > 0 && len(h.messages) > h.limit {
h.messages = h.messages[len(h.messages)-h.limit:]
}
drop := len(h.messages) - h.limit
h.messages = h.messages[drop:]
}
func (h *History) snapshot() []Message {
// Messages returns a copy of the accumulated messages.
func (h *History) Messages() []Message {
out := make([]Message, len(h.messages))
copy(out, h.messages)
return out
}
// Len returns the number of messages.
func (h *History) Len() int {
return len(h.messages)
}
// Reset clears all messages.
func (h *History) Reset() {
h.messages = nil
}
+27 -101
View File
@@ -1,90 +1,40 @@
package ai
import (
"context"
"testing"
)
import "testing"
type mockModel struct {
reply string
calls int
}
func TestHistory_Add(t *testing.T) {
h := NewHistory(0)
h.Add("user", "hello")
h.Add("assistant", "hi")
func (m *mockModel) Init(...Option) error { return nil }
func (m *mockModel) Options() Options { return Options{} }
func (m *mockModel) String() string { return "mock" }
func (m *mockModel) Stream(context.Context, *Request, ...GenerateOption) (Stream, error) {
return nil, nil
}
func (m *mockModel) Generate(_ context.Context, req *Request, _ ...GenerateOption) (*Response, error) {
m.calls++
return &Response{Reply: m.reply}, nil
}
func TestHistory_AccumulatesMessages(t *testing.T) {
m := &mockModel{reply: "hi"}
h := NewHistory("system", 0)
resp, err := h.Generate(context.Background(), m, "hello", nil)
if err != nil {
t.Fatal(err)
}
if resp.Reply != "hi" {
t.Errorf("reply = %q", resp.Reply)
}
// user + assistant = 2
if h.Len() != 2 {
t.Errorf("len = %d, want 2", h.Len())
}
h.Generate(context.Background(), m, "again", nil)
// 2 + user + assistant = 4
if h.Len() != 4 {
t.Errorf("len = %d, want 4", h.Len())
}
msgs := h.Messages()
if msgs[0].Role != "user" || msgs[0].Content != "hello" {
t.Errorf("first message = %+v", msgs[0])
t.Errorf("first = %+v", msgs[0])
}
if msgs[1].Role != "assistant" || msgs[1].Content != "hi" {
t.Errorf("second message = %+v", msgs[1])
}
if msgs[2].Role != "user" || msgs[2].Content != "again" {
t.Errorf("third message = %+v", msgs[2])
t.Errorf("second = %+v", msgs[1])
}
}
func TestHistory_Truncation(t *testing.T) {
m := &mockModel{reply: "ok"}
h := NewHistory("system", 4)
h.Generate(context.Background(), m, "one", nil)
h.Generate(context.Background(), m, "two", nil)
h.Generate(context.Background(), m, "three", nil)
// 3 turns x 2 messages = 6, but limit is 4
if h.Len() != 4 {
t.Errorf("len = %d, want 4", h.Len())
h := NewHistory(3)
for _, m := range []string{"a", "b", "c", "d", "e"} {
h.Add("user", m)
}
msgs := h.Messages()
// oldest messages should have been dropped
if msgs[0].Role != "user" || msgs[0].Content != "two" {
t.Errorf("first retained message = %+v, want user/two", msgs[0])
if h.Len() != 3 {
t.Errorf("len = %d, want 3", h.Len())
}
if h.Messages()[0].Content != "c" {
t.Errorf("first retained = %+v", h.Messages()[0])
}
}
func TestHistory_Reset(t *testing.T) {
m := &mockModel{reply: "ok"}
h := NewHistory("system", 0)
h.Generate(context.Background(), m, "hello", nil)
if h.Len() == 0 {
t.Fatal("expected messages")
}
h := NewHistory(0)
h.Add("user", "hello")
h.Reset()
if h.Len() != 0 {
t.Errorf("len after reset = %d", h.Len())
@@ -92,45 +42,21 @@ func TestHistory_Reset(t *testing.T) {
}
func TestHistory_SnapshotIsCopy(t *testing.T) {
m := &mockModel{reply: "ok"}
h := NewHistory("system", 0)
h.Generate(context.Background(), m, "hello", nil)
h := NewHistory(0)
h.Add("user", "hello")
msgs := h.Messages()
msgs[0].Content = "mutated"
if h.Messages()[0].Content == "mutated" {
t.Error("Messages() returned a reference, not a copy")
t.Error("snapshot returned reference, not copy")
}
}
func TestHistory_ToolCallsRecorded(t *testing.T) {
m := &mockModel{}
m2 := &toolModel{}
h := NewHistory("system", 0)
h.Generate(context.Background(), m2, "do something", nil)
// user + assistant (tool call) + assistant (answer) = 3
if h.Len() != 3 {
t.Errorf("len = %d, want 3", h.Len())
func TestHistory_Unlimited(t *testing.T) {
h := NewHistory(0)
for i := 0; i < 100; i++ {
h.Add("user", "msg")
}
if h.Len() != 100 {
t.Errorf("len = %d, want 100", h.Len())
}
_ = m // avoid unused
}
type toolModel struct{}
func (m *toolModel) Init(...Option) error { return nil }
func (m *toolModel) Options() Options { return Options{} }
func (m *toolModel) String() string { return "tool-mock" }
func (m *toolModel) Stream(context.Context, *Request, ...GenerateOption) (Stream, error) {
return nil, nil
}
func (m *toolModel) Generate(_ context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
return &Response{
ToolCalls: []ToolCall{{ID: "1", Name: "test", Input: map[string]any{}}},
Answer: "done",
}, nil
}
+6 -6
View File
@@ -5,7 +5,7 @@
//
// Usage:
//
// import _ "go-micro.dev/v5/ai/mistral"
// import _ "go-micro.dev/v6/ai/mistral"
//
// m := ai.New("mistral",
// ai.WithAPIKey("your-api-key"),
@@ -21,7 +21,7 @@ import (
"net/http"
"strings"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func init() {
@@ -99,7 +99,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
"tool_calls": rawMessage["tool_calls"],
})
for _, tc := range resp.ToolCalls {
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
content := p.opts.ToolHandler(ctx, tc).Content
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
@@ -119,7 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for mistral provider")
return nil, fmt.Errorf("%w: mistral provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
@@ -129,7 +129,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
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)
}
@@ -144,7 +144,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != 200 {
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
+4 -3
View File
@@ -2,9 +2,10 @@ package mistral
import (
"context"
"errors"
"testing"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
@@ -40,8 +41,8 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
func TestProvider_Stream_NotImplemented(t *testing.T) {
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
t.Error("expected error")
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+98 -10
View File
@@ -3,6 +3,8 @@ package ai
import (
"context"
"encoding/json"
"errors"
"strings"
)
@@ -36,7 +38,8 @@ type Request struct {
SystemPrompt string
// Tools available for the model to use
Tools []Tool
// Messages for continuing a conversation (optional)
// Messages for continuing a conversation (optional).
// Use ai.History to accumulate these across turns.
Messages []Message
}
@@ -46,6 +49,13 @@ type Message struct {
Content any // Can be string or structured content
}
// Usage describes token counts returned by model providers.
type Usage struct {
InputTokens int `json:"input_tokens,omitempty"`
OutputTokens int `json:"output_tokens,omitempty"`
TotalTokens int `json:"total_tokens,omitempty"`
}
// Response represents the response from a model
type Response struct {
// Reply is the text response from the model
@@ -54,22 +64,91 @@ type Response struct {
ToolCalls []ToolCall
// Answer is the final answer after tool execution (if tools were used)
Answer string
// Usage contains provider token usage when available.
Usage Usage
}
// ToolCall represents a request to call a tool
// ToolCall represents a request to call a tool and its result
type ToolCall struct {
ID string // Tool call ID (for correlation)
Name string // Tool name
Input map[string]any // Tool input arguments
ID string // Tool call ID (for correlation)
Name string // Tool name
Input map[string]any // Tool input arguments
Result string // Tool execution result (populated after execution)
Error string // Tool execution error (populated after execution)
}
// Scan decodes the call's Input into v (a pointer to a struct or map),
// the same way a codec decodes an RPC request body. Use it when a tool
// wants typed arguments instead of the raw map:
//
// var args struct{ Query string `json:"query"` }
// if err := call.Scan(&args); err != nil { ... }
func (c ToolCall) Scan(v any) error {
b, err := json.Marshal(c.Input)
if err != nil {
return err
}
return json.Unmarshal(b, v)
}
// ToolResult represents the result of a tool execution
type ToolResult struct {
ID string // Tool call ID (for correlation)
Content string // Tool execution result (JSON string)
Value any // Structured result (optional)
Content string // Tool execution result (JSON string), shown to the model
// 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
// a detected loop, audit refusals — without parsing the message.
Refused string `json:"refused,omitempty"`
}
// Stream is the interface for streaming responses (future implementation)
// Refusal reason codes set on ToolResult.Refused by the agent's guardrails.
const (
RefusedMaxSteps = "max_steps"
RefusedLoop = "loop"
RefusedApproval = "approval"
)
// RunInfo describes the agent run a tool call belongs to. The agent
// attaches it to the context passed to a ToolHandler, so a wrapper can
// correlate calls within a run and across delegation without coupling to
// the agent package. Flows also attach their name and current step so
// tools and agents called from a workflow can be tied back to the
// services → agents → workflows lifecycle that invoked them. Per-call
// detail (tool name, id) is on the ToolCall. Attempt and MaxAttempts are
// set while a model Generate call is in progress, so tools and wrappers can
// 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
}
type runInfoKey struct{}
// WithRunInfo attaches run info to ctx.
func WithRunInfo(ctx context.Context, r RunInfo) context.Context {
return context.WithValue(ctx, runInfoKey{}, r)
}
// RunInfoFrom returns the run info attached to ctx, and whether it was set.
func RunInfoFrom(ctx context.Context) (RunInfo, bool) {
r, ok := ctx.Value(runInfoKey{}).(RunInfo)
return r, ok
}
// ErrStreamingUnsupported is returned by providers that implement the Model
// interface but do not yet support token streaming. Use errors.Is so callers
// can distinguish an unsupported capability from transient provider failures.
var ErrStreamingUnsupported = errors.New("ai: streaming unsupported")
// Stream is the interface for streaming responses.
type Stream interface {
// Recv receives the next chunk of the response
Recv() (*Response, error)
@@ -77,8 +156,17 @@ type Stream interface {
Close() error
}
// ToolHandler is a function that handles tool calls
type ToolHandler func(name string, input map[string]any) (result any, content string)
// ToolHandler executes a tool call and returns its result. It mirrors a
// go-micro RPC handler — context first, a request in, a result out — so
// the same mental model carries over from services to tools.
type ToolHandler func(ctx context.Context, call ToolCall) ToolResult
// ToolWrapper wraps a ToolHandler to add behavior around execution —
// logging, metrics, retries, guardrails. It is the tool-side analog of
// client.CallWrapper and server.HandlerWrapper: a wrapper takes the next
// handler and returns a new one, and code before the next(...) call runs
// before the tool, code after runs after.
type ToolWrapper func(ToolHandler) ToolHandler
// NewFunc creates a new Model instance
type NewFunc func(...Option) Model
@@ -132,7 +220,7 @@ func AutoDetectProvider(baseURL string) string {
// DefaultModel is a default model instance
var DefaultModel Model
// Generate generates a response using the default model
// Generate generates a response using the default model.
func Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
if DefaultModel == nil {
return nil, nil
+126 -9
View File
@@ -2,6 +2,7 @@
package openai
import (
"bufio"
"bytes"
"context"
"encoding/json"
@@ -10,7 +11,7 @@ import (
"net/http"
"strings"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func init() {
@@ -20,6 +21,7 @@ func init() {
ai.RegisterImage("openai", func(opts ...ai.Option) ai.ImageModel {
return NewProvider(opts...)
})
ai.RegisterStream("openai")
}
// Provider implements the ai.Model interface for OpenAI
@@ -83,7 +85,12 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
// Build messages
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
{"role": "user", "content": req.Prompt},
}
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})
}
// Build initial request
@@ -91,6 +98,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
"model": p.opts.Model,
"messages": messages,
}
if p.opts.MaxTokens > 0 {
apiReq["max_tokens"] = p.opts.MaxTokens
}
if len(openaiTools) > 0 {
apiReq["tools"] = openaiTools
@@ -117,7 +127,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
})
for _, tc := range resp.ToolCalls {
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
content := p.opts.ToolHandler(ctx, tc).Content
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
@@ -140,9 +150,110 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
return resp, nil
}
// Stream generates a streaming response (not yet implemented)
// Stream generates a streaming response from the OpenAI chat completions API.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for openai provider")
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,
"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, "/") + "/v1/chat/completions"
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")
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 &openAIStream{body: httpResp.Body, scanner: bufio.NewScanner(httpResp.Body)}, nil
}
type openAIStream struct {
body io.ReadCloser
scanner *bufio.Scanner
closed bool
}
func (s *openAIStream) 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
}
// Final chunk (after include_usage) carries token usage and no content.
if chunk.Usage != nil {
return &ai.Response{Usage: ai.Usage{
InputTokens: chunk.Usage.PromptTokens,
OutputTokens: chunk.Usage.CompletionTokens,
TotalTokens: chunk.Usage.TotalTokens,
}}, nil
}
continue
}
if err := s.scanner.Err(); err != nil {
return nil, err
}
return nil, io.EOF
}
func (s *openAIStream) Close() error {
if s.closed {
return nil
}
s.closed = true
return s.body.Close()
}
// callAPI makes an HTTP request to the OpenAI API
@@ -155,7 +266,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
// Build HTTP request
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
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)
}
@@ -173,12 +284,17 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
// Read response
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != 200 {
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
// Parse response
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 {
Content string `json:"content"`
@@ -204,6 +320,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
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},
}
// Extract tool calls
@@ -255,7 +372,7 @@ func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/images/generations"
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
@@ -270,7 +387,7 @@ func (p *Provider) GenerateImage(ctx context.Context, req *ai.ImageRequest, opts
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != 200 {
if httpResp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
+94 -8
View File
@@ -2,9 +2,15 @@ package openai
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"time"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
@@ -80,16 +86,96 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
}
func TestProvider_Stream_NotImplemented(t *testing.T) {
p := NewProvider()
func TestProvider_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
req := &ai.Request{
Prompt: "Hello",
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
_, err := p.Stream(context.Background(), req)
if err == nil {
t.Error("Expected error for unimplemented streaming, got nil")
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
func TestProvider_StreamPropagatesMalformedChunk(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {bad json}\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if _, err := stream.Recv(); err == nil {
t.Fatal("Recv returned nil error for malformed chunk")
}
}
func TestProvider_StreamCloseReleasesResponse(t *testing.T) {
released := make(chan struct{})
ts := 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\":\"hel\"}}]}\n\n"))
if f, ok := w.(http.Flusher); ok {
f.Flush()
}
<-r.Context().Done()
close(released)
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
if err := stream.Close(); err != nil {
t.Fatalf("Close returned error: %v", err)
}
select {
case <-released:
case <-time.After(time.Second):
t.Fatal("server did not observe closed stream request")
}
}
+26
View File
@@ -16,6 +16,8 @@ type Options struct {
BaseURL string
// ToolHandler handles tool calls (optional, for automatic tool execution)
ToolHandler ToolHandler
// MaxTokens caps the length of the response (0 = provider default)
MaxTokens int
}
// GenerateOptions for generate call
@@ -75,3 +77,27 @@ func WithToolHandler(handler ToolHandler) Option {
o.ToolHandler = handler
}
}
// WithTools wires a Tools instance into the model, setting the tool
// handler so the model can execute discovered service endpoints. The
// tool list itself is passed per-request via Request.Tools.
//
// tools := ai.NewTools(service.Registry())
// list, _ := tools.Discover()
// m := ai.New("anthropic", ai.WithAPIKey(key), ai.WithTools(tools))
// resp, _ := m.Generate(ctx, &ai.Request{Prompt: input, Tools: list})
func WithTools(t *Tools) Option {
return func(o *Options) {
if t != nil {
o.ToolHandler = t.Handler()
}
}
}
// WithMaxTokens caps the number of tokens in the response. 0 leaves the
// provider default in place.
func WithMaxTokens(n int) Option {
return func(o *Options) {
o.MaxTokens = n
}
}
+189
View File
@@ -0,0 +1,189 @@
package ai
import (
"context"
"errors"
"fmt"
"strings"
"time"
)
// StatusCoder is implemented by provider errors that expose an HTTP-like status code.
type StatusCoder interface {
StatusCode() int
}
// ErrorKind classifies provider-boundary failures into stable buckets callers
// can inspect without parsing provider-specific error strings.
type ErrorKind string
const (
ErrorKindUnknown ErrorKind = "unknown"
ErrorKindCanceled ErrorKind = "canceled"
ErrorKindTimeout ErrorKind = "timeout"
ErrorKindRateLimited ErrorKind = "rate_limited"
ErrorKindUnavailable ErrorKind = "unavailable"
ErrorKindProvider ErrorKind = "provider"
)
// ClassifiedError is implemented by errors that expose a stable ErrorKind.
type ClassifiedError interface {
ErrorKind() ErrorKind
}
// RetryError is returned when Generate is retried and still fails.
type RetryError struct {
Attempts int
Kind ErrorKind
Err error
}
func (e *RetryError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("ai generate failed after %d attempt(s) (%s): %v", e.Attempts, e.ErrorKind(), e.Err)
}
func (e *RetryError) Unwrap() error {
if e == nil {
return nil
}
return e.Err
}
func (e *RetryError) ErrorKind() ErrorKind {
if e == nil || e.Kind == "" {
return ErrorKindUnknown
}
return e.Kind
}
// GeneratePolicy controls timeout and retry behavior for a model call.
type GeneratePolicy struct {
Timeout time.Duration
MaxAttempts int
Backoff time.Duration
}
// GenerateWithRetry calls m.Generate with per-attempt timeout and bounded retry.
func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy GeneratePolicy, opts ...GenerateOption) (*Response, error) {
if policy.MaxAttempts <= 0 {
policy.MaxAttempts = 1
}
if m == nil {
return nil, errors.New("ai model is nil")
}
var last error
for attempt := 1; attempt <= policy.MaxAttempts; attempt++ {
if err := ctx.Err(); err != nil {
return nil, err
}
callCtx := ctx
cancel := func() {}
if policy.Timeout > 0 {
callCtx, cancel = context.WithTimeout(ctx, policy.Timeout)
}
if info, ok := RunInfoFrom(callCtx); ok {
info.Attempt = attempt
info.MaxAttempts = policy.MaxAttempts
callCtx = WithRunInfo(callCtx, info)
}
resp, err := m.Generate(callCtx, req, opts...)
cancel()
if err == nil {
return resp, nil
}
last = err
// Caller cancellation/deadline always wins and is not retried.
if ctx.Err() != nil {
return nil, ctx.Err()
}
transient := IsTransientError(err)
if attempt == policy.MaxAttempts || !transient {
if attempt > 1 || transient {
return nil, &RetryError{Attempts: attempt, Kind: ClassifyError(err), Err: err}
}
return nil, err
}
// Always back off between retries — exponential and capped — so an
// opt-in retry can never become a tight loop hammering the provider,
// even if Backoff was left at zero.
backoff := policy.Backoff
if backoff <= 0 {
backoff = 200 * time.Millisecond
}
if shift := attempt - 1; shift > 0 {
backoff <<= shift
}
if backoff > 30*time.Second {
backoff = 30 * time.Second
}
t := time.NewTimer(backoff)
select {
case <-ctx.Done():
if !t.Stop() {
<-t.C
}
return nil, ctx.Err()
case <-t.C:
}
}
return nil, &RetryError{Attempts: policy.MaxAttempts, Kind: ClassifyError(last), Err: last}
}
// ClassifyError maps provider and context failures to stable operational kinds.
func ClassifyError(err error) ErrorKind {
if err == nil {
return ""
}
var classified ClassifiedError
if errors.As(err, &classified) {
if kind := classified.ErrorKind(); kind != "" {
return kind
}
}
if errors.Is(err, context.Canceled) {
return ErrorKindCanceled
}
if errors.Is(err, context.DeadlineExceeded) {
return ErrorKindTimeout
}
var sc StatusCoder
if errors.As(err, &sc) {
code := sc.StatusCode()
switch {
case code == 429:
return ErrorKindRateLimited
case code >= 500:
return ErrorKindUnavailable
case code > 0:
return ErrorKindProvider
}
}
msg := strings.ToLower(err.Error())
switch {
case strings.Contains(msg, "rate limit") || strings.Contains(msg, "too many requests"):
return ErrorKindRateLimited
case strings.Contains(msg, "timeout") || strings.Contains(msg, "deadline"):
return ErrorKindTimeout
case strings.Contains(msg, "temporar") || strings.Contains(msg, "unavailable"):
return ErrorKindUnavailable
default:
return ErrorKindUnknown
}
}
// IsTransientError reports whether err is worth retrying at the provider boundary.
func IsTransientError(err error) bool {
switch ClassifyError(err) {
case ErrorKindTimeout, ErrorKindRateLimited, ErrorKindUnavailable:
return true
default:
return false
}
}
+183
View File
@@ -0,0 +1,183 @@
package ai
import (
"context"
"errors"
"testing"
"time"
)
type retryModel struct {
generate func(context.Context, *Request, ...GenerateOption) (*Response, error)
}
func (m retryModel) Init(...Option) error { return nil }
func (m retryModel) Options() Options { return Options{} }
func (m retryModel) Generate(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
return m.generate(ctx, req, opts...)
}
func (m retryModel) Stream(context.Context, *Request, ...GenerateOption) (Stream, error) {
return nil, ErrStreamingUnsupported
}
func (m retryModel) String() string { return "retry-test" }
func TestGenerateWithRetryRetriesTransientErrors(t *testing.T) {
attempts := 0
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts++
if attempts == 1 {
return nil, errors.New("temporary provider outage")
}
return &Response{Reply: "ok"}, nil
}}
resp, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 2,
Backoff: time.Millisecond,
})
if err != nil {
t.Fatalf("GenerateWithRetry returned error: %v", err)
}
if resp.Reply != "ok" {
t.Fatalf("response reply = %q, want ok", resp.Reply)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
}
func TestGenerateWithRetryDoesNotRetryCallerCancellation(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
attempts := 0
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts++
cancel()
return nil, errors.New("temporary provider outage")
}}
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 3,
Backoff: time.Millisecond,
})
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want 1", attempts)
}
}
func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
attempts := 0
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
attempts++
<-ctx.Done()
return nil, ctx.Err()
}}
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
Timeout: time.Millisecond,
MaxAttempts: 2,
Backoff: time.Millisecond,
})
var retryErr *RetryError
if !errors.As(err, &retryErr) {
t.Fatalf("error = %T %[1]v, want RetryError", err)
}
if retryErr.Attempts != 2 {
t.Fatalf("retry attempts = %d, want 2", retryErr.Attempts)
}
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error = %v, want context.DeadlineExceeded", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
}
func TestGenerateWithRetryAddsAttemptMetadataToRunInfo(t *testing.T) {
var got []RunInfo
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
info, ok := RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from attempt context")
}
got = append(got, info)
if info.Attempt == 1 {
return nil, errors.New("temporary provider outage")
}
return &Response{Reply: "ok"}, nil
}}
ctx := WithRunInfo(context.Background(), RunInfo{RunID: "run-1", Agent: "worker"})
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 2,
Backoff: time.Millisecond,
})
if err != nil {
t.Fatalf("GenerateWithRetry returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("attempt contexts = %d, want 2", len(got))
}
for i, info := range got {
wantAttempt := i + 1
if info.Attempt != wantAttempt {
t.Fatalf("attempt %d RunInfo.Attempt = %d, want %d", i, info.Attempt, wantAttempt)
}
if info.MaxAttempts != 2 {
t.Fatalf("attempt %d RunInfo.MaxAttempts = %d, want 2", i, info.MaxAttempts)
}
if info.RunID != "run-1" || info.Agent != "worker" {
t.Fatalf("attempt %d RunInfo identity = (%q, %q), want (run-1, worker)", i, info.RunID, info.Agent)
}
}
}
type statusErr int
func (e statusErr) Error() string { return "provider status" }
func (e statusErr) StatusCode() int { return int(e) }
func TestClassifyErrorDistinguishesOperationalOutcomes(t *testing.T) {
tests := []struct {
name string
err error
want ErrorKind
}{
{name: "canceled", err: context.Canceled, want: ErrorKindCanceled},
{name: "timeout", err: context.DeadlineExceeded, want: ErrorKindTimeout},
{name: "rate limit status", err: statusErr(429), want: ErrorKindRateLimited},
{name: "unavailable status", err: statusErr(503), want: ErrorKindUnavailable},
{name: "provider status", err: statusErr(400), want: ErrorKindProvider},
{name: "rate limit text", err: errors.New("rate limit exceeded"), want: ErrorKindRateLimited},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
if got := ClassifyError(tt.err); got != tt.want {
t.Fatalf("ClassifyError() = %q, want %q", got, tt.want)
}
})
}
}
func TestGenerateWithRetryExposesRetryErrorKind(t *testing.T) {
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
return nil, statusErr(429)
}}
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 2,
Backoff: time.Millisecond,
})
var retryErr *RetryError
if !errors.As(err, &retryErr) {
t.Fatalf("error = %T %[1]v, want RetryError", err)
}
if retryErr.ErrorKind() != ErrorKindRateLimited {
t.Fatalf("retry kind = %q, want %q", retryErr.ErrorKind(), ErrorKindRateLimited)
}
if !errors.Is(err, statusErr(429)) {
t.Fatalf("retry error does not unwrap provider status: %v", err)
}
}
+6 -6
View File
@@ -5,7 +5,7 @@
//
// Usage:
//
// import _ "go-micro.dev/v5/ai/together"
// import _ "go-micro.dev/v6/ai/together"
//
// m := ai.New("together",
// ai.WithAPIKey("your-api-key"),
@@ -21,7 +21,7 @@ import (
"net/http"
"strings"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func init() {
@@ -99,7 +99,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
"tool_calls": rawMessage["tool_calls"],
})
for _, tc := range resp.ToolCalls {
_, content := p.opts.ToolHandler(tc.Name, tc.Input)
content := p.opts.ToolHandler(ctx, tc).Content
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
@@ -119,7 +119,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("streaming not yet implemented for together provider")
return nil, fmt.Errorf("%w: together provider", ai.ErrStreamingUnsupported)
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
@@ -129,7 +129,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, "POST", apiURL, bytes.NewReader(reqBody))
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)
}
@@ -144,7 +144,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != 200 {
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
+4 -3
View File
@@ -2,9 +2,10 @@ package together
import (
"context"
"errors"
"testing"
"go-micro.dev/v5/ai"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
@@ -40,8 +41,8 @@ func TestProvider_Generate_NoAPIKey(t *testing.T) {
}
func TestProvider_Stream_NotImplemented(t *testing.T) {
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
t.Error("expected error")
if _, err := NewProvider().Stream(context.Background(), &ai.Request{Prompt: "hi"}); !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
}
+185
View File
@@ -0,0 +1,185 @@
package ai
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"go-micro.dev/v6/client"
codecBytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/registry"
)
type toolNameMap struct {
mu sync.RWMutex
m map[string]string
}
func (n *toolNameMap) put(safe, original string) {
n.mu.Lock()
n.m[safe] = original
n.mu.Unlock()
}
func (n *toolNameMap) get(safe string) (string, bool) {
n.mu.RLock()
v, ok := n.m[safe]
n.mu.RUnlock()
return v, ok
}
// Tools discovers go-micro services from a registry and converts their
// endpoints into Tool definitions. It also executes tool calls via RPC.
//
// Create with NewTools, discover the tool list with Discover, and wire
// execution into a model with WithTools:
//
// tools := ai.NewTools(service.Registry())
// list, _ := tools.Discover()
// m := ai.New("anthropic", ai.WithAPIKey(key), ai.WithTools(tools))
// resp, _ := m.Generate(ctx, &ai.Request{Prompt: input, Tools: list})
type Tools struct {
registry registry.Registry
client client.Client
names *toolNameMap
}
// ToolOption configures a Tools instance.
type ToolOption func(*Tools)
// ToolClient sets the client used to execute tool calls. Defaults to
// client.DefaultClient.
func ToolClient(c client.Client) ToolOption {
return func(t *Tools) {
if c != nil {
t.client = c
}
}
}
// NewTools creates a Tools bound to the given registry.
func NewTools(reg registry.Registry, opts ...ToolOption) *Tools {
t := &Tools{
registry: reg,
client: client.DefaultClient,
names: &toolNameMap{m: map[string]string{}},
}
for _, o := range opts {
o(t)
}
return t
}
// Discover walks the registry and returns one Tool per service
// endpoint. Tool names are LLM-safe (dots replaced with underscores).
func (t *Tools) Discover() ([]Tool, error) {
services, err := t.registry.ListServices()
if err != nil {
return nil, err
}
var out []Tool
for _, svc := range services {
full, err := t.registry.GetService(svc.Name)
if err != nil || len(full) == 0 {
continue
}
for _, ep := range full[0].Endpoints {
original := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
safe := strings.ReplaceAll(original, ".", "_")
t.names.put(safe, original)
desc := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
if ep.Metadata != nil {
if d, ok := ep.Metadata["description"]; ok && d != "" {
desc = d
}
}
props := map[string]any{}
if ep.Request != nil {
for _, field := range ep.Request.Values {
props[field.Name] = map[string]any{
"type": toolJSONType(field.Type),
"description": fmt.Sprintf("%s (%s)", field.Name, field.Type),
}
}
}
out = append(out, Tool{
Name: safe,
OriginalName: original,
Description: desc,
Properties: props,
})
}
}
return out, nil
}
// Handler returns a ToolHandler that executes tool calls via RPC using
// the configured client. Tool names may be LLM-safe (underscored) or
// original (dotted). WithTools uses this internally.
func (t *Tools) Handler() ToolHandler {
c := t.client
if c == nil {
c = client.DefaultClient
}
return func(ctx context.Context, call ToolCall) ToolResult {
name := call.Name
if orig, ok := t.names.get(name); ok {
name = orig
}
parts := strings.SplitN(name, ".", 2)
if len(parts) != 2 {
return toolErrResult(call.ID, "invalid tool name: "+name)
}
inputBytes, err := json.Marshal(call.Input)
if err != nil {
return toolErrResult(call.ID, "failed to marshal input: "+err.Error())
}
req := c.NewRequest(parts[0], parts[1], &codecBytes.Frame{Data: inputBytes})
var rsp codecBytes.Frame
if err := c.Call(ctx, req, &rsp); err != nil {
return toolErrResult(call.ID, err.Error())
}
var result any
if err := json.Unmarshal(rsp.Data, &result); err != nil {
result = string(rsp.Data)
}
return ToolResult{ID: call.ID, Value: result, Content: string(rsp.Data)}
}
}
// DiscoverTools is a convenience that discovers tools from a registry
// without creating a Tools instance. For paired discovery + execution,
// create a Tools with NewTools instead.
func DiscoverTools(reg registry.Registry) ([]Tool, error) {
return NewTools(reg).Discover()
}
func toolErrResult(id, msg string) ToolResult {
encoded, _ := json.Marshal(map[string]string{"error": msg})
return ToolResult{ID: id, Value: map[string]string{"error": msg}, Content: string(encoded)}
}
func toolJSONType(goType string) string {
switch goType {
case "string":
return "string"
case "int", "int32", "int64", "uint", "uint32", "uint64":
return "integer"
case "float32", "float64":
return "number"
case "bool":
return "boolean"
default:
return "object"
}
}
-197
View File
@@ -1,197 +0,0 @@
// Package tools turns go-micro services into ai.Tool definitions and
// provides an ai.ToolHandler that executes tool calls by issuing RPCs
// to the corresponding service.
//
// This is the building block that lets any go-micro service reason
// about and call other services through an LLM:
//
// m := ai.New("anthropic",
// ai.WithAPIKey(key),
// ai.WithToolHandler(tools.Handler(service.Client())),
// )
// resp, _ := m.Generate(ctx, &ai.Request{
// Prompt: userInput,
// Tools: tools.FromRegistry(service.Registry()),
// })
package tools
import (
"context"
"encoding/json"
"fmt"
"strings"
"sync"
"go-micro.dev/v5/ai"
"go-micro.dev/v5/client"
codecBytes "go-micro.dev/v5/codec/bytes"
"go-micro.dev/v5/registry"
)
// nameMap holds the mapping between LLM-safe tool names (no dots) and the
// original `service.Endpoint` names used by the registry/client. Many
// providers reject dots in tool names, so we substitute underscores when
// presenting the tool and restore the original when executing.
type nameMap struct {
mu sync.RWMutex
m map[string]string
}
func (n *nameMap) put(safe, original string) {
n.mu.Lock()
n.m[safe] = original
n.mu.Unlock()
}
func (n *nameMap) get(safe string) (string, bool) {
n.mu.RLock()
v, ok := n.m[safe]
n.mu.RUnlock()
return v, ok
}
// Set is the shared discovery state between FromRegistry and Handler.
// Use New, then Discover + Handler together when you want the handler
// to recognise LLM-safe tool names that were emitted by Discover.
//
// FromRegistry+Handler are convenience wrappers that create their own
// internal Set; Set is exposed for callers that want to control the
// lifecycle (e.g. cache the tool list and reuse it across turns).
type Set struct {
registry registry.Registry
names *nameMap
}
// New creates an empty Set bound to the given registry. Call Discover
// to populate it. The registry is only used by Discover; Handler does
// not need it.
func New(reg registry.Registry) *Set {
return &Set{
registry: reg,
names: &nameMap{m: map[string]string{}},
}
}
// Discover walks the registry and returns one ai.Tool per service
// endpoint. The returned tools have LLM-safe names (dots replaced with
// underscores); the Set remembers the mapping so Handler can route
// calls back to the right service.
func (s *Set) Discover() ([]ai.Tool, error) {
services, err := s.registry.ListServices()
if err != nil {
return nil, err
}
var tools []ai.Tool
for _, svc := range services {
full, err := s.registry.GetService(svc.Name)
if err != nil || len(full) == 0 {
continue
}
for _, ep := range full[0].Endpoints {
original := fmt.Sprintf("%s.%s", svc.Name, ep.Name)
safe := strings.ReplaceAll(original, ".", "_")
s.names.put(safe, original)
desc := fmt.Sprintf("Call %s on %s service", ep.Name, svc.Name)
if ep.Metadata != nil {
if d, ok := ep.Metadata["description"]; ok && d != "" {
desc = d
}
}
props := map[string]any{}
if ep.Request != nil {
for _, field := range ep.Request.Values {
props[field.Name] = map[string]any{
"type": jsonType(field.Type),
"description": fmt.Sprintf("%s (%s)", field.Name, field.Type),
}
}
}
tools = append(tools, ai.Tool{
Name: safe,
OriginalName: original,
Description: desc,
Properties: props,
})
}
}
return tools, nil
}
// Handler returns an ai.ToolHandler that executes tool calls against
// the given client. Tool names may be the LLM-safe form (with
// underscores) emitted by Discover or the original dotted form; both
// resolve to the same RPC.
func (s *Set) Handler(c client.Client) ai.ToolHandler {
if c == nil {
c = client.DefaultClient
}
return func(name string, input map[string]any) (any, string) {
if orig, ok := s.names.get(name); ok {
name = orig
}
parts := strings.SplitN(name, ".", 2)
if len(parts) != 2 {
return errResult("invalid tool name: " + name)
}
inputBytes, err := json.Marshal(input)
if err != nil {
return errResult("failed to marshal input: " + err.Error())
}
req := c.NewRequest(parts[0], parts[1], &codecBytes.Frame{Data: inputBytes})
var rsp codecBytes.Frame
if err := c.Call(context.Background(), req, &rsp); err != nil {
return errResult(err.Error())
}
var result any
if err := json.Unmarshal(rsp.Data, &result); err != nil {
result = string(rsp.Data)
}
return result, string(rsp.Data)
}
}
// FromRegistry is a convenience that builds a one-shot Set, discovers
// tools, and returns just the tool list. Use NewSet directly if you
// need to also wire up Handler against the same name mapping.
func FromRegistry(reg registry.Registry) ([]ai.Tool, error) {
return New(reg).Discover()
}
// Handler is a convenience that returns an ai.ToolHandler bound to the
// given client. It only resolves dotted "service.Endpoint" names — it
// has no awareness of any LLM-safe name mapping. For full round-tripping
// of underscored names emitted by FromRegistry, construct a Set with
// New and call Set.Handler.
func Handler(c client.Client) ai.ToolHandler {
return (&Set{names: &nameMap{m: map[string]string{}}}).Handler(c)
}
func errResult(msg string) (any, string) {
encoded, _ := json.Marshal(map[string]string{"error": msg})
return map[string]string{"error": msg}, string(encoded)
}
// jsonType maps Go types to JSON schema types. Anything that isn't a
// recognised primitive becomes "object".
func jsonType(goType string) string {
switch goType {
case "string":
return "string"
case "int", "int32", "int64", "uint", "uint32", "uint64":
return "integer"
case "float32", "float64":
return "number"
case "bool":
return "boolean"
default:
return "object"
}
}
+31 -38
View File
@@ -1,12 +1,13 @@
package tools
package ai
import (
"context"
"testing"
"go-micro.dev/v5/registry"
"go-micro.dev/v6/registry"
)
func TestJSONType(t *testing.T) {
func TestToolJSONType(t *testing.T) {
cases := map[string]string{
"string": "string",
"int": "integer",
@@ -17,24 +18,24 @@ func TestJSONType(t *testing.T) {
"": "object",
}
for in, want := range cases {
if got := jsonType(in); got != want {
t.Errorf("jsonType(%q) = %q, want %q", in, got, want)
if got := toolJSONType(in); got != want {
t.Errorf("toolJSONType(%q) = %q, want %q", in, got, want)
}
}
}
func TestFromRegistry_Empty(t *testing.T) {
func TestDiscoverTools_Empty(t *testing.T) {
reg := registry.NewMemoryRegistry()
tools, err := FromRegistry(reg)
tools, err := DiscoverTools(reg)
if err != nil {
t.Fatalf("FromRegistry: %v", err)
t.Fatalf("DiscoverTools: %v", err)
}
if len(tools) != 0 {
t.Errorf("expected 0 tools, got %d", len(tools))
}
}
func TestFromRegistry_DiscoversEndpoints(t *testing.T) {
func TestDiscoverTools_DiscoversEndpoints(t *testing.T) {
reg := registry.NewMemoryRegistry()
svc := &registry.Service{
Name: "users",
@@ -63,9 +64,9 @@ func TestFromRegistry_DiscoversEndpoints(t *testing.T) {
t.Fatalf("Register: %v", err)
}
tools, err := FromRegistry(reg)
tools, err := DiscoverTools(reg)
if err != nil {
t.Fatalf("FromRegistry: %v", err)
t.Fatalf("DiscoverTools: %v", err)
}
if len(tools) != 1 {
t.Fatalf("expected 1 tool, got %d", len(tools))
@@ -73,7 +74,7 @@ func TestFromRegistry_DiscoversEndpoints(t *testing.T) {
tool := tools[0]
if tool.Name != "users_Users_Get" {
t.Errorf("safe name = %q, want users_Users_Get", tool.Name)
t.Errorf("safe name = %q", tool.Name)
}
if tool.OriginalName != "users.Users.Get" {
t.Errorf("original = %q", tool.OriginalName)
@@ -81,43 +82,35 @@ func TestFromRegistry_DiscoversEndpoints(t *testing.T) {
if tool.Description != "Fetch a user by ID" {
t.Errorf("description = %q", tool.Description)
}
id, ok := tool.Properties["id"].(map[string]any)
if !ok {
t.Fatal("missing id property")
}
if id["type"] != "string" {
t.Errorf("id type = %v", id["type"])
}
expand, ok := tool.Properties["expand"].(map[string]any)
if !ok {
t.Fatal("missing expand property")
}
if expand["type"] != "boolean" {
t.Errorf("expand type = %v", expand["type"])
}
}
func TestSet_HandlerResolvesSafeName(t *testing.T) {
s := New(registry.NewMemoryRegistry())
s.names.put("users_Users_Get", "users.Users.Get")
func TestTools_HandlerResolvesSafeName(t *testing.T) {
tools := NewTools(registry.NewMemoryRegistry())
tools.names.put("users_Users_Get", "users.Users.Get")
resolved, ok := s.names.get("users_Users_Get")
resolved, ok := tools.names.get("users_Users_Get")
if !ok || resolved != "users.Users.Get" {
t.Errorf("name map lookup = (%q, %v)", resolved, ok)
}
}
func TestSet_HandlerInvalidName(t *testing.T) {
s := New(registry.NewMemoryRegistry())
h := s.Handler(nil)
func TestTools_HandlerInvalidName(t *testing.T) {
tools := NewTools(registry.NewMemoryRegistry())
h := tools.Handler()
// "foo" has no dot, no mapping entry — should error cleanly.
result, content := h("foo", map[string]any{})
if result == nil {
res := h(context.Background(), ToolCall{Name: "foo", Input: map[string]any{}})
if res.Value == nil {
t.Fatal("expected error result")
}
if content == "" {
if res.Content == "" {
t.Error("expected non-empty content")
}
}
func TestWithTools(t *testing.T) {
tools := NewTools(registry.NewMemoryRegistry())
opts := NewOptions(WithTools(tools))
if opts.ToolHandler == nil {
t.Error("WithTools did not set a ToolHandler")
}
}
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"sync"
"time"
jwtToken "github.com/micro/plugins/v5/auth/jwt/token"
"go-micro.dev/v5/auth"
"go-micro.dev/v5/cmd"
"go-micro.dev/v6/auth"
jwtToken "go-micro.dev/v6/auth/jwt/token"
"go-micro.dev/v6/cmd"
)
func init() {
+5 -5
View File
@@ -4,8 +4,8 @@ import (
"encoding/base64"
"time"
"github.com/dgrijalva/jwt-go"
"go-micro.dev/v5/auth"
"github.com/golang-jwt/jwt/v5"
"go-micro.dev/v6/auth"
)
// authClaims to be encoded in the JWT.
@@ -14,7 +14,7 @@ type authClaims struct {
Scopes []string `json:"scopes"`
Metadata map[string]string `json:"metadata"`
jwt.StandardClaims
jwt.RegisteredClaims
}
// JWT implementation of token provider.
@@ -49,10 +49,10 @@ func (j *JWT) Generate(acc *auth.Account, opts ...GenerateOption) (*Token, error
// generate the JWT
expiry := time.Now().Add(options.Expiry)
t := jwt.NewWithClaims(jwt.SigningMethodRS256, authClaims{
acc.Type, acc.Scopes, acc.Metadata, jwt.StandardClaims{
acc.Type, acc.Scopes, acc.Metadata, jwt.RegisteredClaims{
Subject: acc.ID,
Issuer: acc.Issuer,
ExpiresAt: expiry.Unix(),
ExpiresAt: jwt.NewNumericDate(expiry),
},
})
tok, err := t.SignedString(key)
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
"time"
"go-micro.dev/v5/auth"
"go-micro.dev/v6/auth"
)
func TestGenerate(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package token
import (
"time"
"go-micro.dev/v5/store"
"go-micro.dev/v6/store"
)
type Options struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"errors"
"time"
"go-micro.dev/v5/auth"
"go-micro.dev/v6/auth"
)
var (
+1 -1
View File
@@ -14,7 +14,7 @@
package noop
import (
"go-micro.dev/v5/auth"
"go-micro.dev/v6/auth"
)
// NewAuth returns a new noop auth provider.
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"time"
"go-micro.dev/v5/logger"
"go-micro.dev/v6/logger"
)
func NewOptions(opts ...Option) Options {
+16 -16
View File
@@ -15,14 +15,14 @@ import (
"time"
"github.com/google/uuid"
"go-micro.dev/v5/codec/json"
merr "go-micro.dev/v5/errors"
"go-micro.dev/v5/registry"
"go-micro.dev/v5/registry/cache"
"go-micro.dev/v5/transport/headers"
maddr "go-micro.dev/v5/internal/util/addr"
mnet "go-micro.dev/v5/internal/util/net"
mls "go-micro.dev/v5/internal/util/tls"
"go-micro.dev/v6/codec/json"
merr "go-micro.dev/v6/errors"
maddr "go-micro.dev/v6/internal/util/addr"
mnet "go-micro.dev/v6/internal/util/net"
mls "go-micro.dev/v6/internal/util/tls"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/registry/cache"
"go-micro.dev/v6/transport/headers"
"golang.org/x/net/http2"
)
@@ -100,7 +100,7 @@ func newTransport(config *tls.Config) *http.Transport {
})
// setup http2
http2.ConfigureTransport(t)
_ = http2.ConfigureTransport(t)
return t
}
@@ -288,19 +288,19 @@ func (h *httpBroker) run(l net.Listener) {
}
func (h *httpBroker) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if req.Method != "POST" {
if req.Method != http.MethodPost {
err := merr.BadRequest("go.micro.broker", "Method not allowed")
http.Error(w, err.Error(), http.StatusMethodNotAllowed)
return
}
defer req.Body.Close()
req.ParseForm()
_ = req.ParseForm()
b, err := io.ReadAll(req.Body)
if err != nil {
errr := merr.InternalServerError("go.micro.broker", "Error reading request body: %v", err)
w.WriteHeader(500)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(errr.Error()))
return
}
@@ -308,7 +308,7 @@ func (h *httpBroker) ServeHTTP(w http.ResponseWriter, req *http.Request) {
var m *Message
if err = h.opts.Codec.Unmarshal(b, &m); err != nil {
errr := merr.InternalServerError("go.micro.broker", "Error parsing request body: %v", err)
w.WriteHeader(500)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(errr.Error()))
return
}
@@ -318,7 +318,7 @@ func (h *httpBroker) ServeHTTP(w http.ResponseWriter, req *http.Request) {
if len(topic) == 0 {
errr := merr.InternalServerError("go.micro.broker", "Topic not found")
w.WriteHeader(500)
w.WriteHeader(http.StatusInternalServerError)
w.Write([]byte(errr.Error()))
return
}
@@ -406,7 +406,7 @@ func (h *httpBroker) Connect() error {
addr := h.address
h.address = l.Addr().String()
go http.Serve(l, h.mux)
go func() { _ = http.Serve(l, h.mux) }()
go func() {
h.run(l)
h.Lock()
@@ -555,7 +555,7 @@ func (h *httpBroker) Publish(topic string, msg *Message, opts ...PublishOption)
}
// discard response body
io.Copy(io.Discard, r.Body)
_, _ = io.Copy(io.Discard, r.Body)
r.Body.Close()
return nil
}
+5 -3
View File
@@ -6,8 +6,8 @@ import (
"time"
"github.com/google/uuid"
"go-micro.dev/v5/broker"
"go-micro.dev/v5/registry"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/registry"
)
var (
@@ -161,7 +161,9 @@ func pub(b *testing.B, c int) {
go func() {
for range ch {
if err := brk.Publish(topic, msg); err != nil {
b.Fatalf("Unexpected publish error: %v", err)
b.Errorf("Unexpected publish error: %v", err)
wg.Done()
return
}
select {
case <-done:
+4 -4
View File
@@ -7,9 +7,9 @@ import (
"sync"
"github.com/google/uuid"
log "go-micro.dev/v5/logger"
maddr "go-micro.dev/v5/internal/util/addr"
mnet "go-micro.dev/v5/internal/util/net"
maddr "go-micro.dev/v6/internal/util/addr"
mnet "go-micro.dev/v6/internal/util/net"
log "go-micro.dev/v6/logger"
)
type memoryBroker struct {
@@ -122,7 +122,7 @@ func (m *memoryBroker) Publish(topic string, msg *Message, opts ...PublishOption
if err := sub.handler(p); err != nil {
p.err = err
if eh := m.opts.ErrorHandler; eh != nil {
eh(p)
_ = eh(p)
continue
}
return err
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"fmt"
"testing"
"go-micro.dev/v5/broker"
"go-micro.dev/v6/broker"
)
func TestMemoryBroker(t *testing.T) {
+1 -1
View File
@@ -3,7 +3,7 @@ package nats
import (
"context"
"go-micro.dev/v5/broker"
"go-micro.dev/v6/broker"
)
// setBrokerOption returns a function to setup a context with given value.
+12 -12
View File
@@ -9,10 +9,10 @@ import (
"time"
natsp "github.com/nats-io/nats.go"
"go-micro.dev/v5/broker"
"go-micro.dev/v5/codec/json"
"go-micro.dev/v5/logger"
"go-micro.dev/v5/registry"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/codec/json"
"go-micro.dev/v6/logger"
"go-micro.dev/v6/registry"
)
type natsBroker struct {
@@ -194,7 +194,7 @@ func (n *natsBroker) Disconnect() error {
if n.conn != nil {
// drain the connection if specified
if n.drain {
n.conn.Drain()
_ = n.conn.Drain()
n.closeCh <- nil
}
@@ -233,7 +233,7 @@ func (n *natsBroker) Publish(topic string, msg *broker.Message, opts ...broker.P
if err != nil {
return err
}
defer n.pool.Put(poolConn)
defer func() { _ = n.pool.Put(poolConn) }()
conn := poolConn.Conn()
if conn == nil {
@@ -279,7 +279,7 @@ func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...bro
m.Body = msg.Data
n.opts.Logger.Log(logger.ErrorLevel, err)
if eh != nil {
eh(pub)
_ = eh(pub)
}
return
}
@@ -287,7 +287,7 @@ func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...bro
pub.err = err
n.opts.Logger.Log(logger.ErrorLevel, err)
if eh != nil {
eh(pub)
_ = eh(pub)
}
}
}
@@ -304,7 +304,7 @@ func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...bro
conn := poolConn.Conn()
if conn == nil {
n.pool.Put(poolConn)
_ = n.pool.Put(poolConn)
return nil, errors.New("invalid connection from pool")
}
@@ -315,13 +315,13 @@ func (n *natsBroker) Subscribe(topic string, handler broker.Handler, opts ...bro
}
if err != nil {
n.pool.Put(poolConn)
_ = n.pool.Put(poolConn)
return nil, err
}
// Return connection to pool after subscription is created
// The subscription keeps the connection alive
n.pool.Put(poolConn)
_ = n.pool.Put(poolConn)
return &subscriber{s: sub, opts: opt}, nil
}
@@ -349,7 +349,7 @@ func (n *natsBroker) setOption(opts ...broker.Option) {
o(&n.opts)
}
n.Once.Do(func() {
n.Do(func() {
n.nopts = natsp.GetDefaultOptions()
n.poolSize = 1 // Default to single connection (no pooling)
n.poolIdleTimeout = 5 * time.Minute
+1 -1
View File
@@ -5,7 +5,7 @@ import (
"testing"
natsp "github.com/nats-io/nats.go"
"go-micro.dev/v5/broker"
"go-micro.dev/v6/broker"
)
var addrTestCases = []struct {
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"time"
natsp "github.com/nats-io/nats.go"
"go-micro.dev/v5/broker"
"go-micro.dev/v6/broker"
)
type optionsKey struct{}
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"context"
"crypto/tls"
"go-micro.dev/v5/codec"
"go-micro.dev/v5/logger"
"go-micro.dev/v5/registry"
"go-micro.dev/v6/codec"
"go-micro.dev/v6/logger"
"go-micro.dev/v6/registry"
)
type Options struct {
+4 -4
View File
@@ -61,14 +61,14 @@ func (r *rabbitMQChannel) Connect(prefetchCount int, prefetchGlobal bool, confir
func (r *rabbitMQChannel) Close() error {
if r.channel == nil {
return errors.New("Channel is nil")
return errors.New("channel is nil")
}
return r.channel.Close()
}
func (r *rabbitMQChannel) Publish(exchange, key string, message amqp.Publishing) error {
if r.channel == nil {
return errors.New("Channel is nil")
return errors.New("channel is nil")
}
if r.confirmPublish != nil {
@@ -84,11 +84,11 @@ func (r *rabbitMQChannel) Publish(exchange, key string, message amqp.Publishing)
if r.confirmPublish != nil {
confirmation, ok := <-r.confirmPublish
if !ok {
return errors.New("Channel closed before could receive confirmation of publish")
return errors.New("channel closed before could receive confirmation of publish")
}
if !confirmation.Ack {
return errors.New("Could not publish message, received nack from broker on confirmation")
return errors.New("could not publish message, received nack from broker on confirmation")
}
}
+6 -8
View File
@@ -11,16 +11,16 @@ import (
"time"
amqp "github.com/rabbitmq/amqp091-go"
"go-micro.dev/v5/logger"
mtls "go-micro.dev/v5/internal/util/tls"
mtls "go-micro.dev/v6/internal/util/tls"
"go-micro.dev/v6/logger"
)
type MQExchangeType string
const (
ExchangeTypeFanout MQExchangeType = "fanout"
ExchangeTypeTopic = "topic"
ExchangeTypeDirect = "direct"
ExchangeTypeTopic MQExchangeType = "topic"
ExchangeTypeDirect MQExchangeType = "direct"
)
var (
@@ -46,8 +46,6 @@ var (
Locale: defaultLocale,
}
dial = amqp.Dial
dialTLS = amqp.DialTLS
dialConfig = amqp.DialConfig
)
@@ -251,9 +249,9 @@ func (r *rabbitMQConn) tryConnect(secure bool, config *amqp.Config) error {
if !r.withoutExchange {
if r.exchange.Durable {
r.Channel.DeclareDurableExchange(r.exchange)
_ = r.Channel.DeclareDurableExchange(r.exchange)
} else {
r.Channel.DeclareExchange(r.exchange)
_ = r.Channel.DeclareExchange(r.exchange)
}
r.ExchangeChannel, err = newRabbitChannel(r.Connection, r.prefetchCount, r.prefetchGlobal, r.confirmPublish)
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"testing"
amqp "github.com/rabbitmq/amqp091-go"
"go-micro.dev/v5/logger"
"go-micro.dev/v6/logger"
)
func TestNewRabbitMQConnURL(t *testing.T) {
+2 -2
View File
@@ -3,8 +3,8 @@ package rabbitmq
import (
"context"
"go-micro.dev/v5/broker"
"go-micro.dev/v5/server"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/server"
)
// setSubscribeOption returns a function to setup a context with given value.
+3 -3
View File
@@ -4,9 +4,9 @@ import (
"context"
"time"
"go-micro.dev/v5/broker"
"go-micro.dev/v5/client"
"go-micro.dev/v5/server"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/server"
)
type durableQueueKey struct{}
+9 -11
View File
@@ -10,18 +10,16 @@ import (
"time"
amqp "github.com/rabbitmq/amqp091-go"
"go-micro.dev/v5/broker"
"go-micro.dev/v5/logger"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/logger"
)
type rbroker struct {
conn *rabbitMQConn
addrs []string
opts broker.Options
prefetchCount int
prefetchGlobal bool
mtx sync.Mutex
wg sync.WaitGroup
conn *rabbitMQConn
addrs []string
opts broker.Options
mtx sync.Mutex
wg sync.WaitGroup
}
type subscriber struct {
@@ -304,9 +302,9 @@ func (r *rbroker) Subscribe(topic string, handler broker.Handler, opts ...broker
p := &publication{d: msg, m: m, t: msg.RoutingKey}
p.err = handler(p)
if p.err == nil && ackSuccess && !opt.AutoAck {
msg.Ack(false)
_ = msg.Ack(false)
} else if p.err != nil && !opt.AutoAck {
msg.Nack(false, requeueOnError)
_ = msg.Nack(false, requeueOnError)
}
}
+14 -19
View File
@@ -7,12 +7,12 @@ import (
"testing"
"time"
"go-micro.dev/v5/logger"
"go-micro.dev/v6/logger"
micro "go-micro.dev/v5"
broker "go-micro.dev/v5/broker"
rabbitmq "go-micro.dev/v5/broker/rabbitmq"
server "go-micro.dev/v5/server"
micro "go-micro.dev/v6"
broker "go-micro.dev/v6/broker"
rabbitmq "go-micro.dev/v6/broker/rabbitmq"
server "go-micro.dev/v6/server"
)
type Example struct{}
@@ -50,8 +50,7 @@ func TestDurable(t *testing.T) {
s := server.NewServer(server.Broker(b))
service := micro.NewService(
micro.Server(s),
service := micro.NewService("test", micro.Server(s),
micro.Broker(b),
)
h := &Example{}
@@ -82,8 +81,7 @@ func TestWithoutExchange(t *testing.T) {
s := server.NewServer(server.Broker(b))
service := micro.NewService(
micro.Server(s),
service := micro.NewService("test", micro.Server(s),
micro.Broker(b),
)
brkrSub := broker.NewSubscribeOptions(
@@ -119,7 +117,7 @@ func TestWithoutExchange(t *testing.T) {
rabbitmq.DeliveryMode(2),
rabbitmq.ContentType("application/json"))
if err != nil {
t.Fatal(err)
t.Errorf("%v", err)
}
}()
@@ -140,8 +138,7 @@ func TestFanoutExchange(t *testing.T) {
s := server.NewServer(server.Broker(b))
service := micro.NewService(
micro.Server(s),
service := micro.NewService("test", micro.Server(s),
micro.Broker(b),
)
brkrSub := broker.NewSubscribeOptions(
@@ -177,7 +174,7 @@ func TestFanoutExchange(t *testing.T) {
rabbitmq.DeliveryMode(2),
rabbitmq.ContentType("application/json"))
if err != nil {
t.Fatal(err)
t.Errorf("%v", err)
}
}()
@@ -198,8 +195,7 @@ func TestDirectExchange(t *testing.T) {
s := server.NewServer(server.Broker(b))
service := micro.NewService(
micro.Server(s),
service := micro.NewService("test", micro.Server(s),
micro.Broker(b),
)
brkrSub := broker.NewSubscribeOptions(
@@ -235,7 +231,7 @@ func TestDirectExchange(t *testing.T) {
rabbitmq.DeliveryMode(2),
rabbitmq.ContentType("application/json"))
if err != nil {
t.Fatal(err)
t.Errorf("%v", err)
}
}()
@@ -256,8 +252,7 @@ func TestTopicExchange(t *testing.T) {
s := server.NewServer(server.Broker(b))
service := micro.NewService(
micro.Server(s),
service := micro.NewService("test", micro.Server(s),
micro.Broker(b),
)
brkrSub := broker.NewSubscribeOptions(
@@ -293,7 +288,7 @@ func TestTopicExchange(t *testing.T) {
rabbitmq.DeliveryMode(2),
rabbitmq.ContentType("application/json"))
if err != nil {
t.Fatal(err)
t.Errorf("%v", err)
}
}()
+7 -7
View File
@@ -14,8 +14,8 @@ type memCache struct {
}
func (c *memCache) Get(ctx context.Context, key string) (interface{}, time.Time, error) {
c.RWMutex.RLock()
defer c.RWMutex.RUnlock()
c.RLock()
defer c.RUnlock()
item, found := c.items[key]
if !found {
@@ -37,8 +37,8 @@ func (c *memCache) Put(ctx context.Context, key string, val interface{}, d time.
e = time.Now().Add(d).UnixNano()
}
c.RWMutex.Lock()
defer c.RWMutex.Unlock()
c.Lock()
defer c.Unlock()
c.items[key] = Item{
Value: val,
@@ -49,8 +49,8 @@ func (c *memCache) Put(ctx context.Context, key string, val interface{}, d time.
}
func (c *memCache) Delete(ctx context.Context, key string) error {
c.RWMutex.Lock()
defer c.RWMutex.Unlock()
c.Lock()
defer c.Unlock()
_, found := c.items[key]
if !found {
@@ -61,6 +61,6 @@ func (c *memCache) Delete(ctx context.Context, key string) error {
return nil
}
func (m *memCache) String() string {
func (c *memCache) String() string {
return "memory"
}
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"time"
"go-micro.dev/v5/logger"
"go-micro.dev/v6/logger"
)
// Options represents the options for the cache.
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
rclient "github.com/go-redis/redis/v8"
"go-micro.dev/v5/cache"
"go-micro.dev/v6/cache"
)
type redisOptionsContextKey struct{}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"testing"
rclient "github.com/go-redis/redis/v8"
"go-micro.dev/v5/cache"
"go-micro.dev/v6/cache"
)
func Test_newUniversalClient(t *testing.T) {
+2 -2
View File
@@ -5,7 +5,7 @@ import (
"time"
rclient "github.com/go-redis/redis/v8"
"go-micro.dev/v5/cache"
"go-micro.dev/v6/cache"
)
// NewRedisCache returns a new redis cache.
@@ -52,6 +52,6 @@ func (c *redisCache) Delete(ctx context.Context, key string) error {
return c.client.Del(ctx, key).Err()
}
func (m *redisCache) String() string {
func (c *redisCache) String() string {
return "redis"
}
+1 -1
View File
@@ -6,7 +6,7 @@ import (
"testing"
"time"
"go-micro.dev/v5/cache"
"go-micro.dev/v6/cache"
)
var (
+1 -1
View File
@@ -4,7 +4,7 @@ import (
"context"
"time"
"go-micro.dev/v5/internal/util/backoff"
"go-micro.dev/v6/internal/util/backoff"
)
type BackoffFunc func(ctx context.Context, req Request, attempts int) (time.Duration, error)
+2 -2
View File
@@ -9,8 +9,8 @@ import (
cache "github.com/patrickmn/go-cache"
"go-micro.dev/v5/metadata"
"go-micro.dev/v5/transport/headers"
"go-micro.dev/v6/metadata"
"go-micro.dev/v6/transport/headers"
)
// NewCache returns an initialized cache.

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