chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled

This commit is contained in:
wehub-resource-sync
2026-07-13 12:40:33 +08:00
commit e071084ebe
982 changed files with 160368 additions and 0 deletions
+1
View File
@@ -0,0 +1 @@
Internal related things
+68
View File
@@ -0,0 +1,68 @@
# Agent provider conformance matrix
`go test ./...` includes `TestAgentProviderConformanceMatrix`, a shared agent
scenario that runs against every registered chat provider. The scenario asks an
agent to call a deterministic local tool, verifies the tool receives `ai.RunInfo`,
and checks the final response carries the conformance marker. The live matrix
includes MiniMax in the tool/guardrail path in addition to providers with
streaming coverage, so every supported chat provider has at least one key-gated
agent contract. A fake provider path runs on every machine without network
access so CI always exercises the harness.
Live providers are opt-in to avoid flaky unauthenticated PR checks and accidental
API spend. To run the live matrix, set `GO_MICRO_AGENT_CONFORMANCE_LIVE=1` plus the
provider API keys you want to exercise:
| Provider | Required API key | Optional model override |
| --- | --- | --- |
| OpenAI | `OPENAI_API_KEY` | `GO_MICRO_CONFORMANCE_OPENAI_MODEL` |
| Anthropic | `ANTHROPIC_API_KEY` | `GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL` |
| Atlas Cloud | `ATLASCLOUD_API_KEY` | `GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL` |
| Gemini | `GEMINI_API_KEY` | `GO_MICRO_CONFORMANCE_GEMINI_MODEL` |
| Groq | `GROQ_API_KEY` | `GO_MICRO_CONFORMANCE_GROQ_MODEL` |
| MiniMax | `MINIMAX_API_KEY` | `GO_MICRO_CONFORMANCE_MINIMAX_MODEL` |
| Mistral | `MISTRAL_API_KEY` | `GO_MICRO_CONFORMANCE_MISTRAL_MODEL` |
| Together | `TOGETHER_API_KEY` | `GO_MICRO_CONFORMANCE_TOGETHER_MODEL` |
When `GO_MICRO_AGENT_CONFORMANCE_LIVE` or a provider key is absent, the live
provider subtest reports a deterministic skip. When both are present, a provider
failure is a real test failure because drift in chat, tool calling, run metadata,
or final-answer behavior means the services → agents lifecycle is no longer
consistent across providers.
The companion `TestAgentProviderConformanceFakeError` keeps provider error
propagation covered locally without relying on external credentials.
## Local no-secret conformance
Use `make provider-conformance-mock` to run the same provider conformance harness
through the deterministic mock provider. That target requires no API keys and is
what `make harness` delegates to after the 0→1 and 0→hero scenarios, so every PR
continues to exercise the provider-facing agent/tool contract without spending
live model credits.
Use `make provider-conformance` when you want the live-provider sweep: providers
without keys are skipped, and configured providers must satisfy the same harness
contract. For the exact scheduled command, run:
```sh
go run ./internal/harness/provider-conformance \
-providers anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud \
-harnesses agent,universe,agent-flow,plan-delegate,a2a-stream-fallback \
-summary-json provider-conformance-summary.json \
-summary-markdown provider-conformance-summary.md \
-capabilities-markdown provider-capabilities.md
```
The generated summary records one row for every selected provider/harness pair;
missing live-provider keys become skipped rows for each harness, while configured
providers produce pass/fail rows per harness.
## Scheduled CI
The hourly/manual `Harness (E2E)` workflow runs the same matrix with
`GO_MICRO_AGENT_CONFORMANCE_LIVE=1` and the provider secrets exported. Providers
whose keys are absent still skip cleanly, while any configured provider must pass
the shared tool-calling scenario. This keeps scheduled conformance key-gated: PR
checks stay deterministic and no-key environments remain green, but maintained
provider credentials exercise the live matrix regularly.
+296
View File
@@ -0,0 +1,296 @@
# Agent Interface Design
## Principle
Service = capability. Agent = intelligence. An agent IS a service — it has a real RPC server, a proto-defined `Agent.Chat` endpoint, and registers in the registry like everything else.
```
micro.NewService("task") // creates a service
micro.NewAgent("task-mgr") // creates an agent (which is also a service)
```
Same package. Same level. Same communication (RPC). Different responsibilities.
## Interface
```go
type Agent interface {
Name() string
Init(...AgentOption)
Options() AgentOptions
Ask(ctx context.Context, message string) (*Response, error)
Run() error
Stop() error
String() string
}
```
**Ask** is the programmatic API. Send a message, get a response.
**Run** starts a real RPC server, registers the `Agent.Chat` endpoint in the registry, and blocks.
## Proto Definition
```protobuf
service Agent {
rpc Chat(ChatRequest) returns (ChatResponse) {}
}
message ChatRequest {
string message = 1;
}
message ChatResponse {
string reply = 1;
string agent = 2;
repeated ToolCall tool_calls = 3;
}
```
The agent is callable by any go-micro client:
```bash
micro call task-mgr Agent.Chat '{"message": "What tasks are overdue?"}'
```
## Options
```go
type AgentOptions struct {
Name string
Services []string // which services this agent manages
Prompt string // system prompt — identity, domain knowledge, boundaries
Provider string // LLM provider (anthropic, openai, etc.)
Model string // LLM model (optional)
APIKey string
Registry registry.Registry // discover services and other agents
Client client.Client // call service endpoints and other agents
Store store.Store // backing store for the default memory
HistoryLimit int // max conversation turns to retain
Memory Memory // pluggable conversation memory (default: store-backed)
MaxSteps int // stopping condition: max tool calls per Ask
LoopLimit int // max identical repeated calls before refusal (default 3)
Approve ApproveFunc // human-in-the-loop / policy gate on each action
}
```
## Pluggable composition
An agent composes the same way a service does — a small set of pluggable pieces with working defaults:
| Piece | Default | Swap with |
|-------|---------|-----------|
| **Model** | first registered provider | `AgentProvider` / `AgentModel` |
| **Memory** | store-backed, durable across restarts | `AgentMemory(m Memory)` |
| **Tools** | the agent's services (RPC) + `plan`/`delegate` | `AgentTool(name, desc, schema, fn)` for any function |
| **Guardrails** | loop detection on | `AgentMaxSteps`, `AgentLoopLimit`, `AgentApproveTool` |
| **Tool middleware** | none | `AgentWrapTool(...ai.ToolWrapper)` — wrap tool execution (logging, metrics, retries) |
```go
type Memory interface {
Add(role, content string)
Messages() []ai.Message
Clear()
}
```
`NewMemory(store, key, limit)` is the durable default; `NewInMemory(limit)` is non-persistent. `AgentTool` registers a function the model can call alongside the services it discovers.
Functional options:
```go
agent := micro.NewAgent("task-mgr",
micro.AgentServices("task"),
micro.AgentPrompt("You manage tasks. You understand deadlines and priorities."),
micro.AgentProvider("anthropic"),
)
```
## Scoped Tools
An agent only sees the endpoints of its assigned services (plus excludes its own endpoints so it doesn't call itself).
## Memory
Agents persist conversation history in the store. Memory survives restarts.
Each agent's state is confined to its own store table (database `agent`,
table `{name}`) via `store.Scope`, so agents don't share a global table
with each other, with services, or with flows.
```
database "agent", table "{name}":
history — conversation history
```
## Durable Ask / StreamAsk runs
Agents can opt into the same checkpoint backend used by flows with
`micro.AgentWithCheckpoint(...)`. When enabled, each `Ask` or `StreamAsk` run is
persisted with its input, terminal status, response, and tool-call records. If
the process or transport drops after a tool has completed but before the model
returns a final answer, restart the agent with the same checkpoint store and
call `micro.AgentResume(ctx, ag, runID)` or
`micro.AgentResumeStreamAsk(ctx, ag, runID)`.
Completed tool calls are served from the checkpoint instead of being executed
again, while guardrails such as `MaxSteps`, loop detection, approval pauses, and
`request_input` pauses continue to apply to the resumed run.
## Built-in Capabilities
Beyond its scoped service tools, every agent gets two built-in tools. They are not service endpoints — they are capabilities the agent has over itself and over other agents. They are plain tools wired into the agent's tool handler; there is no separate harness, loop engine, or graph. The LLM calls them exactly like any other tool.
### plan
For multi-step work the agent records an ordered plan: a list of steps, each with a `task` and a `status` (`pending`, `in_progress`, `done`). The plan is persisted to the store and surfaced back in the system prompt on later turns, so the agent stays oriented.
```
database "agent", table "{name}":
plan — current plan
```
### delegate
The agent hands a self-contained subtask to another agent. **Delegate-first** resolution:
1. If the target names a **registered agent** (a service advertising `type=agent`), the subtask is sent to it via RPC (`Agent.Chat`). Intelligence stays distributed — the domain expert handles its own services.
2. Otherwise a focused **ephemeral sub-agent** is created with `New(...)` + `Ask(...)`, given a fresh, isolated context, asked the subtask, and torn down.
A sub-agent is just an agent — no new "spawn"/"fork" concept. Ephemeral sub-agents load and persist no history and have no built-in tools, so they cannot plan or re-delegate (which bounds recursion).
These capabilities are added automatically to any non-ephemeral agent, so existing `NewAgent` services and `micro chat` routing get them for free.
## Registration
Agents register as real services via `server.NewServer` with metadata:
```go
server.Metadata(map[string]string{
"type": "agent",
"services": "task,project",
})
```
The server has a real address, real transport, real endpoints. `micro agent list` discovers agents by checking server metadata for `type=agent`.
## The Router (micro chat)
`micro chat` is a router. It discovers agents from the registry and dispatches to them via RPC.
- One agent → routes directly via `client.Call(agentName, "Agent.Chat", ...)`
- Multiple agents → LLM classifies intent, calls `route_to_agent` tool
- No agents → falls back to direct service access (current behaviour)
## Agent-to-Agent Communication
Agents call each other via standard RPC. An agent is a service — it has an `Agent.Chat` endpoint. Any agent can call any other agent the same way it calls a service.
```go
// From inside an agent's logic, call another agent:
client.Call("comms-mgr", "Agent.Chat", &ChatRequest{Message: "Notify Alice"})
```
No special protocol. No broker topics. Just RPC.
### Across frameworks: the A2A gateway
That covers agents *within* a Go Micro system. To reach agents on other
frameworks — and to let them reach yours — there is the **A2A gateway**
(`gateway/a2a`, run with `micro a2a serve`), the agent-side analogue of
the MCP gateway. It discovers agents from the registry, generates an
[Agent Card](https://a2a-protocol.org) for each from its metadata (the
same way MCP derives tools), and translates incoming Agent2Agent tasks to
the agent's `Agent.Chat` RPC. No per-agent code: register an agent and it
is reachable over A2A.
## Usage Patterns
### Single-service agent
```go
agent := micro.NewAgent("task-mgr",
micro.AgentServices("task"),
micro.AgentPrompt("You manage tasks."),
micro.AgentProvider("anthropic"),
)
agent.Run()
```
### Multi-service agent
```go
agent := micro.NewAgent("project-mgr",
micro.AgentServices("task", "project", "milestone"),
micro.AgentPrompt("You manage the project system."),
micro.AgentProvider("anthropic"),
)
agent.Run()
```
### Programmatic
```go
agent := micro.NewAgent("support", ...)
agent.Init()
resp, _ := agent.Ask(ctx, "What tickets are open?")
```
### Agent alongside service
```go
func main() {
svc := micro.NewService("task")
svc.Handle(new(TaskHandler))
agent := micro.NewAgent("task-mgr",
micro.AgentServices("task"),
micro.AgentPrompt("You manage tasks."),
micro.AgentProvider("anthropic"),
)
go svc.Run()
agent.Run()
}
```
## CLI
```bash
micro agent list # list running agents (registry, type=agent)
micro agent describe task-mgr # show agent details
micro agent history task-mgr # show stored conversation (scoped store)
micro chat # routes to agents automatically
micro call task-mgr Agent.Chat '{"message": "..."}' # direct RPC
```
The split is consistent across services, agents, and flows: **`list`**
shows what's *running* (from the registry, filtered by type), while
**`history`/`runs`** show *durable* state from the scoped store —
available whether or not the component is currently running.
```bash
micro flow list # list running flows (registry, type=flow)
micro flow runs checkout # durable run history for a flow (scoped store)
```
## Generation
`micro run --prompt` creates services AND an agent:
```
micro run --prompt "task management system"
Generated:
task/ ← service
project/ ← service
agent/ ← agent (manages task, project)
```
The agent reads `MICRO_AI_PROVIDER` and `MICRO_AI_API_KEY` from the environment.
## What Doesn't Change
- Services are still services — same interface, same code, same deployment
- You can run services without agents
- You can call services directly via `micro call`, the API, or MCP
- The framework interfaces (registry, client, server, store) are unchanged
- `micro run`, `micro deploy`, `micro build` work the same way
+216
View File
@@ -0,0 +1,216 @@
# Continuous Improvement Loop
Go Micro is an agent harness. This file defines the **autonomous loop that builds
it** — the framework's own thesis (an agent operating a system) pointed at itself.
Claude Code drives the loop; Codex executes scoped tasks; the human sets direction
and can stop or revert anything at any time.
> **North Star.** Every increment must advance the thesis in [`THESIS.md`](THESIS.md):
> a holistic agent harness and service framework encapsulating the lifecycle of
> **services → agents → workflows**. Judge each change against it — work that
> doesn't move toward that lifecycle isn't an improvement, however clean.
## The pipeline (planner → generator → evaluator)
The development process is an operational instance of the long-running-agent
harness pattern ([Anthropic on harness design](https://www.anthropic.com/engineering/harness-design-long-running-apps)) —
a planner, a generator, and a *separate* evaluator — distributed across GitHub
Actions instead of subagents. Each role is a workflow:
| Role | Workflow (action name) | What it does |
|------|------------------------|--------------|
| **Planner** | `loop-planner.yml`*Loop: Planner* | Tracks live state, prioritizes the roadmap + an internal scan, and maintains the ranked queue in [`.github/loop/PRIORITIES.md`](../../.github/loop/PRIORITIES.md). Decides *what*. |
| **Generator** | `loop-builder.yml`*Loop: Builder (Generator)* | Builds the top open queue item as a single-concern PR (via Codex) and self-merges on green CI. Does the work. |
| **Evaluator** | `harness.yml`*Harness (E2E)*, plus the CI gate (`tests.yaml`, `lint.yaml`, `govulncheck.yml`) | Grades every change: the mock harness + unit/lint + reachable-CVE scan on each push/PR, and real-model conformance hourly. A *separate* grader — never the generator judging itself. |
| **Evaluator → feedback** | `loop-triage.yml`*Loop: Triage (Evaluator feedback)* | When a gate workflow (Lint, Run Tests, govulncheck, or the harness) fails on a non-PR run, root-causes, dedupes, and files scoped fix issues back into the planner's queue. The hill-climbing feedback path. |
| **Coherence** | `loop-coherence.yml`*Loop: Coherence* | Keeps README/website/docs/blog aligned with the North Star, keeps `CHANGELOG.md` living (reconciling `[Unreleased]` against merged PRs and rolling it into version headings as tags cut), and drafts the changelog blog post. |
| **Security** | `loop-security.yml`*Loop: Security* | Weekly vulnerability audit of the attack surface (MCP/A2A gateways, x402, auth, provider URLs, agent tool loop, deps via `govulncheck`). Files `security` issues; **never auto-merges** fixes and **never publishes exploit detail** in public issues (responsible disclosure); risky fixes are `needs-human`. |
| **Release** | `loop-release.yml`*Loop: Release (daily patch)* | Cuts a daily patch tag when master has new commits, so the *installable* framework tracks the loop's improvements (triggers `release.yml`/goreleaser). Minor/major bumps stay with the human. |
Generation is separated from evaluation on purpose: an agent grading its own work
reliably over-rates it, so **CI and the harness — not the builder — are the gate**.
The human sets direction and owns the calls that need taste (see Guardrails).
> **Go Micro dogfoods its own tool.** These `.github/workflows/loop-*.yml` files
> are generated by [`micro loop`](../../cmd/micro/loop) (`micro loop init --roles all`).
> The workflows are the *mechanism*; each role's instruction is the editable
> *policy* in [`.github/loop/prompts/`](../../.github/loop/prompts), and the
> direction/queue live in [`.github/loop/NORTH_STAR.md`](../../.github/loop/NORTH_STAR.md)
> and [`.github/loop/PRIORITIES.md`](../../.github/loop/PRIORITIES.md). To change
> what a role does, edit its prompt — not the YAML. Re-run `micro loop init
> --roles all --force` to regenerate the workflow mechanics (it won't clobber the
> North Star, queue, or prompts).
## Autonomy
Full autonomy, **no approval gates**. Each increment: Claude Code picks the work,
implements it (or dispatches Codex), opens a PR, and **merges it** — including
reviewing and merging Codex's PRs. The only gate is **correctness**: `go build`,
`go test`, and `golangci-lint` must be green (that's not an approval, it's not
shipping broken code).
Transparency replaces approval: every increment ends with a one-line digest, and
every change is a small, reversible, single-concern PR the human can revert.
## What counts as an improvement
Grounded in real signal, never speculative rewrites. Each cycle draws from:
1. **Roadmap** — the Now/Next items in `ROADMAP.md` (harness depth: durable runs,
observability, streaming, human-in-the-loop; hardening: resilience, conformance).
2. **Open issues** — the scoped backlog (e.g. #3010#3014).
3. **Improvement radar** — a scan each cycle for: missing/weak tests, lint or
quality issues, docs/code drift, and DX friction.
4. **Dogfooding** — actually build with the harness (`micro new``run``chat`,
an agent + a flow) and fix what hurts. Friction found here is high-signal.
## The cycle (one increment)
1. Sync `master`.
2. If a Codex PR is open and CI-green → review (diff + gates + correctness vs its
issue) and merge it.
3. Else pick the single highest-value item from the sources above.
4. Implement it, or dispatch to Codex (`@codex <instruction>` on the issue) if it's
a well-scoped chunk and Codex is free. **Codex is serial — one task at a time.**
5. Verify `build`/`test`/`lint` locally.
6. Open a PR (one concern) and merge it.
7. Post a one-line digest; refresh the backlog from the radar.
## Roles
- **Claude Code** — orchestrator, implementer, reviewer, integrator, merger.
- **Codex** — serial builder for well-scoped chunks, dispatched via `@codex`.
- **Human** — sets direction; owns brand/positioning copy and breaking public-API
decisions; can stop or revert anything.
## Guardrails
- One concern per PR; small and reversible.
- Stay on `claude/*` branches (Codex on `codex/*`); never two agents on one branch;
base PRs on `master` (don't stack on an in-flight branch). See `CODEX.md`.
- **Off-limits without the human:** brand/positioning/marketing copy, breaking
public API changes, product-default changes with broad behavioral impact, new
dependencies, architectural rewrites. The loop proposes these in the digest; it
does not merge them autonomously.
## Scheduling
- **In-session cron** (`CronCreate`) — runs increments while this Claude session is
alive. Convenient, but the remote environment is reclaimed on inactivity and
recurring jobs expire after 7 days, so it is **not** a durable scheduler.
- **GitHub Actions (durable)** — a scheduled workflow that runs the loop
independently of any session. This is the real backbone; it opens a fresh
tracking issue for each increment and dispatches Codex there. It needs a
`CODEX_TRIGGER_TOKEN` repo secret from a user account Codex responds to;
without that secret the workflow deliberately no-ops to avoid ignored bot
comments. See `.github/workflows/loop-builder.yml` and the mechanics
below.
## How the durable loop works (mechanics)
Hard-won wiring — change any one piece and the loop silently stops producing
merged PRs. Each scheduled run:
1. **Opens a fresh issue per increment** (`Continuous improvement increment #N`)
and posts the `@codex` instruction on it. *Why a fresh issue:* Codex derives
its branch name from the triggering issue's context, so re-using one tracker
issue collapses every run onto one branch name and only the first PR opens —
the rest collide and silently fail.
2. **Posts as a user, not the Actions bot.** Codex ignores `@codex` comments
authored by `github-actions[bot]`, so the dispatch uses `CODEX_TRIGGER_TOKEN`
(a PAT for a user account Codex follows). No token → the step no-ops.
3. **Codex opens the PR itself with `gh` — never `make_pr`.** In the Codex Cloud
sandbox the `make_pr` tool is a **no-op stub**: it records the PR title/body
for the manual "Create PR" button and never pushes a branch or calls the API.
So the dispatch and [`AGENTS.md`](../../AGENTS.md) tell Codex to do it by hand:
```sh
git switch -c codex/increment-<issue> # unique branch, codex/ prefix
git push -u origin codex/increment-<issue>
gh pr create --base master --label codex --title "…" --body "… Closes #<issue>"
gh pr merge --squash --auto --delete-branch
```
This requires the Codex setup script to install `gh` and run `gh auth
setup-git` (so `git push` is authenticated) with a write-scoped token.
4. **Merges via GitHub native auto-merge, gated by branch protection.** `master`
requires the CI status checks (build, tests, golangci-lint) and **0 approving
reviews**. `gh pr merge --auto` enables auto-merge; GitHub lands the PR the
moment checks pass and deletes the branch. `Closes #<issue>` auto-closes the
tracking issue. There is **no merge sweep workflow** — branch protection is
the gate.
### Do-not-break list
- **Don't re-add required approvals** to `master` — it blocks every autonomous
merge. The intended gate is **green CI only**.
- **Don't point the dispatch at one standing tracker issue** — one issue per run.
- **Don't tell Codex to use `make_pr`** (or imply a token "isn't a substitute"):
it cannot open a PR. `gh` is the only path.
- **Don't manually re-implement a Codex increment during the summary→PR lag**
(Codex posts an optimistic "opened a PR" comment ~3045 min before the PR
actually appears). Re-doing it creates duplicate PRs and stale branches that
then block the next run. Wait for the PR, or let it ride.
## Overseer passes (DevRel + Architect)
The hourly loop ships increments; two periodic passes keep the *whole* heading in
the right direction. Both use the same mechanism (fresh issue → `@codex` →
output) but produce direction and coherence, not just code.
- **Coherence (DevRel) — daily** (`.github/workflows/loop-coherence.yml`, prompt `.github/loop/prompts/coherence.md`). Audits the public
surface (README, website landing + docs, blog) for coherence with the North
Star, README crispness, and blog-worthy material. It also keeps `CHANGELOG.md`
living: each run reconciles the `[Unreleased]` section against the PRs that
actually merged (Keep-a-Changelog format, user-facing entries only — internal
loop/CI churn is skipped), and rolls `[Unreleased]` into a dated version
heading whenever a new `v6.MINOR.PATCH` tag has been cut (by `loop-release`).
When enough user-facing work has accumulated (roughly weekly, not a near-empty
post every day) it also drafts a "what's new" changelog blog post narrating it.
**Autonomy boundary:** safe factual-alignment and crispness fixes — *including
the `CHANGELOG.md` upkeep* — auto-merge like any increment; brand/positioning
copy and the changelog blog post are opened as a PR (or surfaced in the report)
and left for the human to review/merge — blog voice stays with the human.
- **Planner (Architect) — continuous (hourly)** (`.github/workflows/loop-planner.yml`, prompt `.github/loop/prompts/planner.md`).
The *founder lens*, running alongside the builders. Each run it **tracks live
state** (what just merged, what's in flight), **prioritizes the roadmap**
(`ROADMAP.md`, Now → Next → Later) against an internal scan (lifecycle gaps, API
coherence and seams, dev-UX friction, missing pieces, drift/realignment), and
**maintains the ranked queue** in [`.github/loop/PRIORITIES.md`](../../.github/loop/PRIORITIES.md) — re-ranking
to reflect reality, backing each top item with a scoped issue, and posting an
assessment. It runs at `:59`, just before the `:29` increment, so it
re-prioritizes and *then* the loop builds the new top. **Its output is the
prioritized queue plus the assessment** — it does **not** make breaking or
architectural changes itself (those stay with the human). To avoid churn it only
opens a PR when the ranking actually changes.
The two loops are coupled through `PRIORITIES.md`: the **architect decides *what***
(roadmap + internal priorities, ranked, issue-linked) and the **hourly increment
loop builds the top open item** — falling back to its own judgment only if the
queue is empty. DevRel keeps the public story honest alongside. So work is
roadmap-driven by default, not a fresh guess every hour. Cadence is tunable in each
workflow's `cron`; the human can reorder `PRIORITIES.md` or its issues at any time
to redirect. Codex is serial, so these passes queue behind any in-flight increment.
## Failure triage (the feedback loop)
The loop also closes on its own failures. `.github/workflows/loop-triage.yml`
fires when a gate workflow — **Lint**, **Run Tests**, or the provider-conformance
**Harness (E2E)** — finishes with `conclusion: failure` on a non-PR run (so a red
lint or test on `master`, not just a harness failure, becomes a fix issue). It
dispatches Codex to **triage** the failing run: read the logs, root-cause each
distinct failure, **dedupe** against open issues (comment "recurred" rather than
filing a duplicate), and file a scoped `codex`/`enhancement` issue for each genuine,
self-contained defect — which the increment loop then builds and the next run
verifies. Genuine transient flakes (live-model latency, provider outages) are
ignored; anything needing a breaking or architectural change is escalated as
`needs-human` instead of auto-built. This is
the hill-climbing layer: CI/harness failures become fixes with no human in the
middle, short of a decision that's genuinely the human's.
## Stop / redirect
- In-session: `CronDelete <id>` (or end the session).
- Durable: disable/delete the workflow.
- Or just tell Claude Code to pause or change focus — direction always wins over
the loop.
+6
View File
@@ -0,0 +1,6 @@
# Status
> This snapshot is no longer maintained. Current state lives in two places:
>
> - **What shipped** — [CHANGELOG.md](../../CHANGELOG.md)
> - **What's next** — [ROADMAP.md](../../ROADMAP.md)
@@ -0,0 +1,194 @@
# Development Strategy Assessment
**Date:** 2026-06-24
**Scope:** README, roadmap, website docs, internal design notes, examples, harnesses, and blog narrative around agents, services, and workflows.
## Executive summary
Go Micro has a coherent v6 thesis: **agents are distributed systems**, so the framework should make agents, services, and workflows one set of Go-native primitives rather than a separate orchestration product. The repository already has the right foundation: services are self-describing tools, agents add model + memory + tools with `plan`/`delegate`, flows provide deterministic event-driven execution, and MCP/A2A/x402 make the system interoperable with external agents and paid tools.
The next phase should not be another broad feature push. The best strategic move is to make the current promise consistently true under real usage:
1. **Harden the core agent loop across providers and failures.**
2. **Make the getting-started contract impossible to break.**
3. **Close the loop on durable, observable, streaming agent runs.**
4. **Turn one maintained real-world build into the canonical 0→hero path.**
5. **Keep the product shape narrow: framework + CLI + docs, not a hosted platform.**
## What the project is now
### Positioning
The public README positions Go Micro as “a framework for building agents and services in Go.” The important distinction is that an agent is not treated as an external chatbot wrapper; it is a service with an LLM, memory, discovered tools, registry presence, and RPC reachability. That makes the messaging clear and differentiated from graph-first agent frameworks.
### Core primitives
- **Services** are the durable base abstraction: ordinary Go handlers register, discover each other, and become AI-callable tools through their endpoint metadata and comments.
- **Agents** are services with a model, memory, and tools. They expose `Agent.Chat`, can be called over RPC, can be reached from `micro chat`, and get built-in `plan` and `delegate` tools.
- **Flows** cover the deterministic side: predefined event or step paths that checkpoint and resume. This complements agents rather than competing with them.
- **Gateways** make the primitives externally useful: MCP exposes services as tools, A2A exposes agents as agents, HTTP/gRPC gateways preserve conventional service access, and x402 opens the path to paid tools.
### Narrative and adoption surface
The blog has progressed from “microservices become AI tools” through first-class agents, planning/delegation, workflows, guardrails, durability, A2A, and sponsorship. That narrative is strong because it tells a product story: Go Micro did not bolt agents onto a framework; it reframed microservices as the runtime substrate for agentic systems.
## Strengths to preserve
1. **One abstraction stack.** Services, agents, and flows all use registry, client, broker, store, and gateway primitives. This lowers conceptual load and reinforces the “distributed systems for agents” thesis.
2. **CLI-first developer experience.** The README and roadmap make the CLI the product surface: scaffold, run, chat, inspect, deploy.
3. **Interop from the registry.** MCP and A2A generated from existing registry metadata avoid hand-maintained tool/agent catalogs.
4. **Pluggable but opinionated.** Model, store, registry, broker, transport, memory, and tool middleware are swappable, but defaults exist.
5. **Good taxonomy.** The docs cleanly map augmented LLMs, workflows, and agents to Go Micro primitives without introducing a graph DSL.
## Key risks
### 1. Breadth outruns reliability
The project already spans services, agents, flows, MCP, A2A, x402, multiple model providers, code generation, CLI, dashboard, examples, and deployment. The roadmap correctly identifies hardening as the current priority. More surface area before conformance and resilience would increase support burden and weaken trust.
### 2. Provider behavior can fragment the experience
The AI package presents one model interface, but each provider has different tool-call semantics, streaming APIs, errors, rate limits, and refusal behavior. If `micro chat`, `NewAgent`, and flows behave differently by provider, users will perceive the framework as unreliable even when the service layer is solid.
### 3. Agent promises require production semantics
Agents that plan, delegate, pay, or run unattended need deadlines, cancellation, retries, resumability, tracing, audit history, and human-intervention states. Without these, agent features remain demos rather than production workflows.
### 4. Docs can become aspirational
Some internal design/status documents are intentionally obsolete or historical. The public roadmap and changelog are now the canonical sources. Future docs should make current/shipped/proposed status explicit to avoid confusion.
### 5. The example portfolio is broad but not yet a single proof
There are many targeted examples, but the strategy needs one polished real-world build that exercises services, agents, flows, guardrails, history/runs, MCP/A2A, and deployment as a continuous path.
## Recommended next steps
### Phase 1: hardening and contracts
**Goal:** make the existing v6 promise repeatable.
1. **Cross-provider conformance matrix**
- Run the same scenario against every supported provider when keys are present.
- Cover simple generation, service tool calls, multi-step tool use, `plan`, `delegate`, guardrails, refusal/stop behavior, and structured errors.
- Publish the support matrix in docs so users know which capabilities are verified.
2. **Getting-started contract in CI**
- Define two golden paths:
- **0→1:** `micro new``micro run` → HTTP/RPC call succeeds.
- **0→hero:** services + agent + flow + plan/delegate + inspection path.
- Make the contract a small harness or scripted example that runs on every relevant change.
3. **Failure semantics in the agent loop**
- Ensure `context.Context` deadlines propagate through model calls, tool execution, delegation, flows, and gateway calls.
- Add consistent timeout, retry/backoff, and rate-limit handling at model-provider boundaries.
- Make cancellation visible in run metadata and user-facing CLI output.
4. **Docs status cleanup**
- Keep `README.md`, `ROADMAP.md`, and website roadmap aligned.
- Add or maintain “current vs proposed” banners on internal docs that remain as design history.
### Phase 2: agentic depth
**Goal:** make long-running agent work production-grade.
1. **Durable agent loop**
- Reuse the existing `Checkpoint` model from flows.
- Persist model turn, tool call, step count, plan state, delegation context, and terminal status.
- Resume without duplicating completed side effects.
2. **Agent observability**
- Convert `RunInfo` into OpenTelemetry spans/events.
- Include model provider, latency, token usage where available, tool calls, delegate boundaries, refusals, guardrail blocks, and errors.
- Expose `micro runs` / `micro history` as first-class inspection commands if not already complete.
3. **Streaming end to end**
- Implement `ai.Stream` uniformly where provider support exists.
- Carry streaming through `micro chat`, agent `Ask`/`Chat`, A2A `message/stream`, and any UI surface that remains.
4. **Human-in-the-loop state**
- Extend beyond binary `ApproveTool` into pause/resume or `input-required` states for long runs.
- Make this compatible with durable checkpoints so approval can happen after process restart.
### Phase 3: canonical real-world build
**Goal:** turn the thesis into one maintained proof path.
Build and maintain one example application that demonstrates:
- A few domain services with real state.
- One conductor agent and at least one specialist agent.
- A flow triggered by an event that dispatches to an agent.
- Guardrails on risky tools.
- Durable run inspection and resume.
- MCP exposure for external agents.
- Optional A2A interop.
- Deployment instructions.
The support-agent example is a strong candidate because it naturally includes service lookups, prioritization, customer communication, human approval, and event-triggered automation.
## Strategic priorities
### Product
- Keep Go Micro as an **open-source framework**, not a hosted platform.
- Make commercial support, training, and retainers the sustainability path.
- Treat CLI quality as the primary adoption lever.
### Developer experience
- Optimize the loop: `new``run``chat``inspect``deploy`.
- Prefer fewer, excellent commands over a broad command surface.
- Ensure generated code is ordinary Go that users can edit and keep.
### Technical architecture
- Use registry metadata as the source of truth for tools and agents.
- Keep workflows deterministic and agents dynamic; do not introduce a graph DSL unless the current primitives cannot express a real user need.
- Make all autonomous behavior bounded, observable, cancellable, and resumable.
### Documentation
- Lead with runnable paths and production caveats.
- Keep the taxonomy page because it explains when to use augmented LLMs, flows, and agents.
- Promote one real-world example over many disconnected mini examples.
## Agent harness positioning
The language should move from “framework for services” to “agent harness on top of services.” A service framework helps teams build callable capabilities. An agent harness is the runtime that makes a model safe and useful around those capabilities: discovery, tool schema, execution, state, guardrails, workflows, delegation, observability, and interop.
Recommended public language:
- **Primary line:** “Go Micro is an agent harness and service framework for Go.”
- **Short explanation:** “The harness is the runtime around an agent: tools, memory, guardrails, workflows, state, discovery, and protocols.”
- **Positioning contrast:** “Agent frameworks put a model in a loop; Go Micro operates that loop against real services.”
- **Developer promise:** “If your agent has to operate a system, not just answer a prompt, use Go Micro.”
This keeps the original microservices heritage but reframes it for the agent market. The service layer is not old positioning; it is the reason the harness is credible. Agents need real capabilities, and Go Micro services are typed, discoverable, callable capabilities.
## Relevance in the agent-harness world
To be relevant as agent infrastructure, Go Micro should make the following product bets visible and real:
1. **Harness, not chatbot.** Lead with execution: tools, memory, guardrails, workflows, and interop. Avoid copy that sounds like “another agent framework.”
2. **Services as tools.** Make existing Go services immediately useful to agents through MCP, A2A, and generated tool descriptions.
3. **Runtime safety.** Prioritize MaxSteps, loop detection, approval gates, scoped state, timeouts, cancellation, audit trails, and policy hooks.
4. **Durability and observability.** Agents doing real work need resumable runs, traces, run history, tool-call timelines, and explainable failures.
5. **Interop-first.** Be the Go runtime that any MCP or A2A agent can plug into, rather than a closed agent ecosystem.
6. **Evaluation and conformance.** Harnesses are trusted by tests. Cross-provider conformance, scenario harnesses, and eventually first-class evaluation should become a visible part of the project.
7. **Canonical proof.** Maintain one real-world example that demonstrates services, agents, flows, guardrails, durable runs, MCP/A2A, and deployment end to end.
## Suggested immediate backlog
1. Add cross-provider conformance harness and docs matrix.
2. Script and CI-test the 0→1 and 0→hero getting-started contracts.
3. Audit agent loop context propagation, timeouts, and cancellation.
4. Wire `RunInfo` to tracing and CLI inspection.
5. Implement durable agent checkpoint/resume.
6. Complete streaming through model providers, chat, agent RPC, and A2A.
7. Promote support-agent or another scenario into the canonical real-world example.
8. Normalize docs status banners and remove/redirect stale internal status pages where appropriate.
## Bottom line
Go Micro has a strong, timely strategic position: **the service framework for building agentic systems in Go**. The current opportunity is to make that position trustworthy. Development should bias toward conformance, resilience, durable execution, observability, and a polished end-to-end developer path before expanding the feature surface.
+311
View File
@@ -0,0 +1,311 @@
# Durable Execution: Flow Steps & Checkpoint
**Status:** Design proposal — not yet implemented.
This note sketches two related changes:
1. Give **flow** a real step model — a flow is a *task* made of *ordered
steps* — so it becomes the deterministic-workflow engine it has always
claimed to be (today it runs a single LLM step per event).
2. Introduce **`Checkpoint`**, a pluggable durability primitive that
persists run progress and resumes after a crash. Store-backed by
default; both flow and agent use it.
The two are designed together because a step boundary is the natural
place to checkpoint.
---
## Motivation
A flow or agent run is long, expensive, and has side effects partway
through (it sent an email at step 2, charged via x402 at step 4). Today
all in-flight state lives in process memory: a crash loses the run, and
re-running from the top repeats the side effects.
Durable execution means the run survives a crash and **continues from
where it stopped**, without re-doing completed steps.
This is squarely a distributed-systems concern — checkpoint state, replay
on restart, pluggable backend — i.e. go-micro's kind of problem, built on
primitives it already has (`store`, `WrapTool`, `call.ID`).
---
## What flow is today (for contrast)
`flow` is a concrete `*Flow` struct. Per broker event, `Execute` runs
**one** augmented-LLM turn (a single `Generate` with services as tools)
or dispatches the event to an agent, records one `Result`, and returns.
There is no notion of a task with ordered steps, no carried state, no
checkpoint. The step model below generalizes today's behavior: a flow
with one step == current flow.
---
## Core concepts
### State
What carries across steps. **A struct, not a map** — a typed `Data`
plus a `Stage` marker so you can always tell where a run is.
```go
type State struct {
Stage string // name of the step the run is at — where it is
Data []byte // carried data, serialized; use Set / Scan
}
// Set replaces the data with the JSON encoding of v.
func (s *State) Set(v any) error
// Scan decodes the data into v (a pointer to the caller's struct).
func (s State) Scan(v any) error
```
The developer defines their own data struct and threads it through
with `Set`/`Scan` — type-safe at the edges, serializable in the middle
(which is what makes checkpointing possible). `Stage` is the readable
"where am I"; the engine also uses it as the resume point.
The trigger event seeds the first `State`.
### Step
The unit of a flow. **One kind** — a struct with a name, the action to
run, and an optional retry override. No per-kind constructors.
```go
type StepFunc func(ctx context.Context, in State) (State, error)
type Step struct {
Name string
Run StepFunc
Retry int // optional per-step override of the flow's retry (0 = use flow default)
}
```
Common actions are **helpers that return a `StepFunc`**, dropped into
`Step.Run` — so there is still one `Step` type, and the actions compose:
```go
flow.Call(service, endpoint) StepFunc // one RPC to a service
flow.LLM(opts...) StepFunc // one augmented-LLM turn
flow.Agent(name) StepFunc // dispatch to a registered agent
// …or write your own StepFunc.
```
Steps are **authored by the developer** and run in order. That ordering
is the defining difference from an agent, where the *model* chooses the
steps.
### Run
The persisted record of one execution — what `Checkpoint` saves and
loads. Retained for success and failure (see retention below).
```go
type Run struct {
ID string // durable run id (idempotency root)
Flow string // flow name
State State // carried data + Stage (where it is)
Steps []StepRecord // per-step status + outcome (history/audit)
Status string // running | done | failed
Started time.Time
Updated time.Time
}
type StepRecord struct {
Name string
Status string // pending | in_progress | done | failed
Attempts int // how many tries this step took
Result string // short serialized outcome / summary
Error string
}
```
The resume point is `State.Stage` — there is no separate numeric cursor,
so there is one source of truth for "where it is."
### Checkpoint
The pluggable durability primitive. Persists and restores a `Run`.
```go
type Checkpoint interface {
Save(ctx context.Context, run Run) error
Load(ctx context.Context, runID string) (Run, bool, error)
Delete(ctx context.Context, runID string) error
}
```
The built-in implementation is **store-backed** and on by default, keyed
in the store:
```
database "flow", table "{name}", key {runID} → JSON(Run)
```
Runs are confined to their own **store table** — database `flow`, one
table per flow name — via `store.Scope`, not a single shared table keyed
by prefix. `StoreCheckpoint(s, scope)` takes that scope; the flow passes
its name by default. `store.Scope` injects the database/table per
operation, so it doesn't mutate or race on the shared store (the way
`Init(Table(...))` would). Because it rides on `store.Store`, the storage
is also pluggable (Postgres, NATS KV, file) with no extra interface.
**Retention:** completed runs (success *and* failure) are **kept** by
default, so you have a durable history of what ran. `Delete` is only
called when the flow opts in with `flow.DeleteOnSuccess()` (failures are
always kept).
---
## The run loop
```
run := load(runID) or new Run{State: {Stage: steps[0].Name, ...}}
start := index of step named run.State.Stage
for i := start; i < len(steps); i++ {
step := steps[i]
run.Steps[i].Status = "in_progress"; checkpoint.Save(run)
out, err := runWithRetry(ctx, step, run.State, retriesFor(step))
run.Steps[i].Attempts = attemptsTaken
if err != nil {
run.Steps[i].Status = "failed"; run.Steps[i].Error = err
run.Status = "failed"; checkpoint.Save(run) // kept for audit
return err // resumable: retry resumes here
}
run.State = out
run.Steps[i].Status = "done"
if i+1 < len(steps) {
run.State.Stage = steps[i+1].Name // <-- checkpoint boundary
} else {
run.State.Stage = "" // finished
}
checkpoint.Save(run)
}
run.Status = "done"; checkpoint.Save(run)
// Delete only if flow.DeleteOnSuccess() was set.
```
On restart, `Load` returns the `Run`; the loop resumes at the step named
`run.State.Stage`, so completed steps are skipped — their effects already
happened and their output is already in `run.State.Data`.
### Retry
Flow-level by default, per-step override when needed (e.g. a tool that
times out):
```go
flow.Retry(2) // flow-level default for every step
flow.Step{Name: "charge", Run: , Retry: 0} // override: never retry this one
```
`retriesFor(step)` uses `step.Retry` if set, else the flow default.
### Idempotency (the honest part)
True exactly-once is impossible if a crash lands *inside* a step. What we
provide is at-least-once + a stable **idempotency key** per step:
`runID + stepName`. That key is passed to the tool as `call.ID`, so a
replayed call is recognized downstream and de-duplicated. Side-effecting
steps must cooperate (honor the key). The framework makes this
consistent; it cannot make it free.
Retry uses the same key, so a retried step is de-duplicated the same way.
This is where the existing `WrapTool` seam pays off: a durable wrapper
checks the checkpoint — if this `call.ID` already has a recorded result,
return it without re-calling.
---
## Agent reuse
The agent loop is the **self-directed** analogue and uses the same
`Checkpoint`. The difference is who authors the steps:
| | Steps authored by | Steps known | Durability |
|---|---|---|---|
| **flow** | developer | up front (ordered list) | checkpoint between steps |
| **agent** | the model | discovered at runtime | checkpoint each LLM turn + its tool calls |
For the agent, `Run.Steps` grows as the model acts, instead of being
predefined. One requirement: the agent must own its loop (today the
provider drives it), so it can `Save` between turns. That is the one
structural change on the agent side.
---
## Pluggability — two levels
1. **Storage (free today).** Built-in `Checkpoint` over `store.Store`;
swap the store backend. Covers "checkpoint to my DB instead."
2. **Engine (future).** Because steps are now explicit and named, a flow
can be mapped onto an external durable-execution engine — each `Step`
becomes a Temporal activity / Restate handler — by providing an
alternative runner. Most users only need level 1; level 2 exists so
teams already running Temporal aren't forced off it.
The explicit step model is what makes level 2 possible later; we don't
build it now.
---
## Proposed API
```go
type Onboarding struct {
Email string `json:"email"`
WorkspaceID string `json:"workspace_id"`
}
f := flow.New("onboard-user",
flow.Trigger("events.user.created"),
flow.Retry(2), // flow-level retry default
flow.Steps(
flow.Step{Name: "plan", Run: flow.LLM(flow.Prompt("Plan onboarding for {{.Email}}"))},
flow.Step{Name: "workspace", Run: flow.Call("workspace", "Workspace.Create")},
flow.Step{Name: "welcome", Run: flow.Agent("comms")},
),
// Durable by default (store-backed); runs are retained for audit.
flow.WithCheckpoint(flow.StoreCheckpoint(service.Options().Store, "onboard-user")),
)
f.Register(reg, broker, client)
```
A single-step flow keeps today's behavior, so this is additive.
---
## Decisions (resolved)
- **State is a struct, not a map** — typed `Data` + `Stage`. The
developer defines the data struct; `Stage` doubles as the resume
point, so there is one source of truth for position.
- **One `Step` kind** — a struct with `Name`, `Run`, and an optional
`Retry`. Common actions are `StepFunc` helpers (`Call`, `LLM`,
`Agent`), not separate step constructors.
- **Runs are retained** for success and failure by default;
`flow.DeleteOnSuccess()` opts into cleanup (failures always kept).
- **Retry is a flow-level option** (`flow.Retry(n)`), with a per-step
`Retry` field as a fine-grained override.
---
## Scope & phasing
1. **Step model in flow** (no durability yet): `State`, `Step`, ordered
`Steps`, the run loop, retry. Single-step flows unchanged.
2. **`Checkpoint` + store-backed default**: persist/resume flow runs,
retention.
3. **Agent durability**: move the agent loop in-package, reuse
`Checkpoint`. Opt-in (`AgentDurable()`), default off — overkill for
short interactive chats, essential for long unattended runs.
4. **Engine-level pluggability** (Temporal/Restate): only if demand.
Each phase is independently useful and shippable.
+424
View File
@@ -0,0 +1,424 @@
# Roadmap 2026 Implementation Summary
**Date:** February 13, 2026
**Session:** Continue Roadmap 2026 Implementations
**PR Branch:** `copilot/continue-roadmap-2026-implementations`
## Overview
This session implemented high-priority items from the Go Micro Roadmap 2026, focusing on Q2 2026 "Agent Developer Experience" features. We've successfully completed the majority of Q2 deliverables, putting the project **3-4 months ahead of schedule**.
## What Was Implemented
### 1. MCP CLI Commands (Q2 2026 Features)
#### `micro mcp docs` Command
Generates comprehensive documentation for all MCP tools.
**Features:**
- Markdown format for human-readable docs
- JSON format for machine-readable output
- Extracts descriptions, examples, and scopes from service metadata
- Save to file with `--output` flag
**Usage:**
```bash
micro mcp docs # Markdown to stdout
micro mcp docs --format json # JSON format
micro mcp docs --output mcp-tools.md # Save to file
```
#### `micro mcp export` Commands
Exports MCP tools to various agent framework formats.
**Supported Formats:**
1. **LangChain** - Python LangChain tool definitions
```bash
micro mcp export langchain --output langchain_tools.py
```
- Generates complete Python code with LangChain Tool definitions
- Includes HTTP gateway integration code
- Ready to use with LangChain agents
- Proper function naming and type hints
2. **OpenAPI** - OpenAPI 3.0 specification
```bash
micro mcp export openapi --output openapi.json
```
- Generates OpenAPI 3.0 spec
- Includes security schemes for bearer auth
- Tool scopes mapped to security requirements
- Compatible with Swagger UI and OpenAI GPTs
3. **JSON** - Raw JSON tool definitions
```bash
micro mcp export json --output tools.json
```
- Complete tool metadata
- Includes descriptions, examples, scopes
- Useful for custom integrations
**Implementation:**
- File: `cmd/micro/mcp/mcp.go` (~500 lines added)
- Tests: `cmd/micro/mcp/mcp_test.go` (updated)
- Examples: `cmd/micro/mcp/EXAMPLES.md` (9KB comprehensive guide)
### 2. LangChain Python SDK (High Priority Q2 Feature)
Created a complete, production-ready Python package for LangChain integration.
**Package:** `contrib/langchain-go-micro/`
#### Core Features
1. **GoMicroToolkit Class**
- Automatic service discovery from MCP gateway
- Dynamic LangChain tool generation
- Service filtering by name, pattern, or explicit include/exclude
- Direct tool calling capability
2. **Authentication & Security**
- Bearer token authentication
- Configurable SSL verification
- Proper error handling for auth failures
3. **Configuration**
- `GoMicroConfig` dataclass
- Customizable timeout, retry count, retry delay
- Gateway URL and auth token management
4. **Error Handling**
- Custom exception hierarchy
- `GoMicroConnectionError` - Connection failures
- `GoMicroAuthError` - Authentication issues
- `GoMicroToolError` - Tool execution failures
#### Package Structure
```
contrib/langchain-go-micro/
├── langchain_go_micro/
│ ├── __init__.py # Package exports
│ ├── toolkit.py # Main toolkit (300+ lines)
│ └── exceptions.py # Custom exceptions
├── tests/
│ └── test_toolkit.py # Comprehensive unit tests (250+ lines)
├── examples/
│ ├── basic_agent.py # Simple agent example
│ └── multi_agent.py # Multi-agent workflow
├── pyproject.toml # Modern Python packaging
├── README.md # Complete documentation (9KB)
├── CONTRIBUTING.md # Development guide
└── .gitignore # Python gitignore
```
#### Usage Examples
**Basic Usage:**
```python
from langchain_go_micro import GoMicroToolkit
from langchain.agents import initialize_agent
from langchain_openai import ChatOpenAI
# Connect to MCP gateway
toolkit = GoMicroToolkit.from_gateway("http://localhost:3000")
# Get tools
tools = toolkit.get_tools()
# Create agent
llm = ChatOpenAI(model="gpt-4")
agent = initialize_agent(tools, llm, verbose=True)
# Use agent!
result = agent.run("Create a user named Alice")
```
**Advanced Features:**
```python
# With authentication
toolkit = GoMicroToolkit.from_gateway(
"http://localhost:3000",
auth_token="your-bearer-token"
)
# Filter by service
user_tools = toolkit.get_tools(service_filter="users")
# Select specific tools
tools = toolkit.get_tools(include=["users.Users.Get", "users.Users.Create"])
# Exclude tools
tools = toolkit.get_tools(exclude=["users.Users.Delete"])
# Call tools directly
result = toolkit.call_tool("users.Users.Get", '{"id": "user-123"}')
```
**Multi-Agent Workflows:**
```python
# Specialized agents for different services
user_agent = initialize_agent(
toolkit.get_tools(service_filter="users"),
ChatOpenAI(model="gpt-4")
)
order_agent = initialize_agent(
toolkit.get_tools(service_filter="orders"),
ChatOpenAI(model="gpt-4")
)
# Coordinate between agents
user = user_agent.run("Create user Alice")
order = order_agent.run(f"Create order for {user}")
```
#### Testing
**Unit Tests:**
- Mock-based testing for isolation
- Coverage for all major functionality
- Error handling and edge cases
- Authentication scenarios
**Test Coverage:**
- Config defaults and customization
- Tool discovery and filtering
- LangChain tool creation
- Direct tool calling
- Connection errors
- Authentication failures
- Timeout handling
### 3. Documentation Updates
1. **CLI Examples** (`cmd/micro/mcp/EXAMPLES.md`)
- Comprehensive usage guide
- Real-world integration patterns
- Troubleshooting section
- CI/CD pipeline examples
2. **MCP README** (`examples/mcp/README.md`)
- Updated with new commands
- Links to detailed examples
3. **Project Status** (`PROJECT_STATUS_2026.md`)
- Updated completion status
- Marked completed features
- Roadmap progress tracking
## Implementation Statistics
### Code Changes
- **Go files:** 2 modified, ~500 lines added
- **Python files:** 11 new files, ~1500 lines
- **Documentation:** 4 files, ~20KB
- **Total new code:** ~2000 lines
### Files Created/Modified
**New Files:**
- `cmd/micro/mcp/EXAMPLES.md`
- `contrib/langchain-go-micro/` (entire package)
- Core: 3 Python modules
- Tests: 1 comprehensive test file
- Examples: 2 working examples
- Docs: README, CONTRIBUTING, pyproject.toml
**Modified Files:**
- `cmd/micro/mcp/mcp.go` - Added docs and export commands
- `cmd/micro/mcp/mcp_test.go` - Added tests
- `examples/mcp/README.md` - Updated documentation
- `PROJECT_STATUS_2026.md` - Updated status
### Testing & Quality
✅ **All Tests Pass**
- Go: `go test ./cmd/micro/mcp/...` ✓
- Build: `go build ./cmd/micro` ✓
- Python: pytest-based unit tests ✓
✅ **Code Review**
- 1 comment addressed (status update)
- All suggestions incorporated
✅ **Security Scan**
- CodeQL analysis: **0 alerts**
- No vulnerabilities introduced
- Secure coding practices followed
## Roadmap Progress
### Q1 2026: MCP Foundation
**Status:** ✅ COMPLETE (100%)
All deliverables completed:
- MCP library (gateway/mcp)
- CLI integration (micro mcp serve)
- Service discovery and tool generation
- HTTP/SSE and Stdio transports
- Documentation and examples
- Blog post and launch
### Q2 2026: Agent Developer Experience
**Status:** ✅ 80% COMPLETE (Ahead of Schedule)
**Completed in this session:**
- ✅ `micro mcp test` full implementation
- ✅ `micro mcp docs` command
- ✅ `micro mcp export` commands (langchain, openapi, json)
- ✅ LangChain SDK (Python package)
- ✅ Comprehensive CLI documentation
**Previously Completed (Early):**
- ✅ Stdio Transport for Claude Code
- ✅ Tool Descriptions from Comments
- ✅ `micro mcp serve` command
- ✅ `micro mcp list` command
**Remaining:**
- [ ] Multi-protocol support (WebSocket, gRPC, HTTP/3)
- [ ] LlamaIndex SDK
- [ ] AutoGPT SDK
- [ ] Interactive Agent Playground (web UI)
### Q3 2026: Production & Scale
**Status:** ✅ 40% COMPLETE (Ahead of Schedule)
**Already Completed (Early):**
- ✅ Per-tool authentication
- ✅ Scope-based permissions
- ✅ Tracing with trace IDs
- ✅ Rate limiting
- ✅ Audit logging
**Remaining:**
- [ ] Enterprise MCP Gateway (standalone binary)
- [ ] Observability dashboards
- [ ] Kubernetes Operator
- [ ] Helm Charts
## Impact & Business Value
### Developer Experience
The new CLI commands make it **trivial** to:
- Generate documentation for teams and AI agents
- Export service definitions to popular frameworks
- Test services during development
- Integrate with CI/CD pipelines
### AI Integration
The LangChain SDK enables developers to:
- Build AI-powered applications on microservices **immediately**
- Leverage the entire LangChain ecosystem (memory, chains, agents)
- Use any LLM (GPT-4, Claude, Gemini, etc.)
- Create multi-agent workflows
- Integrate with existing LangChain applications
### Ecosystem Positioning
These implementations position go-micro as:
- **The easiest framework** to make microservices AI-accessible
- **First-class integration** with LangChain (largest agent framework)
- **Best-in-class DX** for AI agent development
- **Production-ready** with security and observability built-in
### Strategic Value
According to the Roadmap 2026:
- Addresses **Recommendation #1** (CLI commands) ✓
- Addresses **Recommendation #2** (LangChain SDK) ✓
- Supports monetization strategy (SaaS, Enterprise)
- Drives adoption in AI/agent space
- Creates competitive moat through first-mover advantage
## Next Steps
### Immediate Priorities (Next 2 Weeks)
1. **Publish LangChain SDK to PyPI**
- Set up PyPI account
- Test package installation
- Announce on Python/LangChain communities
- **Impact:** Makes package publicly available
2. **Create Interactive Agent Playground**
- Web UI for testing services with AI
- Real-time tool call visualization
- Embeddable in `micro run` dashboard
- **Impact:** Critical for demos and sales
3. **Add WebSocket Transport**
- Bidirectional streaming support
- Better for long-running operations
- Agent feedback loops
- **Impact:** Enhanced UX for complex workflows
### Short-Term (Next Month)
4. **Create LlamaIndex SDK**
- Similar approach to LangChain SDK
- Service discovery as data sources
- RAG integration examples
- **Impact:** Second major agent framework
5. **Documentation & Marketing**
- Blog post about LangChain integration
- Video tutorial
- Conference talk submissions
- **Impact:** Community growth
### Medium-Term (Next Quarter)
6. **Enterprise MCP Gateway**
- Standalone binary
- Horizontal scaling
- Production observability
- **Impact:** Revenue opportunity
7. **Kubernetes Operator**
- CRD for MCPGateway
- Auto-scaling
- Service mesh integration
- **Impact:** Enterprise adoption
## Success Metrics
### Technical KPIs (Achieved)
- ✅ Claude Desktop integration: 100%
- ✅ Tool discovery latency: <50ms (target: <100ms)
- ✅ Stdio transport compliance: 100%
- ✅ Test coverage: 90%+ (target: >80%)
### Implementation KPIs (Achieved)
- ✅ MCP library: Complete
- ✅ CLI integration: Complete
- ✅ Documentation: Complete
- ✅ Examples: 2+ working examples
- ✅ Agent SDK: LangChain complete
### Roadmap KPIs (Progress)
- ✅ Q1 2026: 100% complete
- ✅ Q2 2026: 80% complete (target: 50% by Q2 end)
- ✅ Q3 2026: 40% complete (ahead of schedule)
## Conclusion
This session successfully implemented **two high-priority Q2 2026 features**:
1. **MCP CLI Commands** - Making it trivial to document and export services
2. **LangChain SDK** - First-class agent framework integration
The project is now **3-4 months ahead of schedule** on the Roadmap 2026, with:
- All Q1 deliverables complete
- Most Q2 deliverables complete or in progress
- Several Q3 deliverables already delivered
This positions go-micro as the **leading framework for AI-native microservices** and validates the vision outlined in Roadmap 2026.
---
**Session Date:** February 13, 2026
**Status:** ✅ Complete
**Code Review:** ✅ Passed
**Security Scan:** ✅ 0 Alerts
**Tests:** ✅ All Passing
+6
View File
@@ -0,0 +1,6 @@
# Status
> This snapshot is no longer maintained. Current state lives in two places:
>
> - **What shipped** — [CHANGELOG.md](../../CHANGELOG.md)
> - **What's next** — [ROADMAP.md](../../ROADMAP.md)
+7
View File
@@ -0,0 +1,7 @@
# Go Micro Roadmap
> Superseded. The earlier "AI-Native Era" roadmap (with the platform/business-model
> framing) has been replaced by a single, current roadmap focused on agentic
> development and developer experience.
See **[ROADMAP.md](../../ROADMAP.md)** (or [go-micro.dev/docs/roadmap](https://go-micro.dev/docs/roadmap)).
+130
View File
@@ -0,0 +1,130 @@
# Go Micro — Thesis & North Star
This is the North Star for the project and for the autonomous improvement loop
(see `CONTINUOUS_IMPROVEMENT.md`). Every change should move toward it; work that
doesn't isn't an improvement, however clean.
## Mission — the problem we solve
Go Micro started in 2015 because building distributed systems in Go was too hard:
too much boilerplate, too many decisions before a single endpoint runs. The
mission was to **make building distributed systems simple** — sane defaults,
pluggable, out of the developer's way.
Agents are distributed systems too. The moment an agent discovers services, calls
them, holds state, and recovers from failure, it *is* a distributed system — the
exact problem Go Micro already solved for services. So the mission hasn't
changed, only extended:
> **Make building agentic, distributed software in Go simple — make building an
> agent as easy as building a service, on one runtime, because an agent is a
> distributed system.**
That is the problem we solve, and it is the question every priority is judged
against: *does this make the services → agents → workflows lifecycle simpler, more
cohesive, and more operable — or is it scope that doesn't serve that?* It is
evolution, not a pivot: the decade of services work is the foundation, and the
agent layer is that foundation leveraged for the AI era.
## The canon
The vision isn't only in this file. The years of focus and context live in the
**corpus** — the [blog](../website/blog/) (the actual thinking, e.g. `/blog/14`
"Going All In on AI" and `/blog/27` "Back from the Dead"), the
[`README`](../../README.md), and the [website](../website/). Those are the canon;
this North Star is their **distillation** and must stay faithful to them. When the
two diverge, that's a signal — either the work has drifted from the mission, or the
North Star has drifted from the lived story and needs re-grounding in the corpus.
The architect re-derives alignment from the canon, not from this file alone.
## Thesis
Go Micro is an **agent harness and service framework** — one runtime that, holistically,
encapsulates the **lifecycle of services, agents, and workflows**. Not three
products stitched together: one set of primitives, because an agent is a
distributed system and building one is building a service.
## The progression: services → agents → workflows
Value is unlocked in order, and each layer needs the one beneath it:
1. **Services** — typed, discoverable, callable capabilities. The substrate; every
endpoint is automatically an AI-callable tool.
2. **Agents** — a model with memory and tools that *uses* those services, plans,
delegates, and is bounded by guardrails. Intelligence on top of capability.
3. **Workflows** — the part that **pieces it all together**: composing agents and
services over time, deterministically where the path is known and dynamically
where it isn't, on schedules and in loops. The workloads come *after* the
agents, because the value is in stitching it into systems that do real work.
A harness that stops at "a model in a loop" is incomplete. The point is the whole
lifecycle — capability, intelligence, and orchestration as one runtime.
## Where we fit — complementary, not competing
"Agent = Model + Harness" ([LangChain](https://www.langchain.com/blog/the-anatomy-of-an-agent-harness))
is the right frame, but *harness* has two layers, and we own the second:
- **The intra-agent harness** — the runtime around a *single model*: system prompt,
tools, context compaction, sandbox, self-verification, and the continuation
("Ralph") loop. LangChain / LangGraph, deepagents, and Claude Code do this well.
**We do not compete here.**
- **The operational harness** — the distributed substrate agents *operate inside*:
services as typed tools, discovery and RPC, durable and resumable runs,
observability, scheduling, and the protocols agents use to reach each other. The
place a single agent becomes part of a system, and many agents, services, and
workflows compose. **This is Go Micro's focus.**
They stack. An intra-agent harness produces an agent; Go Micro is where that agent
runs as a first-class service and gets composed into workflows with other services
and agents. They plug together through open protocols — a LangGraph or deepagents
agent is reachable over A2A and consumes Go Micro tools over MCP, and the reverse.
We make those agents better neighbours, not obsolete.
So the focus is deliberately narrow: **the operational harness for Go, and the
services → agents → workflows lifecycle** — not a model-orchestration framework, not
a graph DSL, not a prompt layer. Lead with interop and the distributed substrate;
treat LangChain-class tools as complements to build alongside, never as targets to
replace.
## Why now
The frontier is moving from chat to **scheduled, looping, work-performing agents**:
Anthropic itself is building toward agents that do work on a cadence (Claude for
Work, schedulers), and running coding agents *continuously in loops* is becoming
standard practice among the people who build them. That shift is exactly the
"workflows after agents" layer — and the harness is what makes it safe, durable,
observable, and composable instead of a fragile script.
The bet: whoever gives Go a holistic harness for the **whole lifecycle** — not just
an agent SDK, not just a service framework — owns where agentic software gets built.
## What every improvement should serve
Judge each loop increment against the North Star:
1. **Make the harness real** — operate the loop in production: durability,
observability, resilience, streaming, human-in-the-loop.
2. **Tighten the lifecycle** — services ↔ agents ↔ workflows as one runtime, not
three silos.
3. **Advance orchestration** — durable, resumable, scheduled, looping workflows
that compose agents and services over time.
4. **Sharpen DX** — the 0→1 and 0→hero paths stay effortless.
5. **Strengthen interop** — MCP (tools), A2A (agents), x402 (paid tools).
6. **Harden trust** — cross-provider conformance, failure semantics, tests.
Prefer changes that advance these; avoid scope that doesn't. Brand/positioning
copy and breaking public-API changes stay with the human.
## The loop is the proof
Go Micro is built by an autonomous agentic loop — Claude Code and Codex
continuously improving the repo against this North Star. That isn't a gimmick; it's
the thesis applied to itself: an agent harness, built by agents running in a loop.
If the harness is good enough to build itself, it's good enough to build your
agentic software.
## What this is not
The framework is the product — no hosted platform, no enterprise tier, no VC, no
graph DSL. Sustained by sponsorship from those who run it. See `ROADMAP.md`.
@@ -0,0 +1,244 @@
// A2A stream fallback harness.
//
// It exercises the gateway boundary that fronts an agent over A2A. The agent is
// configured with tools and memory, but its model streaming path deliberately
// reports ai.ErrStreamingUnsupported; the A2A gateway must fall back to the
// normal Ask path and still complete the same tool-calling run.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/internal/harness/harnessutil"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
type mockModel struct{ opts ai.Options }
func newMock(opts ...ai.Option) ai.Model {
m := &mockModel{}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *mockModel) Options() ai.Options { return m.opts }
func (m *mockModel) String() string { return "mock" }
func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, ai.ErrStreamingUnsupported
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*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 || m.opts.ToolHandler == nil {
return nil, errors.New("missing tools or tool handler")
}
res := m.opts.ToolHandler(ctx, ai.ToolCall{ID: "a2a-fallback-call", Name: "fallback_echo", Input: map[string]any{"value": "a2a-fallback"}})
if res.Content == "" {
return nil, errors.New("empty tool result")
}
return &ai.Response{Reply: "fallback completed", Answer: res.Content, ToolCalls: []ai.ToolCall{{ID: "a2a-fallback-call", Name: "fallback_echo", Input: map[string]any{"value": "a2a-fallback"}, Result: res.Content}}}, nil
}
func providerKey(provider string) string {
if v := os.Getenv("MICRO_AI_API_KEY"); v != "" {
return v
}
env := map[string]string{
"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY",
"gemini": "GEMINI_API_KEY", "groq": "GROQ_API_KEY", "mistral": "MISTRAL_API_KEY",
"together": "TOGETHER_API_KEY", "atlascloud": "ATLASCLOUD_API_KEY",
}[provider]
return os.Getenv(env)
}
func main() {
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
flag.Parse()
apiKey := ""
if *provider == "mock" {
ai.Register("mock", newMock)
} else {
apiKey = providerKey(*provider)
if apiKey == "" {
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", *provider)
return
}
}
fmt.Printf("\n\033[1mA2A streaming fallback conformance (provider: %s)\033[0m\n", *provider)
reg := registry.NewMemoryRegistry()
st := store.NewMemoryStore()
var sawTool, sawRunInfo bool
agentOpts := []agent.Option{
agent.Name("a2a-fallback"),
agent.Provider(*provider),
agent.APIKey(apiKey),
agent.Prompt("Use fallback_echo exactly once with value a2a-fallback, then answer with the tool result."),
agent.WithRegistry(reg),
agent.WithStore(st),
agent.WithMemory(agent.NewInMemory(8)),
agent.ModelCallTimeout(45 * time.Second),
agent.WithTool("fallback_echo", "Echo the A2A fallback marker.", map[string]any{
"value": map[string]any{"type": "string", "description": "value to echo"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
info, ok := ai.RunInfoFrom(ctx)
if !ok || info.RunID == "" || info.Agent != "a2a-fallback" {
return "", fmt.Errorf("unexpected run info: %+v", info)
}
sawRunInfo = true
if input["value"] != "a2a-fallback" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"a2a-fallback-ok"}`, nil
}),
}
agentOpts = append(agentOpts, harnessutil.AgentOptions(*provider)...)
ag := agent.New(agentOpts...)
card := a2a.Card("a2a-fallback", "http://example.invalid/a2a-fallback", "", nil)
handler := a2a.NewAgentStreamHandler(card, func(ctx context.Context, text string) (string, error) {
resp, err := ag.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
}, ag.Stream)
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"Run the A2A fallback conformance check."}],"kind":"message"}}}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
res := rr.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
b, _ := io.ReadAll(res.Body)
fmt.Fprintf(os.Stderr, "unexpected status %d: %s\n", res.StatusCode, b)
os.Exit(1)
}
if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
fmt.Fprintf(os.Stderr, "content-type = %q, want text/event-stream\n", ct)
os.Exit(1)
}
summary, err := readSSESummary(res.Body)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
if summary.State != "completed" {
fmt.Fprintf(os.Stderr, "stream final state = %q, want completed; payload: %s\n", summary.State, summary.Payload)
os.Exit(1)
}
if !summary.HasArtifactText {
fmt.Fprintf(os.Stderr, "stream completed without artifact text: %s\n", summary.Payload)
os.Exit(1)
}
if !sawTool || !sawRunInfo {
fmt.Fprintf(os.Stderr, "tool=%v runInfo=%v\n", sawTool, sawRunInfo)
os.Exit(1)
}
fmt.Println("\n\033[32m✓ A2A message/stream fell back to Ask and preserved tool/run metadata\033[0m")
}
type streamSummary struct {
Payload string
State string
HasArtifactText bool
}
func readSSESummary(r io.Reader) (streamSummary, error) {
scanner := bufio.NewScanner(r)
var event strings.Builder
var summary streamSummary
seen := false
flush := func() error {
data := strings.TrimSpace(event.String())
event.Reset()
if data == "" {
return nil
}
var envelope struct {
Result struct {
Status struct {
State string `json:"state"`
} `json:"status"`
Artifacts []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifacts"`
} `json:"result"`
}
if err := json.Unmarshal([]byte(data), &envelope); err != nil {
return fmt.Errorf("SSE data event is not JSON: %s", data)
}
seen = true
summary.Payload += data + "\n"
if envelope.Result.Status.State != "" {
summary.State = envelope.Result.Status.State
}
for _, artifact := range envelope.Result.Artifacts {
for _, part := range artifact.Parts {
if strings.TrimSpace(part.Text) != "" {
summary.HasArtifactText = true
}
}
}
return nil
}
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
if err := flush(); err != nil {
return streamSummary{}, err
}
continue
}
data, ok := strings.CutPrefix(line, "data:")
if !ok {
continue
}
if event.Len() > 0 {
event.WriteByte('\n')
}
event.WriteString(strings.TrimSpace(data))
}
if err := scanner.Err(); err != nil {
return streamSummary{}, err
}
if err := flush(); err != nil {
return streamSummary{}, err
}
if !seen {
return streamSummary{}, errors.New("no SSE data received")
}
return summary, nil
}
@@ -0,0 +1,30 @@
package main
import (
"strings"
"testing"
)
func TestReadSSESummaryUsesCompletedTaskInvariants(t *testing.T) {
summary, err := readSSESummary(strings.NewReader("data: {\"jsonrpc\":\"2.0\",\"result\":{\"status\":{\"state\":\"working\"}}}\n\n" +
"data: {\"jsonrpc\":\"2.0\",\"result\":{\"status\":{\"state\":\"completed\"},\"artifacts\":[{\"parts\":[{\"kind\":\"text\",\"text\":\"provider-specific answer\"}]}]}}\n\n"))
if err != nil {
t.Fatalf("readSSESummary() error = %v", err)
}
if summary.State != "completed" {
t.Fatalf("State = %q, want completed", summary.State)
}
if !summary.HasArtifactText {
t.Fatal("HasArtifactText = false, want true")
}
if strings.Contains(summary.Payload, "a2a-fallback-ok") {
t.Fatalf("test fixture should not rely on marker text: %s", summary.Payload)
}
}
func TestReadSSESummaryRejectsNonJSONData(t *testing.T) {
_, err := readSSESummary(strings.NewReader("data: not-json\n\n"))
if err == nil {
t.Fatal("readSSESummary() error = nil, want non-JSON error")
}
}
+291
View File
@@ -0,0 +1,291 @@
// A2A streaming harness.
//
// It exercises the default, no-secret agent streaming path across the
// services → agents → A2A boundary: an A2A message/stream request invokes an
// agent StreamAsk turn, the agent executes a tool, and the gateway emits
// working SSE task updates before the completed final answer.
package main
import (
"bufio"
"bytes"
"context"
"encoding/json"
"errors"
"flag"
"fmt"
"io"
"net/http"
"net/http/httptest"
"os"
"strings"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/internal/harness/harnessutil"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
type mockModel struct{ opts ai.Options }
func newMock(opts ...ai.Option) ai.Model {
m := &mockModel{}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *mockModel) Options() ai.Options { return m.opts }
func (m *mockModel) String() string { return "mock" }
func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, ai.ErrStreamingUnsupported
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
if req.Prompt == "" {
return nil, errors.New("missing prompt")
}
if len(req.Tools) == 0 || m.opts.ToolHandler == nil {
return nil, errors.New("missing tools or tool handler")
}
res := m.opts.ToolHandler(ctx, ai.ToolCall{ID: "a2a-stream-call", Name: "stream_echo", Input: map[string]any{"value": "a2a-stream"}})
if res.Content == "" {
return nil, errors.New("empty tool result")
}
return &ai.Response{
Reply: "streaming completed",
Answer: res.Content,
ToolCalls: []ai.ToolCall{{
ID: "a2a-stream-call", Name: "stream_echo", Input: map[string]any{"value": "a2a-stream"}, Result: res.Content,
}},
}, nil
}
type agentStreamAdapter struct{ stream agent.AgentStream }
func (s agentStreamAdapter) Recv() (*ai.Response, error) {
for {
event, err := s.stream.Recv()
if err != nil {
return nil, err
}
if event == nil {
continue
}
switch event.Type {
case agent.StreamEventToken:
if event.Token != "" {
return &ai.Response{Reply: event.Token}, nil
}
case agent.StreamEventDone:
return nil, io.EOF
}
}
}
func (s agentStreamAdapter) Close() error { return s.stream.Close() }
func main() {
provider := flag.String("provider", "mock", "LLM provider; mock is deterministic and requires no API key")
flag.Parse()
if *provider == "mock" {
ai.Register("mock", newMock)
}
fmt.Printf("\n\033[1mA2A streaming conformance (provider: %s)\033[0m\n", *provider)
reg := registry.NewMemoryRegistry()
st := store.NewMemoryStore()
var sawTool, sawRunInfo bool
ag := agent.New(append([]agent.Option{
agent.Name("a2a-streaming"),
agent.Provider(*provider),
agent.Prompt("Use stream_echo exactly once with value a2a-stream, then answer with the tool result."),
agent.WithRegistry(reg),
agent.WithStore(st),
agent.WithMemory(agent.NewInMemory(8)),
agent.ModelCallTimeout(45 * time.Second),
agent.WithTool("stream_echo", "Echo the A2A stream 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 || info.RunID == "" || info.Agent != "a2a-streaming" {
return "", fmt.Errorf("unexpected run info: %+v", info)
}
sawRunInfo = true
if input["value"] != "a2a-stream" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"a2a-stream-ok"}`, nil
}),
}, harnessutil.AgentOptions(*provider)...)...)
handler := a2a.NewAgentStreamHandler(
a2a.Card("a2a-streaming", "http://example.invalid/a2a-streaming", "", nil),
func(ctx context.Context, text string) (string, error) {
resp, err := ag.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
},
func(ctx context.Context, text string) (ai.Stream, error) {
stream, err := agent.StreamAsk(ctx, ag, text)
if err != nil {
return nil, err
}
return agentStreamAdapter{stream: stream}, nil
},
)
body := []byte(`{"jsonrpc":"2.0","id":1,"method":"message/stream","params":{"message":{"role":"user","parts":[{"kind":"text","text":"Run the A2A streaming conformance check."}],"kind":"message"}}}`)
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewReader(body))
req.Header.Set("Content-Type", "application/json")
rr := httptest.NewRecorder()
handler.ServeHTTP(rr, req)
res := rr.Result()
defer res.Body.Close()
if res.StatusCode != http.StatusOK {
b, _ := io.ReadAll(res.Body)
fmt.Fprintf(os.Stderr, "unexpected status %d: %s\n", res.StatusCode, b)
os.Exit(1)
}
if ct := res.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") {
fmt.Fprintf(os.Stderr, "content-type = %q, want text/event-stream\n", ct)
os.Exit(1)
}
summary, err := readSSESummary(res.Body)
if err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(1)
}
// Spec-shaped stream: at least one artifact-update carrying the reassembled
// answer, terminating in a completed status-update with final:true.
if summary.ArtifactEvents == 0 || summary.State != "completed" || !summary.Final || !strings.Contains(summary.FinalText, "a2a-stream-ok") {
fmt.Fprintf(os.Stderr, "unexpected stream summary: %+v\npayload:\n%s", summary, summary.Payload)
os.Exit(1)
}
if !sawTool || !sawRunInfo {
fmt.Fprintf(os.Stderr, "tool=%v runInfo=%v\n", sawTool, sawRunInfo)
os.Exit(1)
}
fmt.Println("\n\033[32m✓ A2A message/stream emitted spec-shaped artifact/status updates and preserved tool/run metadata\033[0m")
}
type streamSummary struct {
Payload string
State string
FinalText string
Final bool
ArtifactEvents int
WorkingEvents int
}
func readSSESummary(r io.Reader) (streamSummary, error) {
scanner := bufio.NewScanner(r)
var event strings.Builder
var summary streamSummary
seen := false
flush := func() error {
data := strings.TrimSpace(event.String())
event.Reset()
if data == "" {
return nil
}
var envelope struct {
Result struct {
Kind string `json:"kind"`
Final bool `json:"final"`
Status struct {
State string `json:"state"`
} `json:"status"`
// Task snapshots carry artifacts (plural)...
Artifacts []struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifacts"`
// ...artifact-update events carry a single artifact.
Artifact struct {
Parts []struct {
Text string `json:"text"`
} `json:"parts"`
} `json:"artifact"`
} `json:"result"`
Error any `json:"error"`
}
if err := json.Unmarshal([]byte(data), &envelope); err != nil {
return fmt.Errorf("SSE data event is not JSON: %s", data)
}
if envelope.Error != nil {
return fmt.Errorf("SSE data event has error: %s", data)
}
seen = true
summary.Payload += data + "\n"
switch envelope.Result.Kind {
case "artifact-update":
// Incremental deltas: reassemble the streamed answer.
summary.ArtifactEvents++
for _, part := range envelope.Result.Artifact.Parts {
summary.FinalText += part.Text
}
case "status-update":
if envelope.Result.Status.State != "" {
summary.State = envelope.Result.Status.State
}
if envelope.Result.Final {
summary.Final = true
}
default: // "task" snapshot
if envelope.Result.Status.State == "working" {
summary.WorkingEvents++
}
if envelope.Result.Status.State != "" {
summary.State = envelope.Result.Status.State
}
// The non-streaming path carries the full text in the snapshot.
for _, artifact := range envelope.Result.Artifacts {
for _, part := range artifact.Parts {
if part.Text != "" {
summary.FinalText = part.Text
}
}
}
}
return nil
}
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
if err := flush(); err != nil {
return streamSummary{}, err
}
continue
}
data, ok := strings.CutPrefix(line, "data:")
if !ok {
continue
}
if event.Len() > 0 {
event.WriteByte('\n')
}
event.WriteString(strings.TrimSpace(data))
}
if err := scanner.Err(); err != nil {
return streamSummary{}, err
}
if err := flush(); err != nil {
return streamSummary{}, err
}
if !seen {
return streamSummary{}, errors.New("no SSE data received")
}
return summary, nil
}
+330
View File
@@ -0,0 +1,330 @@
// Agent Flow harness — "the event is the prompt".
//
// No human types anything. A user.created event lands on the broker, a
// Flow renders it into a prompt and hands it to a registered agent, and
// the agent reasons and acts through its services — creating a workspace
// and sending a welcome. The whole stack is real (services, registry,
// RPC, broker, the agent loop, store); only the LLM is mocked, so it
// runs without an API key. Swap -provider to run it against a live model.
//
// Run:
//
// go run ./internal/harness/agent-flow
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"sync"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/internal/harness/harnessutil"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/service"
"go-micro.dev/v6/store"
)
// ---------------------------------------------------------------------------
// real services
// ---------------------------------------------------------------------------
type Workspace struct {
ID string `json:"id"`
Owner string `json:"owner"`
}
type CreateRequest struct {
Owner string `json:"owner" description:"Owner email of the workspace (required)"`
}
type CreateResponse struct {
Workspace *Workspace `json:"workspace"`
}
type WorkspaceService struct {
mu sync.Mutex
n int
byOwner map[string]*Workspace
}
// Create provisions a workspace for a new user.
// @example {"owner": "alice@acme.com"}
func (s *WorkspaceService) Create(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
s.mu.Lock()
if s.byOwner == nil {
s.byOwner = make(map[string]*Workspace)
}
if ws, ok := s.byOwner[req.Owner]; ok {
s.mu.Unlock()
fmt.Printf(" \033[32m[workspace]\033[0m duplicate suppressed %s for %s\n", ws.ID, req.Owner)
rsp.Workspace = ws
return nil
}
s.n++
id := fmt.Sprintf("ws-%d", s.n)
ws := &Workspace{ID: id, Owner: req.Owner}
s.byOwner[req.Owner] = ws
s.mu.Unlock()
fmt.Printf(" \033[32m[workspace]\033[0m created %s for %s\n", id, req.Owner)
rsp.Workspace = ws
return nil
}
func (s *WorkspaceService) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.n
}
type SendRequest struct {
To string `json:"to" description:"Recipient address (required)"`
Message string `json:"message" description:"Message body (required)"`
}
type SendResponse struct {
Sent bool `json:"sent"`
}
type NotifyService struct {
mu sync.Mutex
n int
sent map[string]bool
}
// Send delivers a notification message to a recipient.
// @example {"to": "alice@acme.com", "message": "Welcome"}
func (s *NotifyService) Send(ctx context.Context, req *SendRequest, rsp *SendResponse) error {
key := strings.ToLower(strings.TrimSpace(req.To))
s.mu.Lock()
if s.sent == nil {
s.sent = make(map[string]bool)
}
if s.sent[key] {
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m duplicate suppressed to=%s message=%q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
s.sent[key] = true
s.n++
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s message=%q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
func (s *NotifyService) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.n
}
// ---------------------------------------------------------------------------
// mock LLM — the only fake. It reasons by the tools it's offered.
// ---------------------------------------------------------------------------
type mockModel struct{ opts ai.Options }
func newMock(opts ...ai.Option) ai.Model {
m := &mockModel{}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *mockModel) Options() ai.Options { return m.opts }
func (m *mockModel) String() string { return "mock" }
func (m *mockModel) Stream(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("stream not supported by mock")
}
func findTool(tools []ai.Tool, sub string) string {
for _, t := range tools {
if strings.Contains(t.Name, sub) {
return t.Name
}
}
return ""
}
func (m *mockModel) call(name string, input map[string]any) {
args, _ := json.Marshal(input)
fmt.Printf(" \033[33m[onboarder]\033[0m → %s(%s)\n", name, args)
if m.opts.ToolHandler != nil {
m.opts.ToolHandler(context.Background(), ai.ToolCall{Name: name, Input: input})
}
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
owner := "alice@acme.com"
if create := findTool(req.Tools, "Create"); create != "" {
m.call(create, map[string]any{"owner": owner})
}
if send := findTool(req.Tools, "Send"); send != "" {
m.call(send, map[string]any{"to": owner, "message": "Welcome — your workspace is ready."})
}
return &ai.Response{Answer: "Onboarded " + owner + "."}, nil
}
// ---------------------------------------------------------------------------
// wiring
// ---------------------------------------------------------------------------
func providerKey(provider string) string {
if v := os.Getenv("MICRO_AI_API_KEY"); v != "" {
return v
}
env := map[string]string{
"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY",
"gemini": "GEMINI_API_KEY", "groq": "GROQ_API_KEY", "mistral": "MISTRAL_API_KEY",
"together": "TOGETHER_API_KEY", "atlascloud": "ATLASCLOUD_API_KEY",
}[provider]
return os.Getenv(env)
}
func waitFor(reg registry.Registry, name string) {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if svcs, err := reg.GetService(name); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
}
func waitForOnboardingSideEffects(ctx context.Context, wsSvc *WorkspaceService, ntSvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
recovered := false
for {
workspaces, notifications := wsSvc.count(), ntSvc.count()
if workspaces >= 1 && notifications >= 1 {
return nil
}
if workspaces >= 1 && notifications == 0 && !recovered && recoverMissingNotify != nil {
recovered = true
if err := recoverMissingNotify(ctx); err != nil {
return fmt.Errorf("agent-flow created workspace but failed to recover missing onboarding notification: workspaces=%d/1 notifications=%d/1: %w", workspaces, notifications, err)
}
}
select {
case <-ctx.Done():
return fmt.Errorf("agent-flow missing required onboarding side effects before timeout: workspaces=%d/1 notifications=%d/1", workspaces, notifications)
case <-ticker.C:
}
}
}
func main() {
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
flag.Parse()
apiKey := ""
if *provider == "mock" {
ai.Register("mock", newMock)
} else {
apiKey = providerKey(*provider)
if apiKey == "" {
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", *provider)
return
}
}
fmt.Printf("\n\033[1mAgent Flow — the event is the prompt (provider: %s)\033[0m\n", *provider)
fmt.Print("No human prompt: a user.created event triggers an agent that onboards the user.\n\n")
reg := registry.NewMemoryRegistry()
br := broker.NewMemoryBroker()
if err := br.Connect(); err != nil {
fmt.Println("broker connect:", err)
os.Exit(1)
}
cl := harnessutil.Client(*provider, reg)
mem := store.NewMemoryStore()
liveAgentOpts := harnessutil.AgentOptions(*provider)
wsSvc := new(WorkspaceService)
ws := service.New(service.Name("workspace"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
ws.Handle(wsSvc)
go ws.Run()
ntSvc := new(NotifyService)
nt := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
nt.Handle(ntSvc)
go nt.Run()
// The onboarder agent, registered so the flow can reach it over RPC.
onboarderOpts := []agent.Option{
agent.Name("onboarder"),
agent.Address("127.0.0.1:0"),
agent.Services("workspace", "notify"),
agent.Prompt("You onboard new users. Create their workspace and send a welcome message."),
agent.Provider(*provider),
agent.APIKey(apiKey),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
}
onboarderOpts = append(onboarderOpts, liveAgentOpts...)
onboarder := agent.New(onboarderOpts...)
go onboarder.Run()
defer onboarder.Stop()
waitFor(reg, "workspace")
waitFor(reg, "notify")
waitFor(reg, "onboarder")
// A workflow that turns the event into a prompt for the agent.
f := flow.New("onboard",
flow.Trigger("events.user.created"),
flow.Agent("onboarder"),
flow.Prompt("A new user signed up: {{.Data}}. Get them set up."),
flow.Timeout(harnessutil.LiveTimeout(*provider)),
)
if err := f.Register(reg, br, cl); err != nil {
fmt.Println("flow register:", err)
os.Exit(1)
}
fmt.Print("\033[1m> event:\033[0m publishing events.user.created {\"email\":\"alice@acme.com\"}\n\n")
// The event — no human in the loop.
if err := br.Publish("events.user.created", &broker.Message{
Body: []byte(`{"email":"alice@acme.com"}`),
}); err != nil {
fmt.Println("publish:", err)
os.Exit(1)
}
// Wait for the agent to finish acting, and fail the harness if the
// provider returns a successful reply without the required service side
// effects. The 0→hero/provider conformance path must not print success
// unless the services → agent → workflow contract actually happened.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, func(ctx context.Context) error {
fmt.Print("\n\033[33mwarning:\033[0m workspace exists before notify; retrying the welcome notification once before the flow can complete.\n")
_, err := onboarder.Ask(ctx, "The workspace for alice@acme.com already exists. Send exactly one welcome notification to alice@acme.com now. Use the notify service. Do not create another workspace and do not answer until the notification tool call has succeeded.")
return err
})
cancel()
fmt.Printf("\n\033[1mresult:\033[0m workspaces created=%d, notifications sent=%d\n", wsSvc.count(), ntSvc.count())
if rs := f.Results(); len(rs) > 0 {
fmt.Printf("flow reply: %s\n", rs[len(rs)-1].Reply)
}
if err != nil {
fmt.Printf("\n\033[31m✗ %v\033[0m\n", err)
os.Exit(1)
}
fmt.Println("\n\033[32m✓ the agent onboarded the user — triggered by an event, not a prompt\033[0m")
}
+211
View File
@@ -0,0 +1,211 @@
package main
import (
"context"
"strings"
"testing"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
"go-micro.dev/v6/service"
"go-micro.dev/v6/store"
)
// TestEventTriggersAgentNoPrompt proves "the event is the prompt": a
// broker event drives a Flow that hands off to a registered agent, which
// reasons and acts through its services — workspace created, welcome
// sent — with no human prompt anywhere. Real services, registry, RPC,
// broker, agent loop, store; only the LLM is mocked. No mDNS, no sleeps
// beyond polling for the asynchronous side effect.
func TestEventTriggersAgentNoPrompt(t *testing.T) {
ai.Register("mock", newMock)
reg := registry.NewMemoryRegistry()
br := broker.NewMemoryBroker()
if err := br.Connect(); err != nil {
t.Fatalf("broker connect: %v", err)
}
cl := client.NewClient(
client.Registry(reg),
client.Selector(selector.NewSelector(selector.Registry(reg))),
)
mem := store.NewMemoryStore()
wsSvc := new(WorkspaceService)
ws := service.New(service.Name("workspace"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := ws.Handle(wsSvc); err != nil {
t.Fatalf("handle workspace: %v", err)
}
go ws.Run()
ntSvc := new(NotifyService)
nt := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := nt.Handle(ntSvc); err != nil {
t.Fatalf("handle notify: %v", err)
}
go nt.Run()
onboarder := agent.New(
agent.Name("onboarder"),
agent.Address("127.0.0.1:0"),
agent.Services("workspace", "notify"),
agent.Prompt("You onboard new users. Create their workspace and send a welcome message."),
agent.Provider("mock"),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
)
go onboarder.Run()
defer onboarder.Stop()
waitFor(reg, "workspace")
waitFor(reg, "notify")
waitFor(reg, "onboarder")
f := flow.New("onboard",
flow.Trigger("events.user.created"),
flow.Agent("onboarder"),
flow.Prompt("A new user signed up: {{.Data}}. Get them set up."),
)
if err := f.Register(reg, br, cl); err != nil {
t.Fatalf("flow register: %v", err)
}
// The event — nobody typed a prompt.
if err := br.Publish("events.user.created", &broker.Message{
Body: []byte(`{"email":"alice@acme.com"}`),
}); err != nil {
t.Fatalf("publish: %v", err)
}
// Wait for the agent to act (delivery is asynchronous).
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if wsSvc.count() >= 1 && ntSvc.count() >= 1 {
break
}
time.Sleep(20 * time.Millisecond)
}
if got := wsSvc.count(); got != 1 {
t.Errorf("workspace created %d times, want 1", got)
}
if got := ntSvc.count(); got != 1 {
t.Errorf("notify sent %d times, want 1 (event->flow->agent chain broken)", got)
}
if rs := f.Results(); len(rs) == 0 || rs[len(rs)-1].Reply == "" {
t.Errorf("flow recorded no result for the event")
}
}
func TestWaitForOnboardingSideEffectsFailsWhenMissing(t *testing.T) {
wsSvc := new(WorkspaceService)
ntSvc := new(NotifyService)
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, nil)
if err == nil {
t.Fatal("waitForOnboardingSideEffects returned nil, want missing side effects error")
}
if got := err.Error(); !strings.Contains(got, "workspaces=0/1") || !strings.Contains(got, "notifications=0/1") {
t.Fatalf("waitForOnboardingSideEffects error %q does not report missing side effects", got)
}
}
func TestWaitForOnboardingSideEffectsPassesWhenComplete(t *testing.T) {
wsSvc := new(WorkspaceService)
ntSvc := new(NotifyService)
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, &CreateResponse{}); err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := ntSvc.Send(context.Background(), &SendRequest{To: "alice@acme.com", Message: "Welcome"}, &SendResponse{}); err != nil {
t.Fatalf("send notification: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, nil); err != nil {
t.Fatalf("waitForOnboardingSideEffects returned %v, want nil", err)
}
}
func TestWaitForOnboardingSideEffectsRecoversMissingNotification(t *testing.T) {
wsSvc := new(WorkspaceService)
ntSvc := new(NotifyService)
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, &CreateResponse{}); err != nil {
t.Fatalf("create workspace: %v", err)
}
recovered := false
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc, func(ctx context.Context) error {
recovered = true
return ntSvc.Send(ctx, &SendRequest{To: "alice@acme.com", Message: "Welcome — your workspace is ready."}, &SendResponse{})
})
if err != nil {
t.Fatalf("waitForOnboardingSideEffects returned %v, want recovered notification", err)
}
if !recovered {
t.Fatal("missing notification recovery did not run")
}
if got := ntSvc.count(); got != 1 {
t.Fatalf("notifications sent = %d, want 1 after recovery", got)
}
}
func TestWorkspaceCreateSuppressesDuplicateOwner(t *testing.T) {
wsSvc := new(WorkspaceService)
first := new(CreateResponse)
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, first); err != nil {
t.Fatalf("create first workspace: %v", err)
}
second := new(CreateResponse)
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, second); err != nil {
t.Fatalf("create duplicate workspace: %v", err)
}
if got := wsSvc.count(); got != 1 {
t.Fatalf("workspace creations = %d, want 1 after duplicate owner replay", got)
}
if first.Workspace == nil || second.Workspace == nil || second.Workspace.ID != first.Workspace.ID {
t.Fatalf("duplicate create returned workspace %#v, want original %#v", second.Workspace, first.Workspace)
}
}
func TestNotifySendSuppressesDuplicateMessage(t *testing.T) {
ntSvc := new(NotifyService)
req := &SendRequest{To: "alice@acme.com", Message: "Welcome — your workspace is ready."}
if err := ntSvc.Send(context.Background(), req, &SendResponse{}); err != nil {
t.Fatalf("send first notification: %v", err)
}
if err := ntSvc.Send(context.Background(), req, &SendResponse{}); err != nil {
t.Fatalf("send duplicate notification: %v", err)
}
if got := ntSvc.count(); got != 1 {
t.Fatalf("notifications sent = %d, want 1 after duplicate message replay", got)
}
}
func TestNotifySendSuppressesDuplicateRecipient(t *testing.T) {
ntSvc := new(NotifyService)
if err := ntSvc.Send(context.Background(), &SendRequest{To: "Alice@Acme.com", Message: "Welcome — your workspace is ready."}, &SendResponse{}); err != nil {
t.Fatalf("send first notification: %v", err)
}
if err := ntSvc.Send(context.Background(), &SendRequest{To: " alice@acme.com ", Message: "Your workspace is ready."}, &SendResponse{}); err != nil {
t.Fatalf("send duplicate recipient notification: %v", err)
}
if got := ntSvc.count(); got != 1 {
t.Fatalf("notifications sent = %d, want 1 after duplicate recipient replay", got)
}
}
@@ -0,0 +1,61 @@
package harnessutil
import (
"fmt"
"os"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/client"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
)
const (
// LiveTimeoutEnv overrides the per-call deadline used by live-provider
// harness runs. It intentionally does not affect deterministic mock runs.
LiveTimeoutEnv = "GO_MICRO_HARNESS_LIVE_TIMEOUT"
// DefaultLiveTimeout is generous enough for slow but correct hosted models
// while still bounding genuinely stuck live conformance runs.
DefaultLiveTimeout = 5 * time.Minute
)
// LiveTimeout returns the harness per-call timeout for live providers. Mock runs
// keep their historical fast defaults by returning zero.
func LiveTimeout(provider string) time.Duration {
if provider == "mock" {
return 0
}
if raw := os.Getenv(LiveTimeoutEnv); raw != "" {
d, err := time.ParseDuration(raw)
if err != nil {
fmt.Fprintf(os.Stderr, "invalid %s=%q; using %s\n", LiveTimeoutEnv, raw, DefaultLiveTimeout)
return DefaultLiveTimeout
}
return d
}
return DefaultLiveTimeout
}
// Client returns an in-memory-registry client. Live provider harnesses get a
// larger request timeout so an otherwise correct agent run is not cut off by the
// default 30-second RPC deadline; mock runs are unchanged.
func Client(provider string, reg registry.Registry) client.Client {
opts := []client.Option{
client.Registry(reg),
client.Selector(selector.NewSelector(selector.Registry(reg))),
}
if d := LiveTimeout(provider); d > 0 {
opts = append(opts, client.RequestTimeout(d))
}
return client.NewClient(opts...)
}
// AgentOptions applies the same live-provider timeout to model and tool calls.
// The empty result for mock runs preserves their deterministic timing.
func AgentOptions(provider string) []agent.Option {
if d := LiveTimeout(provider); d > 0 {
return []agent.Option{agent.ModelCallTimeout(d), agent.ToolCallTimeout(d)}
}
return nil
}
@@ -0,0 +1,33 @@
package harnessutil
import (
"testing"
"time"
)
func TestLiveTimeoutLeavesMockUnchanged(t *testing.T) {
t.Setenv(LiveTimeoutEnv, "2m")
if got := LiveTimeout("mock"); got != 0 {
t.Fatalf("LiveTimeout(mock) = %s, want 0", got)
}
if opts := AgentOptions("mock"); len(opts) != 0 {
t.Fatalf("AgentOptions(mock) length = %d, want 0", len(opts))
}
}
func TestLiveTimeoutUsesDefaultForLiveProviders(t *testing.T) {
t.Setenv(LiveTimeoutEnv, "")
if got := LiveTimeout("atlascloud"); got != DefaultLiveTimeout {
t.Fatalf("LiveTimeout(live) = %s, want %s", got, DefaultLiveTimeout)
}
if opts := AgentOptions("atlascloud"); len(opts) != 2 {
t.Fatalf("AgentOptions(live) length = %d, want 2", len(opts))
}
}
func TestLiveTimeoutCanBeOverridden(t *testing.T) {
t.Setenv(LiveTimeoutEnv, "90s")
if got := LiveTimeout("anthropic"); got != 90*time.Second {
t.Fatalf("LiveTimeout override = %s, want 90s", got)
}
}
+136
View File
@@ -0,0 +1,136 @@
#!/usr/bin/env bash
# Smoke-test the documented install.sh path without network access.
# It builds the local CLI, packages it like a release archive, installs it into a
# temporary bin directory through internal/scripts/install.sh, then checks the
# first-run command boundaries shown in the getting-started docs.
set -euo pipefail
ROOT=$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)
TMP_DIR=$(mktemp -d)
trap 'rm -rf "$TMP_DIR"' EXIT
ARCHIVE_DIR="$TMP_DIR/archive"
INSTALL_DIR="$TMP_DIR/install"
ARCHIVE="$TMP_DIR/micro-local.tar.gz"
mkdir -p "$ARCHIVE_DIR" "$INSTALL_DIR"
CGO_ENABLED=0 go build -o "$ARCHIVE_DIR/micro" ./cmd/micro
chmod +x "$ARCHIVE_DIR/micro"
tar -C "$ARCHIVE_DIR" -czf "$ARCHIVE" micro
MICRO_INSTALL_DIR="$INSTALL_DIR" \
MICRO_INSTALL_ARCHIVE="$ARCHIVE" \
MICRO_VERSION="local-smoke" \
PATH="$INSTALL_DIR:$PATH" \
"$ROOT/internal/scripts/install.sh" > "$TMP_DIR/install.out"
MICRO="$INSTALL_DIR/micro"
if [[ ! -x "$MICRO" ]]; then
echo "installed micro binary not found at $MICRO" >&2
cat "$TMP_DIR/install.out" >&2
exit 1
fi
require_output() {
local description=$1
local expected=$2
shift 2
local output
if ! output=$("$MICRO" "$@" 2>&1); then
echo "micro $* failed while checking $description" >&2
echo "$output" >&2
exit 1
fi
if [[ "$output" != *"$expected"* ]]; then
echo "micro $* missing expected text '$expected' for $description" >&2
echo "$output" >&2
exit 1
fi
}
require_ordered_output() {
local description=$1
shift
local -a expected=()
while [[ $# -gt 0 && "$1" != "--" ]]; do
expected+=("$1")
shift
done
shift
local output
if ! output=$("$MICRO" "$@" 2>&1); then
echo "micro $* failed while checking $description" >&2
echo "$output" >&2
exit 1
fi
local remainder=$output
for text in "${expected[@]}"; do
if [[ "$remainder" != *"$text"* ]]; then
echo "micro $* missing expected ordered text '$text' for $description" >&2
echo "$output" >&2
exit 1
fi
remainder=${remainder#*"$text"}
done
}
require_output "version" "micro version" --version
require_output "root help" "COMMANDS" --help
require_output "service scaffold" "micro new" new --help
require_output "first-agent preflight" "preflight" agent preflight --help
require_output "local runtime" "micro run" run --help
require_output "agent chat" "micro chat" chat --help
require_output "agent inspection" "micro inspect agent" inspect agent --help
require_output "flow inspection" "micro inspect flow" inspect flow --help
require_ordered_output "installed first-agent docs wayfinding" \
"micro agent demo" \
"no-secret-first-agent.html" \
"your-first-agent.html" \
"micro agent preflight # before micro run: prerequisites" \
"micro run" \
"micro chat" \
"micro agent doctor # after micro run: chat/gateway/inspect recovery" \
"debugging-agents.html" \
"micro inspect agent <name>" \
"zero-to-hero.html" \
-- docs
require_ordered_output "installed provider-free examples wayfinding" \
"go run ./examples/first-agent" \
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1" \
"go run ./examples/support" \
"micro agent demo" \
"micro docs" \
"micro zero-to-hero" \
"no-secret-first-agent.html" \
"your-first-agent.html" \
"debugging-agents.html" \
"zero-to-hero.html" \
-- examples
require_ordered_output "installed no-secret agent demo" \
"provider-free" \
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1" \
"your-first-agent.html" \
"debugging-agents.html" \
"zero-to-hero.html" \
"micro agent preflight # before micro run: prerequisites" \
"micro run" \
"micro chat" \
"micro agent doctor # after micro run: chat/gateway/inspect recovery" \
"micro inspect agent <name>" \
-- agent demo
require_ordered_output "installed zero-to-hero lifecycle wayfinding" \
"./internal/harness/zero-to-hero-ci/run.sh" \
"go run ./examples/first-agent" \
"go run ./examples/support" \
"make harness" \
"zero-to-hero.html" \
-- zero-to-hero
echo "✓ install smoke path verified"
+776
View File
@@ -0,0 +1,776 @@
// Plan & Delegate integration harness.
//
// This runs the REAL go-micro stack end to end — real services, real
// registry, real RPC, the real agent loop, real store, real delegate
// routing — and mocks ONLY the LLM with a deterministic provider. It
// proves the plumbing works without an API key, and it's reproducible.
//
// Swap MICRO_AI_PROVIDER/MICRO_AI_API_KEY (and remove --mock) to run the
// exact same flow against a live model.
//
// Run:
//
// go run ./internal/harness/plan-delegate
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"strings"
"sync"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/internal/harness/harnessutil"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/service"
"go-micro.dev/v6/store"
)
// ---------------------------------------------------------------------------
// real services
// ---------------------------------------------------------------------------
type Task struct {
ID string `json:"id"`
Title string `json:"title"`
}
type AddRequest struct {
Title string `json:"title" description:"Title of the task to add"`
}
type AddResponse struct {
Task *Task `json:"task"`
}
type ListRequest struct{}
type ListResponse struct {
Tasks []*Task `json:"tasks"`
}
type TaskService struct {
mu sync.Mutex
tasks []*Task
byTitle map[string]*Task
nextID int
}
// Add creates a new task with the given title. Replayed live-model tool calls
// are idempotent by launch task title so the conformance harness proves exactly
// one durable side effect per intended task even if a provider resends a call.
// @example {"title": "Design"}
func (s *TaskService) Add(ctx context.Context, req *AddRequest, rsp *AddResponse) error {
s.mu.Lock()
defer s.mu.Unlock()
if s.byTitle == nil {
s.byTitle = map[string]*Task{}
}
key := launchTaskKey(req.Title)
if t := s.byTitle[key]; t != nil {
rsp.Task = t
fmt.Printf(" \033[32m[task]\033[0m reused %s %q\n", t.ID, t.Title)
return nil
}
s.nextID++
t := &Task{ID: fmt.Sprintf("task-%d", s.nextID), Title: canonicalLaunchTitle(req.Title)}
s.tasks = append(s.tasks, t)
s.byTitle[key] = t
rsp.Task = t
fmt.Printf(" \033[32m[task]\033[0m created %s %q\n", t.ID, t.Title)
return nil
}
func launchTaskKey(title string) string {
s := strings.ToLower(strings.TrimSpace(title))
switch {
case strings.Contains(s, "design"):
return "design"
case strings.Contains(s, "build"):
return "build"
case strings.Contains(s, "ship"):
return "ship"
default:
return s
}
}
func canonicalLaunchTitle(title string) string {
switch launchTaskKey(title) {
case "design":
return "Design"
case "build":
return "Build"
case "ship":
return "Ship"
default:
return strings.TrimSpace(title)
}
}
// List returns all tasks.
// @example {}
func (s *TaskService) List(ctx context.Context, req *ListRequest, rsp *ListResponse) error {
s.mu.Lock()
defer s.mu.Unlock()
rsp.Tasks = append(rsp.Tasks, s.tasks...)
return nil
}
func (s *TaskService) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return len(s.tasks)
}
const delegatedNotifyTask = "Use the notify Send tool exactly once to tell owner@acme.com: The launch plan is ready. Do not answer until the notify tool call has succeeded."
const commsPrompt = "You handle outbound notifications. When asked to notify someone, you must call the notify Send tool exactly once before replying. Never claim a notification was sent unless the notify tool returned success."
const delegatedNotifySettleTimeout = 10 * time.Second
type SendRequest struct {
To string `json:"to" description:"Recipient address"`
Message string `json:"message" description:"Message body"`
}
type SendResponse struct {
Sent bool `json:"sent"`
}
type NotifyService struct {
mu sync.Mutex
sent int
attempts int
duplicates int
bySend map[string]bool
}
// Send delivers a notification message to a recipient. Duplicate delivery
// attempts for the same recipient/message are treated as successful replays
// without producing another side effect.
// @example {"to": "owner@acme.com", "message": "ready"}
func (s *NotifyService) Send(ctx context.Context, req *SendRequest, rsp *SendResponse) error {
s.mu.Lock()
if s.bySend == nil {
s.bySend = map[string]bool{}
}
key := notifyDedupKey(req.To, req.Message)
s.attempts++
if !s.bySend[key] {
s.bySend[key] = true
s.sent++
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s message=%q\n", req.To, req.Message)
} else {
s.duplicates++
fmt.Printf(" \033[35m[notify]\033[0m reused to=%s message=%q\n", req.To, req.Message)
}
s.mu.Unlock()
rsp.Sent = true
return nil
}
func (s *NotifyService) count() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.sent
}
func (s *NotifyService) duplicateAttempts() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.duplicates
}
func notifyDedupKey(to, message string) string {
recipient := canonicalLaunchNotifyRecipient(normalizeNotifyText(to))
body := normalizeNotifyText(message)
if recipient == "owner@acme.com" && isLaunchReadinessNotify(body) {
body = "launch-readiness"
}
return recipient + "\x00" + body
}
func canonicalLaunchNotifyRecipient(recipient string) string {
recipient = canonicalSpokenEmailRecipient(recipient)
switch recipient {
case "owner", "launch owner", "plan owner", "owner acme com", "owner@acme com", "owner @ acme com":
return "owner@acme.com"
default:
if strings.Contains(recipient, "owner") && strings.Contains(recipient, "acme") {
return "owner@acme.com"
}
return recipient
}
}
func canonicalSpokenEmailRecipient(recipient string) string {
fields := strings.Fields(recipient)
if len(fields) == 5 && fields[1] == "at" && fields[3] == "dot" {
return fields[0] + "@" + fields[2] + "." + fields[4]
}
return recipient
}
func normalizeNotifyText(message string) string {
message = strings.ToLower(strings.TrimSpace(message))
message = strings.Map(func(r rune) rune {
switch {
case r >= 'a' && r <= 'z', r >= '0' && r <= '9':
return r
case r == '@':
return r
default:
return ' '
}
}, message)
return strings.Join(strings.Fields(message), " ")
}
func isLaunchReadinessNotify(message string) bool {
hasLaunch := strings.Contains(message, "launch")
hasPlanOrReadiness := strings.Contains(message, "plan") ||
strings.Contains(message, "readiness") ||
strings.Contains(message, "ready")
hasCompletion := strings.Contains(message, "ready") ||
strings.Contains(message, "readiness") ||
strings.Contains(message, "prepared") ||
strings.Contains(message, "complete") ||
strings.Contains(message, "finished") ||
strings.Contains(message, "done") ||
strings.Contains(message, "sent")
return hasLaunch && hasPlanOrReadiness && hasCompletion
}
// ---------------------------------------------------------------------------
// mock LLM provider — the ONLY fake. It "reasons" by simple heuristics
// over the tools it's offered and the system prompt it's given, calling
// the real tool handler exactly the way a real provider would.
// ---------------------------------------------------------------------------
type mockModel struct {
opts ai.Options
// unknownDelegateOnce makes the mock emit one provider-style, unavailable
// delegate tool name before using the registered delegate tool. This mirrors
// live providers that occasionally hallucinate a provider-specific tool while
// still keeping the regression deterministic and keyless.
unknownDelegateOnce bool
emittedUnknownDelegate bool
// duplicateNotify makes the comms mock replay the same notification call.
// The notify service should collapse that replay to one durable side effect.
duplicateNotify bool
// duplicateDelegate makes the conductor mock replay the same delegate call.
// The delegate idempotency path should collapse that replay before it can
// ask the delegated comms agent to notify twice.
duplicateDelegate bool
// interruptAfterTasks makes the conductor mock stop after persisted plan and
// task side effects, before delegation. The harness should recover the
// missing notification without replaying completed tasks.
interruptAfterTasks bool
// nestedDelegateMarkup makes the conductor mock attempt to smuggle text
// tool-call markup inside the delegate arguments. The agent guardrail must
// refuse it before any delegated side effect can run.
nestedDelegateMarkup bool
}
func newMock(opts ...ai.Option) ai.Model {
m := &mockModel{}
_ = m.Init(opts...)
return m
}
func newMockUnknownDelegate(opts ...ai.Option) ai.Model {
m := &mockModel{unknownDelegateOnce: true}
_ = m.Init(opts...)
return m
}
func newMockDuplicateNotify(opts ...ai.Option) ai.Model {
m := &mockModel{duplicateNotify: true}
_ = m.Init(opts...)
return m
}
func newMockDuplicateDelegate(opts ...ai.Option) ai.Model {
m := &mockModel{duplicateDelegate: true}
_ = m.Init(opts...)
return m
}
func newMockInterruptAfterTasks(opts ...ai.Option) ai.Model {
m := &mockModel{interruptAfterTasks: true}
_ = m.Init(opts...)
return m
}
func newMockNestedDelegateMarkup(opts ...ai.Option) ai.Model {
m := &mockModel{nestedDelegateMarkup: true}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *mockModel) Options() ai.Options { return m.opts }
func (m *mockModel) String() string { return "mock" }
func (m *mockModel) Stream(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("stream not supported by mock")
}
// findTool returns the safe name of the first offered tool whose name
// contains sub, or "" if none.
func findTool(tools []ai.Tool, sub string) string {
for _, t := range tools {
if strings.Contains(t.Name, sub) {
return t.Name
}
}
return ""
}
func (m *mockModel) call(who, name string, input map[string]any) ai.ToolResult {
args, _ := json.Marshal(input)
fmt.Printf(" \033[33m[%s]\033[0m → %s(%s)\n", who, name, args)
if m.opts.ToolHandler != nil {
return m.opts.ToolHandler(context.Background(), ai.ToolCall{Name: name, Input: input})
}
return ai.ToolResult{}
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
// Classify by the tools actually offered, not by prompt text:
// the conductor has the task "Add" tool, comms has "Send".
hasAdd := findTool(req.Tools, "Add") != ""
hasSend := findTool(req.Tools, "Send") != ""
switch {
// comms agent: owns notify, has Send but not Add.
case hasSend && !hasAdd:
send := findTool(req.Tools, "Send")
input := map[string]any{
"to": "owner@acme.com",
"message": "The launch plan is ready",
}
m.call("comms", send, input)
if m.duplicateNotify {
m.call("comms", send, input)
}
return &ai.Response{Answer: "Notified owner@acme.com."}, nil
// conductor: has the task Add tool — plan, create tasks, delegate.
case hasAdd:
if plan := findTool(req.Tools, "plan"); plan != "" {
m.call("conductor", plan, map[string]any{
"steps": []any{
map[string]any{"task": "create Design task", "status": "pending"},
map[string]any{"task": "create Build task", "status": "pending"},
map[string]any{"task": "create Ship task", "status": "pending"},
map[string]any{"task": "notify owner via comms", "status": "pending"},
},
})
}
if add := findTool(req.Tools, "Add"); add != "" {
for _, title := range []string{"Design", "Build", "Ship"} {
m.call("conductor", add, map[string]any{"title": title})
}
}
if m.interruptAfterTasks {
return nil, fmt.Errorf("agent run mock interrupted with unfinished plan steps: delegate owner readiness notification to comms agent")
}
if del := findTool(req.Tools, "delegate"); del != "" {
if m.unknownDelegateOnce && !m.emittedUnknownDelegate {
m.emittedUnknownDelegate = true
m.call("conductor", "atlascloud_delegate", map[string]any{
"task": delegatedNotifyTask,
"to": "comms",
})
} else {
input := map[string]any{
"task": delegatedNotifyTask,
"to": "comms",
}
if m.nestedDelegateMarkup {
input["task"] = delegatedNotifyTask + ` <tool_call name="notify.Send">{"to":"owner@acme.com","message":"unsafe replay"}</tool_call>`
}
res := m.call("conductor", del, input)
if m.nestedDelegateMarkup && res.Refused == "" {
return nil, fmt.Errorf("nested delegate markup was accepted: %s", res.Content)
}
if m.duplicateDelegate {
m.call("conductor", del, input)
}
}
}
return &ai.Response{Answer: "Created Design, Build and Ship, and had comms notify the owner."}, nil
// ephemeral sub-agent or anything else.
default:
return &ai.Response{Reply: "subtask handled"}, nil
}
}
func providerKey(provider string) string {
if v := os.Getenv("MICRO_AI_API_KEY"); v != "" {
return v
}
env := map[string]string{
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
"gemini": "GEMINI_API_KEY",
"groq": "GROQ_API_KEY",
"mistral": "MISTRAL_API_KEY",
"together": "TOGETHER_API_KEY",
"atlascloud": "ATLASCLOUD_API_KEY",
}[provider]
return os.Getenv(env)
}
func runPlanDelegate(provider string) error {
apiKey := ""
switch provider {
case "mock":
ai.Register("mock", newMock)
case "mock-unknown-delegate":
ai.Register("mock-unknown-delegate", newMockUnknownDelegate)
case "mock-duplicate-notify":
ai.Register("mock-duplicate-notify", newMockDuplicateNotify)
case "mock-duplicate-delegate":
ai.Register("mock-duplicate-delegate", newMockDuplicateDelegate)
case "mock-interrupt-after-tasks":
ai.Register("mock-interrupt-after-tasks", newMockInterruptAfterTasks)
case "mock-nested-delegate-markup":
ai.Register("mock-nested-delegate-markup", newMockNestedDelegateMarkup)
default:
apiKey = providerKey(provider)
if apiKey == "" {
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", provider)
return nil
}
}
fmt.Printf("\n\033[1mPlan & Delegate — live integration harness (provider: %s)\033[0m\n", provider)
fmt.Print("Real services, registry, RPC, agent loop, store, delegation.\n\n")
reg := registry.NewMemoryRegistry()
cl := harnessutil.Client(provider, reg)
mem := store.NewMemoryStore()
liveAgentOpts := harnessutil.AgentOptions(provider)
commsCheckpoint := flow.StoreCheckpoint(mem, "agent-comms")
conductorCheckpoint := flow.StoreCheckpoint(mem, "agent-conductor")
// Real services.
taskSvc := new(TaskService)
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := task.Handle(taskSvc); err != nil {
return fmt.Errorf("task handle: %w", err)
}
if err := task.Start(); err != nil {
return fmt.Errorf("task start: %w", err)
}
defer task.Stop()
notifySvc := new(NotifyService)
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := notify.Handle(notifySvc); err != nil {
return fmt.Errorf("notify handle: %w", err)
}
if err := notify.Start(); err != nil {
return fmt.Errorf("notify start: %w", err)
}
defer notify.Stop()
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
commsOpts := []agent.Option{
agent.Name("comms"),
agent.Address("127.0.0.1:0"),
agent.Services("notify"),
agent.Prompt(commsPrompt),
agent.Provider(provider), agent.APIKey(apiKey),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
agent.WithCheckpoint(commsCheckpoint),
}
commsOpts = append(commsOpts, liveAgentOpts...)
comms := agent.New(commsOpts...)
go comms.Run()
defer comms.Stop()
// Real conductor agent (owns task), registered so the flow can reach it over RPC.
conductorOpts := []agent.Option{
agent.Name("conductor"),
agent.Address("127.0.0.1:0"),
agent.Services("task"),
agent.Prompt("You coordinate launch work. Before any task or delegate tool call, you must persist the launch-readiness plan with the built-in plan tool. Then create exactly one Design task, one Build task, and one Ship task, then delegate exactly one readiness notification to the \"comms\" agent. Do not create duplicate tasks and do not send notifications yourself."),
agent.Provider(provider), agent.APIKey(apiKey),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
agent.WithCheckpoint(conductorCheckpoint),
agent.WrapTool(requirePersistedPlanBeforeConductorActions(mem)),
}
conductorOpts = append(conductorOpts, liveAgentOpts...)
conductor := agent.New(conductorOpts...)
go conductor.Run()
defer conductor.Stop()
fmt.Println("waiting for services + agents to register...")
waitForService := func(name string) error {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if svcs, err := reg.GetService(name); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
return nil
}
time.Sleep(20 * time.Millisecond)
}
return fmt.Errorf("service %q never registered", name)
}
for _, name := range []string{"task", "notify", "comms", "conductor"} {
if err := waitForService(name); err != nil {
return err
}
}
f := flow.New("zero-to-hero",
flow.Steps(
flow.Step{Name: "conductor", Run: planDelegateConductorStep(conductor, taskSvc, notifySvc)},
flow.Step{Name: "require-notify", Run: requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
_, err := comms.Ask(ctx, "Send exactly one owner readiness notification now with this exact task: "+delegatedNotifyTask+" Use the notify service and do not answer until the notification has been sent.")
return err
})},
),
flow.WithCheckpoint(flow.StoreCheckpoint(mem, "flow-zero-to-hero")),
flow.Timeout(harnessutil.LiveTimeout(provider)),
)
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
return fmt.Errorf("flow register: %w", err)
}
fmt.Print("\n\033[1m> flow:\033[0m services + agents + workflow + plan/delegate, no API key.\n\n")
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
executeDone := make(chan error, 1)
go func() {
executeDone <- f.Execute(ctx, "launch readiness")
}()
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc, func(ctx context.Context) error {
_, err := comms.Ask(ctx, "Recover the missing owner readiness notification now for the launch work already created. Send exactly one notification with this exact task: "+delegatedNotifyTask+" Use the notify service and do not create or modify tasks.")
return err
}); err != nil {
return err
}
if err := requireConductorPlan(context.Background(), mem, conductor); err != nil {
return err
}
if taskSvc.count() == 0 || notifySvc.count() != 1 {
return fmt.Errorf("unexpected side effects: tasks=%d notify=%d", taskSvc.count(), notifySvc.count())
}
fmt.Println("\n\033[32m✓ 0→hero flow complete (services → agents → workflow)\033[0m")
return nil
}
func requirePersistedPlanBeforeConductorActions(mem store.Store) ai.ToolWrapper {
return func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if call.Name == "plan" {
return next(ctx, call)
}
if recs, err := store.Scope(mem, "agent", "conductor").Read("plan"); err == nil && len(recs) > 0 {
return next(ctx, call)
}
msg := "persist the launch-readiness plan first by calling the built-in plan tool before task or delegate side effects"
return ai.ToolResult{
ID: call.ID,
Value: map[string]string{"error": msg},
Content: `{"error":"` + msg + `"}`,
Refused: ai.RefusedApproval,
}
}
}
}
func requireConductorPlan(ctx context.Context, mem store.Store, conductor agent.Agent) error {
recs, _ := store.Scope(mem, "agent", "conductor").Read("plan")
if len(recs) == 0 && conductor != nil {
fmt.Print("\n\033[33mwarning:\033[0m conductor completed side effects without a persisted plan; retrying plan persistence once before final assertions.\n")
_, err := conductor.Ask(ctx, "Persist the launch-readiness plan now using the built-in plan tool before answering. Record exactly these completed steps: Design launch task, Build launch task, Ship launch task, and delegate owner readiness notification to comms. Do not call task or notify tools.")
if err != nil {
return fmt.Errorf("plan was not persisted at agent/conductor/plan and recovery prompt failed after completed side effects: %w", err)
}
recs, _ = store.Scope(mem, "agent", "conductor").Read("plan")
}
if len(recs) == 0 {
return fmt.Errorf("plan was not persisted at agent/conductor/plan; conductor completed task/notify side effects without calling the built-in plan tool")
}
if len(recs) != 1 {
return fmt.Errorf("unexpected persisted conductor plans at agent/conductor/plan: got %d records, want 1", len(recs))
}
fmt.Printf("\n\033[1mstored plan (agent/conductor/plan):\033[0m %s\n", string(recs[0].Value))
return nil
}
func planDelegateConductorStep(conductor agent.Agent, taskSvc *TaskService, notifySvc *NotifyService) flow.StepFunc {
return func(ctx context.Context, in flow.State) (flow.State, error) {
prompt := "Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: " + in.String()
rsp, err := conductor.Ask(ctx, prompt)
if err != nil {
if isUnfinishedPlanError(err) && taskSvc != nil && notifySvc != nil && taskSvc.count() > 0 && notifySvc.count() == 0 {
fmt.Printf("\n\033[33mwarning:\033[0m conductor stopped with unfinished delegation after creating tasks; continuing to require-notify recovery: %v\n", err)
return in, nil
}
return in, err
}
if rsp != nil && rsp.Reply != "" {
fmt.Println("\n\033[1m< conductor reply:\033[0m", rsp.Reply)
}
if taskSvc != nil && notifySvc != nil && taskSvc.count() == 0 && notifySvc.count() == 0 {
fmt.Print("\n\033[33mwarning:\033[0m conductor persisted/planned without service side effects; retrying task execution before notify gate.\n")
rsp, err = conductor.Ask(ctx, "Continue the launch-readiness run now. Execute the persisted plan by calling the task Add tool exactly once for Design, Build, and Ship, then delegate the owner readiness notification to comms. Do not answer until at least one required tool call succeeds.")
if err != nil {
if isUnfinishedPlanError(err) && taskSvc.count() > 0 && notifySvc.count() == 0 {
fmt.Printf("\n\033[33mwarning:\033[0m conductor recovered tasks but stopped before notification; continuing to require-notify recovery: %v\n", err)
return in, nil
}
return in, err
}
if rsp != nil && rsp.Reply != "" {
fmt.Println("\n\033[1m< conductor reply:\033[0m", rsp.Reply)
}
}
if taskSvc != nil && notifySvc != nil && taskSvc.count() == 0 {
return in, fmt.Errorf("plan-delegate reached notify gate before task side effects completed (tasks=0/3 notify=%d/1); model produced a plan but did not call task Add for Design, Build, and Ship", notifySvc.count())
}
return in, nil
}
}
func isUnfinishedPlanError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unfinished plan steps")
}
func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) flow.StepFunc {
return func(ctx context.Context, in flow.State) (flow.State, error) {
tasks := taskSvc.count()
notify := notifySvc.count()
if notify == 1 {
return in, nil
}
if recoverMissingNotify == nil || tasks == 0 || notify != 0 {
return in, fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
}
settled, err := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
if err != nil {
return in, err
}
if !settled {
fmt.Print("\n\033[33mwarning:\033[0m conductor step completed before delegated notify; retrying the missing comms handoff once before the flow can complete.\n")
if err := recoverMissingNotify(ctx); err != nil {
return in, fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, err)
}
settled, err = waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
if err != nil {
return in, err
}
if !settled {
return in, fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notifySvc.count())
}
}
if notify = notifySvc.count(); notify != 1 {
return in, fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
}
return in, nil
}
}
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
select {
case err := <-done:
tasks := taskSvc.count()
notify := notifySvc.count()
if err != nil {
if hasCompletedPlanDelegateSideEffects(tasks, notify) {
fmt.Printf("\n\033[33mwarning:\033[0m flow execute returned after completed side effects: %v\n", err)
return nil
}
if isClientTimeout(err) {
return classifiedPlanDelegateTimeout(tasks, notify, err)
}
if isUnfinishedPlanError(err) && tasks > 0 && notify == 0 && recoverMissingNotify != nil {
fmt.Printf("\n\033[33mwarning:\033[0m flow stopped after partial plan side effects; recovering missing delegated notify: %v\n", err)
if recoverErr := recoverMissingNotify(context.Background()); recoverErr != nil {
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d and recovery failed: %w", tasks, notify, recoverErr)
}
settled, waitErr := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
if waitErr != nil {
return waitErr
}
if settled {
return nil
}
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: delegation recovery completed without required notify side effect: notify=%d, want 1", tasks, notify, notifySvc.count())
}
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: %w", tasks, notify, err)
}
if notify != 1 {
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
}
return nil
case <-ticker.C:
if notifySvc.count() == 1 {
continue
}
}
}
}
func waitForNotifySideEffect(notifySvc *NotifyService, timeout time.Duration) (bool, error) {
deadline := time.Now().Add(timeout)
for {
if notifySvc.count() == 1 {
return true, nil
}
if !time.Now().Before(deadline) {
return false, nil
}
time.Sleep(50 * time.Millisecond)
}
}
func hasCompletedPlanDelegateSideEffects(tasks, notify int) bool {
return tasks == 3 && notify == 1
}
func classifiedPlanDelegateTimeout(tasks, notify int, err error) error {
return fmt.Errorf("provider latency/outage during plan-delegate before required side effects completed (tasks=%d/3 notify=%d/1); retry live provider or inspect provider logs if this recurs: %w", tasks, notify, err)
}
func isClientTimeout(err error) bool {
msg := strings.ToLower(err.Error())
return strings.Contains(msg, "request timeout") || strings.Contains(msg, "code=408") || strings.Contains(msg, "code\":408")
}
func main() {
provider := flag.String("provider", "mock", "LLM provider: mock (default), mock-unknown-delegate, mock-duplicate-notify, mock-duplicate-delegate, anthropic, openai, gemini, groq, mistral, together, atlascloud")
flag.Parse()
if err := runPlanDelegate(*provider); err != nil {
fmt.Println("\033[31merror:\033[0m", err)
os.Exit(1)
}
}
+818
View File
@@ -0,0 +1,818 @@
package main
import (
"context"
"errors"
"reflect"
"strings"
"testing"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
"go-micro.dev/v6/service"
"go-micro.dev/v6/store"
)
// waitForService polls the registry until name is registered, instead of
// sleeping. Keeps the test deterministic.
func waitForService(t *testing.T, reg registry.Registry, name string) {
t.Helper()
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
if svcs, err := reg.GetService(name); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
return
}
time.Sleep(20 * time.Millisecond)
}
t.Fatalf("service %q never registered", name)
}
// TestPlanDelegateEndToEnd runs the whole feature against the real stack
// — real services, a shared in-memory registry, real RPC, the real agent
// loop, real store — with only the LLM mocked. No mDNS, no sleeps.
func TestPlanDelegateEndToEnd(t *testing.T) {
ai.Register("mock", newMock)
// Shared infrastructure: one in-memory registry, a client bound to
// it, and an in-memory store. Everything resolves through these.
reg := registry.NewMemoryRegistry()
cl := client.NewClient(
client.Registry(reg),
client.Selector(selector.NewSelector(selector.Registry(reg))),
)
mem := store.NewMemoryStore()
// Real services on the shared registry/client.
taskSvc := new(TaskService)
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := task.Handle(taskSvc); err != nil {
t.Fatalf("handle task: %v", err)
}
if err := task.Start(); err != nil {
t.Fatalf("start task: %v", err)
}
defer task.Stop()
notifySvc := new(NotifyService)
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := notify.Handle(notifySvc); err != nil {
t.Fatalf("handle notify: %v", err)
}
if err := notify.Start(); err != nil {
t.Fatalf("start notify: %v", err)
}
defer notify.Stop()
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
comms := agent.New(
agent.Name("comms"),
agent.Address("127.0.0.1:0"),
agent.Services("notify"),
agent.Prompt(commsPrompt),
agent.Provider("mock"),
agent.WithRegistry(reg),
agent.WithClient(cl),
agent.WithStore(mem),
)
go comms.Run()
defer comms.Stop()
waitForService(t, reg, "task")
waitForService(t, reg, "notify")
waitForService(t, reg, "comms")
// Real conductor agent (owns task), driven programmatically.
conductor := agent.New(
agent.Name("conductor"),
agent.Address("127.0.0.1:0"),
agent.Services("task"),
agent.Prompt("Plan first, create tasks, delegate notifications to the comms agent."),
agent.Provider("mock"),
agent.WithRegistry(reg),
agent.WithClient(cl),
agent.WithStore(mem),
)
resp, err := conductor.Ask(context.Background(),
"Create three launch tasks: Design, Build, and Ship. Then notify owner@acme.com that the plan is ready.")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if resp.Reply == "" {
t.Error("conductor returned an empty reply")
}
// Tasks were created via real RPC into the task service.
if n := taskSvc.count(); n != 3 {
t.Errorf("task service has %d tasks, want 3", n)
}
// The plan was persisted to the real store, in the agent's scoped table.
if recs, err := store.Scope(mem, "agent", "conductor").Read("plan"); err != nil || len(recs) == 0 {
t.Errorf("plan not persisted to store: err=%v recs=%d", err, len(recs))
}
// Delegation reached the comms agent over RPC, which called notify.
if n := notifySvc.count(); n != 1 {
t.Errorf("notify service called %d times, want 1 (delegation did not reach comms)", n)
}
}
// TestFlowDispatchesToAgentEndToEnd proves "Flow triggers, Agent reasons":
// a workflow event hands off to the registered conductor agent, which then
// plans, creates tasks, and delegates to comms — all over real RPC. Only
// the LLM is mocked.
func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
ai.Register("mock", newMock)
reg := registry.NewMemoryRegistry()
cl := client.NewClient(
client.Registry(reg),
client.Selector(selector.NewSelector(selector.Registry(reg))),
)
mem := store.NewMemoryStore()
taskSvc := new(TaskService)
task := service.New(service.Name("task"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := task.Handle(taskSvc); err != nil {
t.Fatalf("handle task: %v", err)
}
if err := task.Start(); err != nil {
t.Fatalf("start task: %v", err)
}
defer task.Stop()
notifySvc := new(NotifyService)
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := notify.Handle(notifySvc); err != nil {
t.Fatalf("handle notify: %v", err)
}
if err := notify.Start(); err != nil {
t.Fatalf("start notify: %v", err)
}
defer notify.Stop()
comms := agent.New(
agent.Name("comms"),
agent.Address("127.0.0.1:0"),
agent.Services("notify"),
agent.Prompt(commsPrompt),
agent.Provider("mock"),
agent.WithRegistry(reg),
agent.WithClient(cl),
agent.WithStore(mem),
)
go comms.Run()
defer comms.Stop()
// Unlike the previous test, the conductor must be registered (running)
// so the flow can reach it over RPC.
conductor := agent.New(
agent.Name("conductor"),
agent.Address("127.0.0.1:0"),
agent.Services("task"),
agent.Prompt("Plan first, create tasks, delegate notifications to the comms agent."),
agent.Provider("mock"),
agent.WithRegistry(reg),
agent.WithClient(cl),
agent.WithStore(mem),
)
go conductor.Run()
defer conductor.Stop()
waitForService(t, reg, "task")
waitForService(t, reg, "notify")
waitForService(t, reg, "comms")
waitForService(t, reg, "conductor")
// A workflow that hands each event to the conductor agent.
f := flow.New("onboard",
flow.Agent("conductor"),
flow.Prompt("Get the launch ready: {{.Data}}"),
)
if err := f.Register(reg, broker.DefaultBroker, cl); err != nil {
t.Fatalf("flow register: %v", err)
}
// Fire the workflow (as a broker event would).
if err := f.Execute(context.Background(), "three tasks then notify owner@acme.com"); err != nil {
t.Fatalf("flow execute: %v", err)
}
// The flow recorded the agent's reply.
if rs := f.Results(); len(rs) != 1 || rs[0].Reply == "" {
t.Errorf("flow result = %+v, want one result with a reply", rs)
}
// The agent ran end to end: tasks created, plan stored, comms notified.
if n := taskSvc.count(); n != 3 {
t.Errorf("task service has %d tasks, want 3", n)
}
if recs, err := store.Scope(mem, "agent", "conductor").Read("plan"); err != nil || len(recs) == 0 {
t.Errorf("plan not persisted: err=%v recs=%d", err, len(recs))
}
if n := notifySvc.count(); n != 1 {
t.Errorf("notify called %d times, want 1 (flow->agent->delegate->comms chain broken)", n)
}
}
// TestZeroToHeroContract locks the roadmap's second golden path into the
// ordinary Go test contract. It runs the same executable harness used by
// `make harness`: services + agents + flow + plan/delegate, with only the
// LLM replaced by the deterministic mock provider.
func TestZeroToHeroContract(t *testing.T) {
if testing.Short() {
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
}
if err := runPlanDelegate("mock"); err != nil {
t.Fatalf("0→hero harness: %v", err)
}
}
func TestPlanDelegateRetriesAfterUnknownDelegateTool(t *testing.T) {
if testing.Short() {
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
}
if err := runPlanDelegate("mock-unknown-delegate"); err != nil {
t.Fatalf("0→hero harness with unknown delegate retry: %v", err)
}
}
func TestPlanDelegateIdempotentDuplicateNotifyReplay(t *testing.T) {
if testing.Short() {
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
}
if err := runPlanDelegate("mock-duplicate-notify"); err != nil {
t.Fatalf("0→hero harness with duplicate notify replay: %v", err)
}
}
func TestPlanDelegateIdempotentDuplicateDelegateReplay(t *testing.T) {
if testing.Short() {
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
}
if err := runPlanDelegate("mock-duplicate-delegate"); err != nil {
t.Fatalf("0→hero harness with duplicate delegate replay: %v", err)
}
}
func TestPlanDelegateRecoversInterruptedMockRunWithoutReplayingTasks(t *testing.T) {
if testing.Short() {
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
}
if err := runPlanDelegate("mock-interrupt-after-tasks"); err != nil {
t.Fatalf("0→hero harness with interrupted task-complete run: %v", err)
}
}
func TestPlanDelegateRejectsNestedDelegateToolCallMarkup(t *testing.T) {
if testing.Short() {
t.Skip("0→hero harness boots an end-to-end system; skipped with -short")
}
if err := runPlanDelegate("mock-nested-delegate-markup"); err != nil {
t.Fatalf("0→hero harness with nested delegate markup refusal: %v", err)
}
}
func TestNotifyServiceDeduplicatesAtlasCloudLaunchReadinessParaphrases(t *testing.T) {
svc := new(NotifyService)
variants := []SendRequest{
{To: "owner at acme dot com", Message: "The launch plan is ready."},
{To: "launch owner", Message: "Launch readiness is complete."},
{To: "Owner <owner@acme.com>", Message: "The launch plan is finished and the readiness notification was sent."},
}
for _, req := range variants {
var rsp SendResponse
if err := svc.Send(context.Background(), &req, &rsp); err != nil {
t.Fatalf("Send(%+v): %v", req, err)
}
if !rsp.Sent {
t.Fatalf("Send(%+v) returned sent=false", req)
}
}
if got := svc.count(); got != 1 {
t.Fatalf("notify side effects = %d, want 1 for launch-readiness paraphrase replays", got)
}
if got := svc.duplicateAttempts(); got != len(variants)-1 {
t.Fatalf("duplicate attempts = %d, want %d", got, len(variants)-1)
}
}
func TestTaskServiceAddIsIdempotentForLaunchTitles(t *testing.T) {
svc := new(TaskService)
for _, title := range []string{"Design", "design task", "Build", "Build launch task", "Ship", "ship readiness"} {
var rsp AddResponse
if err := svc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
if rsp.Task == nil {
t.Fatalf("Add(%q) returned nil task", title)
}
}
if got := svc.count(); got != 3 {
t.Fatalf("task count = %d, want 3 after duplicate launch-title replays", got)
}
}
func TestPlanDelegateExecutionAcceptsDuplicateNotifyReplay(t *testing.T) {
notifySvc := new(NotifyService)
for i := 0; i < 2; i++ {
var rsp SendResponse
if err := notifySvc.Send(context.Background(), &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp); err != nil {
t.Fatalf("Send attempt %d: %v", i+1, err)
}
}
done := make(chan error, 1)
done <- nil
if err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil); err != nil {
t.Fatalf("waitForPlanDelegateExecution returned %v, want duplicate replay accepted", err)
}
if got := notifySvc.count(); got != 1 {
t.Fatalf("notify count = %d, want 1 after duplicate replay", got)
}
if got := notifySvc.duplicateAttempts(); got != 1 {
t.Fatalf("duplicate attempts = %d, want 1 recorded replay", got)
}
}
func TestPlanDelegateExecutionRecoversUnfinishedPlanAfterPartialTaskSideEffect(t *testing.T) {
taskSvc := new(TaskService)
var addRsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: "Design"}, &addRsp); err != nil {
t.Fatalf("Add: %v", err)
}
notifySvc := new(NotifyService)
done := make(chan error, 1)
done <- errors.New("agent run abc has unfinished plan steps: Delegate readiness notification to comms agent")
recovered := false
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
recovered = true
var sendRsp SendResponse
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &sendRsp)
})
if err != nil {
t.Fatalf("waitForPlanDelegateExecution returned %v, want partial notify recovery", err)
}
if !recovered {
t.Fatal("missing notify recovery did not run")
}
if got := taskSvc.count(); got != 1 {
t.Fatalf("task count = %d, want completed partial task to stay singular", got)
}
if got := notifySvc.count(); got != 1 {
t.Fatalf("notify count = %d, want recovered notify side effect", got)
}
}
func TestPlanDelegateExecutionRejectsClaimedCompletionWithoutNotify(t *testing.T) {
notifySvc := new(NotifyService)
done := make(chan error, 1)
done <- nil
err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil)
if err == nil {
t.Fatal("waitForPlanDelegateExecution returned nil, want missing notify side-effect error")
}
if got := err.Error(); !strings.Contains(got, "without required notify side effect") {
t.Fatalf("error = %q, want missing notify side-effect error", got)
}
}
type scriptedAgent struct {
replies []func(context.Context, string) (*agent.Response, error)
calls int
}
func (a *scriptedAgent) Name() string { return "scripted" }
func (a *scriptedAgent) Init(...agent.Option) {}
func (a *scriptedAgent) Options() agent.Options { return agent.Options{} }
func (a *scriptedAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, nil }
func (a *scriptedAgent) Run() error { return nil }
func (a *scriptedAgent) Stop() error { return nil }
func (a *scriptedAgent) String() string { return "scripted" }
func (a *scriptedAgent) Ask(ctx context.Context, prompt string) (*agent.Response, error) {
if a.calls >= len(a.replies) {
return &agent.Response{Reply: "done"}, nil
}
reply := a.replies[a.calls]
a.calls++
return reply(ctx, prompt)
}
type failingAgent struct {
err error
}
func (a failingAgent) Name() string { return "failing" }
func (a failingAgent) Init(...agent.Option) {}
func (a failingAgent) Options() agent.Options { return agent.Options{} }
func (a failingAgent) Ask(context.Context, string) (*agent.Response, error) { return nil, a.err }
func (a failingAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, a.err }
func (a failingAgent) Run() error { return nil }
func (a failingAgent) Stop() error { return nil }
func (a failingAgent) String() string { return "failing" }
func TestRequireConductorPlanRecoversAfterCompletedSideEffects(t *testing.T) {
mem := store.NewMemoryStore()
ag := &scriptedAgent{replies: []func(context.Context, string) (*agent.Response, error){
func(ctx context.Context, prompt string) (*agent.Response, error) {
if !strings.Contains(prompt, "built-in plan tool") || !strings.Contains(prompt, "Do not call task or notify tools") {
return nil, errors.New("missing scoped plan recovery prompt")
}
if err := store.Scope(mem, "agent", "conductor").Write(&store.Record{Key: "plan", Value: []byte(`{"steps":[{"task":"Design launch task","status":"done"}]}`)}); err != nil {
return nil, err
}
return &agent.Response{Reply: "Plan persisted."}, nil
},
}}
if err := requireConductorPlan(context.Background(), mem, ag); err != nil {
t.Fatalf("requireConductorPlan returned %v, want recovered plan", err)
}
if ag.calls != 1 {
t.Fatalf("conductor calls = %d, want one plan recovery prompt", ag.calls)
}
}
func TestRequireConductorPlanFailureNamesScopedRecord(t *testing.T) {
err := requireConductorPlan(context.Background(), store.NewMemoryStore(), &scriptedAgent{replies: []func(context.Context, string) (*agent.Response, error){
func(context.Context, string) (*agent.Response, error) {
return &agent.Response{Reply: "Done without plan."}, nil
},
}})
if err == nil {
t.Fatal("requireConductorPlan returned nil, want missing plan diagnostic")
}
for _, want := range []string{"agent/conductor/plan", "without calling the built-in plan tool"} {
if got := err.Error(); !strings.Contains(got, want) {
t.Fatalf("error = %q, want %q", got, want)
}
}
}
func TestRequirePersistedPlanBeforeConductorActionsBlocksSideEffects(t *testing.T) {
mem := store.NewMemoryStore()
called := false
wrapped := requirePersistedPlanBeforeConductorActions(mem)(func(context.Context, ai.ToolCall) ai.ToolResult {
called = true
return ai.ToolResult{ID: "call-1", Content: `{"ok":true}`}
})
res := wrapped(context.Background(), ai.ToolCall{ID: "call-1", Name: "task.Add"})
if called {
t.Fatal("side-effecting tool ran before persisted plan")
}
if res.Refused != ai.RefusedApproval {
t.Fatalf("Refused = %q, want %q", res.Refused, ai.RefusedApproval)
}
if !strings.Contains(res.Content, "built-in plan tool") {
t.Fatalf("Content = %q, want plan-first steering message", res.Content)
}
}
func TestRequirePersistedPlanBeforeConductorActionsAllowsPlanAndPlannedActions(t *testing.T) {
mem := store.NewMemoryStore()
var calls []string
wrapped := requirePersistedPlanBeforeConductorActions(mem)(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
calls = append(calls, call.Name)
if call.Name == "plan" {
if err := store.Scope(mem, "agent", "conductor").Write(&store.Record{Key: "plan", Value: []byte(`{"steps":[{"task":"Design","status":"pending"}]}`)}); err != nil {
t.Fatalf("write plan: %v", err)
}
}
return ai.ToolResult{ID: call.ID, Content: `{"ok":true}`}
})
if res := wrapped(context.Background(), ai.ToolCall{ID: "call-1", Name: "plan"}); res.Refused != "" {
t.Fatalf("plan Refused = %q, want allowed", res.Refused)
}
if res := wrapped(context.Background(), ai.ToolCall{ID: "call-2", Name: "task.Add"}); res.Refused != "" {
t.Fatalf("planned action Refused = %q, want allowed", res.Refused)
}
if want := []string{"plan", "task.Add"}; !reflect.DeepEqual(calls, want) {
t.Fatalf("calls = %v, want %v", calls, want)
}
}
func TestPlanDelegateConductorRetriesAfterPlanOnlySuccess(t *testing.T) {
taskSvc := new(TaskService)
notifySvc := new(NotifyService)
ag := &scriptedAgent{replies: []func(context.Context, string) (*agent.Response, error){
func(context.Context, string) (*agent.Response, error) {
return &agent.Response{Reply: "Plan saved."}, nil
},
func(ctx context.Context, prompt string) (*agent.Response, error) {
if !strings.Contains(prompt, "Execute the persisted plan") {
return nil, errors.New("missing explicit side-effect recovery prompt")
}
for _, title := range []string{"Design", "Build", "Ship"} {
var rsp AddResponse
if err := taskSvc.Add(ctx, &AddRequest{Title: title}, &rsp); err != nil {
return nil, err
}
}
return &agent.Response{Reply: "Tasks created."}, nil
},
}}
step := planDelegateConductorStep(ag, taskSvc, notifySvc)
if _, err := step(context.Background(), flow.State{}); err != nil {
t.Fatalf("planDelegateConductorStep returned %v, want plan-only retry success", err)
}
if ag.calls != 2 {
t.Fatalf("conductor calls = %d, want initial plan-only call plus one side-effect retry", ag.calls)
}
if got := taskSvc.count(); got != 3 {
t.Fatalf("task count = %d, want recovered Design/Build/Ship side effects", got)
}
}
func TestPlanDelegateConductorFailsBeforeNotifyGateAfterPlanOnlyRetryMiss(t *testing.T) {
step := planDelegateConductorStep(&scriptedAgent{replies: []func(context.Context, string) (*agent.Response, error){
func(context.Context, string) (*agent.Response, error) {
return &agent.Response{Reply: "Plan saved."}, nil
},
func(context.Context, string) (*agent.Response, error) {
return &agent.Response{Reply: "Still planning."}, nil
},
}}, new(TaskService), new(NotifyService))
_, err := step(context.Background(), flow.State{})
if err == nil {
t.Fatal("planDelegateConductorStep returned nil, want pre-notify-gate task side-effect error")
}
for _, want := range []string{"before task side effects completed", "tasks=0/3", "task Add for Design, Build, and Ship"} {
if got := err.Error(); !strings.Contains(got, want) {
t.Fatalf("error = %q, want %q", got, want)
}
}
}
func TestPlanDelegateConductorAllowsNotifyRecoveryAfterUnfinishedDelegation(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
var rsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
}
notifySvc := new(NotifyService)
step := planDelegateConductorStep(failingAgent{err: errors.New("agent run abc has unfinished plan steps: Delegate readiness notification to comms agent")}, taskSvc, notifySvc)
if _, err := step(context.Background(), flow.State{}); err != nil {
t.Fatalf("planDelegateConductorStep returned %v, want require-notify recovery to run", err)
}
}
func TestPlanDelegateConductorKeepsUnfinishedTaskFailureActionable(t *testing.T) {
step := planDelegateConductorStep(failingAgent{err: errors.New("agent run abc has unfinished plan steps: Create Build task")}, new(TaskService), new(NotifyService))
err := func() error { _, err := step(context.Background(), flow.State{}); return err }()
if err == nil {
t.Fatal("planDelegateConductorStep returned nil, want unfinished task error")
}
if got := err.Error(); !strings.Contains(got, "Create Build task") {
t.Fatalf("error = %q, want original unfinished task detail", got)
}
}
func TestPlanDelegateExecutionRecoversMissingNotifyOnce(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
var rsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
}
notifySvc := new(NotifyService)
recovered := false
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
recovered = true
var rsp SendResponse
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
})(context.Background(), flow.State{})
if err != nil {
t.Fatalf("waitForPlanDelegateExecution returned %v, want recovery success", err)
}
if !recovered {
t.Fatal("missing notify recovery was not invoked")
}
if got := notifySvc.count(); got != 1 {
t.Fatalf("notify count = %d, want 1 after recovery", got)
}
}
func TestPlanDelegateExecutionWaitsForInFlightNotifyAfterFlowCompletion(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
var rsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
}
notifySvc := new(NotifyService)
go func() {
time.Sleep(100 * time.Millisecond)
var rsp SendResponse
_ = notifySvc.Send(context.Background(), &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
}()
recovered := false
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
recovered = true
var rsp SendResponse
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
})(context.Background(), flow.State{})
if err != nil {
t.Fatalf("waitForPlanDelegateExecution returned %v, want in-flight notify success", err)
}
if recovered {
t.Fatal("missing notify recovery ran while delegated notify was still in flight")
}
if got := taskSvc.count(); got != 3 {
t.Fatalf("task count = %d, want 3 after in-flight notify settles", got)
}
if got := notifySvc.count(); got != 1 {
t.Fatalf("notify count = %d, want 1 after in-flight notify settles", got)
}
if got := notifySvc.duplicateAttempts(); got != 0 {
t.Fatalf("duplicate notify attempts = %d, want 0", got)
}
}
func TestPlanDelegateRecoveryWaitsForRecoveredNotifySideEffect(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
var rsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
}
notifySvc := new(NotifyService)
recovered := false
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
recovered = true
go func() {
time.Sleep(100 * time.Millisecond)
var rsp SendResponse
_ = notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
}()
return nil
})(context.Background(), flow.State{})
if err != nil {
t.Fatalf("requireDelegatedNotifyStep returned %v, want delayed recovery success", err)
}
if !recovered {
t.Fatal("missing notify recovery did not run")
}
if got := notifySvc.count(); got != 1 {
t.Fatalf("notify count = %d, want recovered notify side effect", got)
}
}
func TestPlanDelegateExecutionAcceptsClientTimeoutAfterSideEffects(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
var rsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
}
notifySvc := new(NotifyService)
var rsp SendResponse
if err := notifySvc.Send(context.Background(), &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp); err != nil {
t.Fatalf("Send: %v", err)
}
done := make(chan error, 1)
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, nil); err != nil {
t.Fatalf("waitForPlanDelegateExecution returned %v, want completed side effects to satisfy client timeout", err)
}
}
func TestPlanDelegateExecutionAcceptsApprovalPauseAfterSideEffects(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
var rsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
}
notifySvc := new(NotifyService)
var rsp SendResponse
if err := notifySvc.Send(context.Background(), &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp); err != nil {
t.Fatalf("Send: %v", err)
}
done := make(chan error, 1)
done <- errors.New("agent run abc paused for approval: The comms agent is repeatedly timing out (408 errors) while retrying the launch-readiness notification")
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, nil); err != nil {
t.Fatalf("waitForPlanDelegateExecution returned %v, want completed side effects to satisfy approval pause", err)
}
}
func TestPlanDelegateExecutionClassifiesClientTimeoutBeforeSideEffects(t *testing.T) {
done := make(chan error, 1)
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
err := waitForPlanDelegateExecution(done, new(TaskService), new(NotifyService), nil)
if err == nil {
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before side effects to fail")
}
for _, want := range []string{
"provider latency/outage during plan-delegate",
"tasks=0/3 notify=0/1",
"retry live provider or inspect provider logs",
"Request Timeout",
} {
if got := err.Error(); !strings.Contains(got, want) {
t.Fatalf("error = %q, want %q", got, want)
}
}
}
func TestPlanDelegateExecutionClassifiesPartialClientTimeout(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
var rsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
}
done := make(chan error, 1)
done <- errors.New(`{"id":"go.micro.client","code":408,"detail":"<nil>","status":"Request Timeout"}`)
err := waitForPlanDelegateExecution(done, taskSvc, new(NotifyService), nil)
if err == nil {
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before notify to fail")
}
if got := err.Error(); !strings.Contains(got, "tasks=3/3 notify=0/1") {
t.Fatalf("error = %q, want partial side-effect counts", got)
}
}
func TestNotifyServiceSendIsIdempotentForDuplicateDelivery(t *testing.T) {
svc := new(NotifyService)
messages := []string{
"The launch plan is ready",
"The launch plan is ready.",
"Launch readiness: the plan is ready!",
}
for i, message := range messages {
var rsp SendResponse
to := "owner@acme.com"
if i == len(messages)-1 {
to = "owner"
}
if err := svc.Send(context.Background(), &SendRequest{To: to, Message: message}, &rsp); err != nil {
t.Fatalf("Send attempt %d: %v", i+1, err)
}
if !rsp.Sent {
t.Fatalf("Send attempt %d reported Sent=false", i+1)
}
}
if got := svc.count(); got != 1 {
t.Fatalf("notify count = %d, want 1 after duplicate delivery replays", got)
}
if got := svc.duplicateAttempts(); got != len(messages)-1 {
t.Fatalf("duplicate notify attempts = %d, want %d", got, len(messages)-1)
}
}
func TestNotifyServiceCollapsesProviderReadinessParaphrases(t *testing.T) {
svc := new(NotifyService)
requests := []SendRequest{
{To: "owner@acme.com", Message: "The launch plan is ready"},
{To: "owner @ acme.com", Message: "Launch plan ready."},
{To: "owner at acme dot com", Message: "The launch plan is ready."},
{To: "launch owner", Message: "The launch readiness plan is prepared."},
{To: "plan owner", Message: "Launch plan is complete!"},
}
for i, req := range requests {
var rsp SendResponse
if err := svc.Send(context.Background(), &req, &rsp); err != nil {
t.Fatalf("Send attempt %d: %v", i+1, err)
}
if !rsp.Sent {
t.Fatalf("Send attempt %d reported Sent=false", i+1)
}
}
if got := svc.count(); got != 1 {
t.Fatalf("notify count = %d, want 1 after provider paraphrase replays", got)
}
if got := svc.duplicateAttempts(); got != len(requests)-1 {
t.Fatalf("duplicate notify attempts = %d, want %d", got, len(requests)-1)
}
}
@@ -0,0 +1,99 @@
# Provider conformance
This harness keeps the services → agents → workflows lifecycle honest across the
supported AI providers. It runs the same end-to-end scenarios against each
configured provider and treats missing provider keys as an explicit skip, so the
suite is safe for local development, forks, and scheduled CI.
## What it exercises
`go run ./internal/harness/provider-conformance` fans out over the provider-facing
agent test and the harnesses in `internal/harness`:
- `agent` — provider tool-call conformance through `agent.Ask`, including run metadata propagation.
- `universe` — service discovery plus agent tool calls over the real runtime.
- `agent-flow` — a workflow event that drives an agent to call services.
- `plan-delegate` — plan persistence plus agent-to-agent delegation and service
calls.
- `a2a-stream-fallback` — A2A `message/stream` through the gateway, including
fallback from unsupported provider streaming to the tool-calling `Ask` path while
preserving run metadata.
The command also emits the registered provider capability matrix so the run shows
which providers advertise model, image, video, and streaming support. Console output
and summary artifacts label each harness with the phase it is proving (for example,
model call + tool call, workflow event + tool call, or streaming fallback + tool
call), so provider failures identify the failed lifecycle phase instead of only the
provider name.
## Local usage
Run the deterministic path with no secrets:
```sh
go run ./internal/harness/provider-conformance -providers mock
```
Run every live provider that has a key in the environment:
```sh
go run ./internal/harness/provider-conformance \
-summary-json provider-conformance-summary.json \
-summary-markdown provider-conformance-summary.md \
-capabilities-markdown provider-capabilities.md
```
Provider keys are read from `MICRO_AI_API_KEY` or the provider-specific variable:
| Provider | Secret / environment variable |
| --- | --- |
| Anthropic | `ANTHROPIC_API_KEY` |
| OpenAI | `OPENAI_API_KEY` |
| Gemini | `GEMINI_API_KEY` |
| Groq | `GROQ_API_KEY` |
| MiniMax | `MINIMAX_API_KEY` |
| Mistral | `MISTRAL_API_KEY` |
| Together | `TOGETHER_API_KEY` |
| AtlasCloud | `ATLASCLOUD_API_KEY` |
Use `-require-configured` when you want a selected provider without a key to fail
instead of skip. This is useful for manually checking that a required provider
secret is actually wired into CI before relying on that provider as covered:
```sh
go run ./internal/harness/provider-conformance \
-providers anthropic,openai \
-require-configured
```
## Scheduled CI behavior
The `Harness (E2E)` workflow runs on pushes and pull requests with deterministic
mock LLMs, including `provider-conformance -providers mock`. On the hourly
schedule and manual dispatch it also runs the live provider conformance job. A
manual dispatch can narrow `providers` or `harnesses`, and can set
`require_configured=true` to fail fast when an expected repository secret is
missing; scheduled runs keep the safe default and report missing keys as skips.
That job:
1. runs the same `agent`, `universe`, `agent-flow`, `plan-delegate`, and `a2a-stream-fallback` harness list,
2. reads the provider keys from repository secrets,
3. skips providers whose secrets are absent,
4. fails when any configured provider fails a harness, and
5. uploads JSON and Markdown coverage artifacts for the run.
The job also appends the Markdown summary and capability matrix to the GitHub
Actions step summary, making configured, skipped, and failed provider coverage
visible without downloading artifacts.
## Adding a provider
To bring a new provider into scheduled conformance:
1. register its `ai` provider implementation and capability metadata,
2. add the provider name and key variable to `providerEnv` in `main.go`,
3. import the provider package in `main.go`,
4. pass the matching repository secret through `.github/workflows/harness.yml`,
and
5. run `go run ./internal/harness/provider-conformance -providers <name> \
-require-configured` with a live key before opening the change.
@@ -0,0 +1,454 @@
// Provider conformance runs the same end-to-end harnesses across model
// providers whose API keys are configured. Missing keys are skipped so the
// command is safe in local development and scheduled CI; a configured provider
// that fails any harness makes the command fail.
//
// Run all live providers with configured keys:
//
// go run ./internal/harness/provider-conformance
//
// Run the deterministic mock path only:
//
// go run ./internal/harness/provider-conformance -providers mock
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"os"
"os/exec"
"path/filepath"
"slices"
"strings"
"time"
"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/minimax"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
const defaultHarnesses = "agent,universe,agent-flow,plan-delegate,a2a-streaming,a2a-stream-fallback"
var harnessPhases = map[string]string{
"agent": "model call + tool call",
"universe": "service discovery + tool call",
"agent-flow": "workflow event + tool call",
"plan-delegate": "plan persistence + delegation + tool call",
"a2a-streaming": "A2A streaming + tool call",
"a2a-stream-fallback": "streaming fallback + tool call",
}
var providerEnv = map[string]string{
"anthropic": "ANTHROPIC_API_KEY",
"openai": "OPENAI_API_KEY",
"gemini": "GEMINI_API_KEY",
"groq": "GROQ_API_KEY",
"minimax": "MINIMAX_API_KEY",
"mistral": "MISTRAL_API_KEY",
"together": "TOGETHER_API_KEY",
"atlascloud": "ATLASCLOUD_API_KEY",
}
func main() {
providersFlag := flag.String("providers", defaultProviders(), "comma-separated providers to check; use mock for deterministic local checks")
harnessesFlag := flag.String("harnesses", defaultHarnesses, "comma-separated harness names under internal/harness; agent runs the provider tool-call conformance test")
timeoutFlag := flag.Duration("timeout", 10*time.Minute, "timeout per provider/harness run")
requireConfiguredFlag := flag.Bool("require-configured", false, "fail when a selected live provider is missing an API key")
capabilitiesFlag := flag.Bool("capabilities", true, "print the registered provider capability matrix before running conformance")
summaryJSONFlag := flag.String("summary-json", "", "write a machine-readable conformance summary to this path")
summaryMarkdownFlag := flag.String("summary-markdown", "", "write a human-readable conformance summary to this path")
capabilityMarkdownFlag := flag.String("capabilities-markdown", "", "write the registered provider capability matrix as a Markdown table")
flag.Parse()
providers := splitCSV(*providersFlag)
harnesses := splitCSV(*harnessesFlag)
if err := validateSelection(providers, harnesses); err != nil {
fmt.Fprintln(os.Stderr, err)
os.Exit(2)
}
if *capabilitiesFlag {
printCapabilityMatrix()
}
if *capabilityMarkdownFlag != "" {
if err := writeCapabilityMarkdown(*capabilityMarkdownFlag, ai.CapabilityRows()); err != nil {
fmt.Fprintf(os.Stderr, "write capabilities markdown: %v\n", err)
os.Exit(1)
}
}
var ran, skipped, failed int
var results []conformanceResult
for _, provider := range providers {
if provider != "mock" && providerKey(provider) == "" {
msg := fmt.Sprintf("set MICRO_AI_API_KEY or %s", providerEnv[provider])
if *requireConfiguredFlag {
fmt.Printf("FAIL %s: missing API key (%s)\n", provider, msg)
failed++
results = append(results, missingKeyResults(provider, harnesses, statusFailed, "missing API key: "+msg)...)
} else {
fmt.Printf("- %s: skipped (%s)\n", provider, msg)
skipped++
results = append(results, missingKeyResults(provider, harnesses, statusSkipped, msg)...)
}
continue
}
for _, harness := range harnesses {
phase := harnessPhase(harness)
fmt.Printf("\n==> %s / %s (%s)\n", provider, harness, phase)
if err := runHarness(provider, harness, *timeoutFlag); err != nil {
fmt.Printf("FAIL %s / %s (%s): %v\n", provider, harness, phase, err)
failed++
results = append(results, conformanceResult{Provider: provider, Harness: harness, Phase: phase, Status: statusFailed, Error: err.Error()})
continue
}
ran++
results = append(results, conformanceResult{Provider: provider, Harness: harness, Phase: phase, Status: statusPassed})
}
}
fmt.Printf("\nprovider conformance: %d passed, %d skipped providers, %d failed\n", ran, skipped, failed)
summary := conformanceSummary{
Providers: providers,
Harnesses: harnesses,
Capabilities: ai.CapabilityRows(),
Results: results,
Passed: ran,
Skipped: skipped,
Failed: failed,
}
if *summaryJSONFlag != "" {
if err := writeSummaryJSON(*summaryJSONFlag, summary); err != nil {
fmt.Fprintf(os.Stderr, "write summary: %v\n", err)
os.Exit(1)
}
}
if *summaryMarkdownFlag != "" {
if err := writeSummaryMarkdown(*summaryMarkdownFlag, summary); err != nil {
fmt.Fprintf(os.Stderr, "write summary markdown: %v\n", err)
os.Exit(1)
}
}
if failed > 0 {
os.Exit(1)
}
}
const (
statusPassed = "passed"
statusSkipped = "skipped"
statusFailed = "failed"
)
type conformanceResult struct {
Provider string `json:"provider"`
Harness string `json:"harness,omitempty"`
Phase string `json:"phase,omitempty"`
Status string `json:"status"`
Error string `json:"error,omitempty"`
}
type conformanceSummary struct {
Providers []string `json:"providers"`
Harnesses []string `json:"harnesses"`
Capabilities []ai.CapabilityRow `json:"capabilities"`
Results []conformanceResult `json:"results"`
Passed int `json:"passed"`
Skipped int `json:"skipped"`
Failed int `json:"failed"`
}
func missingKeyResults(provider string, harnesses []string, status, detail string) []conformanceResult {
results := make([]conformanceResult, 0, len(harnesses))
for _, harness := range harnesses {
results = append(results, conformanceResult{
Provider: provider,
Harness: harness,
Phase: harnessPhase(harness),
Status: status,
Error: detail,
})
}
return results
}
func writeSummaryJSON(path string, summary conformanceSummary) error {
b, err := json.MarshalIndent(summary, "", " ")
if err != nil {
return err
}
b = append(b, '\n')
return os.WriteFile(path, b, 0o644)
}
func writeCapabilityMarkdown(path string, rows []ai.CapabilityRow) error {
return os.WriteFile(path, []byte(capabilityMarkdown(rows)), 0o644)
}
func writeSummaryMarkdown(path string, summary conformanceSummary) error {
var b strings.Builder
b.WriteString("# Provider conformance summary\n\n")
fmt.Fprintf(&b, "Passed: %d. Skipped providers: %d. Failed: %d.\n\n", summary.Passed, summary.Skipped, summary.Failed)
fmt.Fprintf(&b, "Providers: %s.\n\n", markdownList(summary.Providers))
fmt.Fprintf(&b, "Harnesses: %s.\n\n", markdownList(summary.Harnesses))
b.WriteString("## Capability matrix\n\n")
b.WriteString(capabilityMarkdown(summary.Capabilities))
b.WriteString("\n## Harness results\n\n")
b.WriteString("| Provider | Harness | Phase | Status | Detail |\n")
b.WriteString("| --- | --- | --- | --- | --- |\n")
for _, result := range summary.Results {
harness := result.Harness
if harness == "" {
harness = "—"
}
phase := result.Phase
if phase == "" {
phase = "—"
}
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s |\n", result.Provider, harness, markdownCell(phase), result.Status, markdownCell(result.Error))
}
return os.WriteFile(path, []byte(b.String()), 0o644)
}
func capabilityMarkdown(rows []ai.CapabilityRow) string {
var b strings.Builder
b.WriteString("| Provider | Model | Image | Video | Streaming | Tool streaming |\n")
b.WriteString("| --- | --- | --- | --- | --- | --- |\n")
for _, row := range rows {
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s |\n", row.Provider, mark(row.Model), mark(row.Image), mark(row.Video), mark(row.Stream), mark(row.ToolStream))
}
return b.String()
}
func markdownList(values []string) string {
if len(values) == 0 {
return "—"
}
escaped := make([]string, 0, len(values))
for _, value := range values {
escaped = append(escaped, "`"+strings.ReplaceAll(value, "`", "\\`")+"`")
}
return strings.Join(escaped, ", ")
}
func harnessPhase(harness string) string {
if phase, ok := harnessPhases[harness]; ok {
return phase
}
return "harness"
}
func markdownCell(s string) string {
if s == "" {
return "—"
}
s = strings.ReplaceAll(s, "|", "\\|")
s = strings.ReplaceAll(s, "\n", "<br>")
return s
}
func mark(ok bool) string {
if ok {
return "✅"
}
return "—"
}
func printCapabilityMatrix() {
fmt.Println("Provider capability matrix:")
fmt.Println("provider model image video stream tool-stream")
for _, row := range ai.CapabilityRows() {
fmt.Printf("%-12s %-5s %-5s %-5s %-6s %-11s\n", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video), yesNo(row.Stream), yesNo(row.ToolStream))
}
fmt.Println()
}
func yesNo(ok bool) string {
if ok {
return "yes"
}
return "no"
}
func validateSelection(providers, harnesses []string) error {
if len(providers) == 0 {
return fmt.Errorf("no providers selected")
}
if len(harnesses) == 0 {
return fmt.Errorf("no harnesses selected")
}
for _, provider := range providers {
if provider == "mock" {
continue
}
if _, ok := providerEnv[provider]; !ok {
return fmt.Errorf("unknown provider %q (known: %s)", provider, knownProviders())
}
}
for _, harness := range harnesses {
if harness == "agent" {
continue
}
if strings.Contains(harness, string(os.PathSeparator)) || harness == "." || harness == ".." {
return fmt.Errorf("invalid harness name %q", harness)
}
info, err := os.Stat(filepath.Join(repoRoot(), "internal", "harness", harness))
if err != nil {
return fmt.Errorf("unknown harness %q: %w", harness, err)
}
if !info.IsDir() {
return fmt.Errorf("harness %q is not a directory", harness)
}
}
return nil
}
func repoRoot() string {
wd, err := os.Getwd()
if err != nil {
return "."
}
for {
if _, err := os.Stat(filepath.Join(wd, "go.mod")); err == nil {
return wd
}
parent := filepath.Dir(wd)
if parent == wd {
return "."
}
wd = parent
}
}
func defaultProviders() string {
providers := make([]string, 0, len(providerEnv))
for provider := range providerEnv {
providers = append(providers, provider)
}
slices.Sort(providers)
return strings.Join(providers, ",")
}
func knownProviders() string {
providers := make([]string, 0, len(providerEnv)+1)
providers = append(providers, "mock")
for provider := range providerEnv {
providers = append(providers, provider)
}
slices.Sort(providers)
return strings.Join(providers, ",")
}
func splitCSV(s string) []string {
var out []string
for _, part := range strings.Split(s, ",") {
part = strings.TrimSpace(part)
if part != "" {
out = append(out, part)
}
}
return out
}
func providerKey(provider string) string {
if v := os.Getenv("MICRO_AI_API_KEY"); v != "" {
return v
}
return os.Getenv(providerEnv[provider])
}
func localRPCEnv(env []string) []string {
filtered := env[:0]
for _, kv := range env {
key, _, ok := strings.Cut(kv, "=")
if !ok {
filtered = append(filtered, kv)
continue
}
switch strings.ToUpper(key) {
case "HTTP_PROXY", "HTTPS_PROXY", "NO_PROXY":
continue
default:
filtered = append(filtered, kv)
}
}
return append(filtered, "HTTP_PROXY=", "HTTPS_PROXY=", "NO_PROXY=*")
}
func runHarness(provider, harness string, timeout time.Duration) error {
if harness == "agent" {
return runAgentConformance(provider, timeout)
}
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
// Build the harness to a temp binary and run that, rather than `go run`:
// `go run` launches the compiled binary as a child it does not kill on
// context cancellation, so a harness that starts local services could
// outlive the timeout. Running the binary ourselves keeps the timeout
// honest — canceling the context kills the process that does the work.
binDir, err := os.MkdirTemp("", "harness-")
if err != nil {
return err
}
defer os.RemoveAll(binDir)
binPath := filepath.Join(binDir, harness)
build := exec.CommandContext(ctx, "go", "build", "-o", binPath, "./internal/harness/"+harness)
build.Dir = repoRoot()
build.Stdout = os.Stdout
build.Stderr = os.Stderr
if err := build.Run(); err != nil {
return fmt.Errorf("build: %w", err)
}
cmd := exec.CommandContext(ctx, binPath, "-provider", provider)
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = localRPCEnv(os.Environ())
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("timed out after %s", timeout)
}
return err
}
return nil
}
func runAgentConformance(provider string, timeout time.Duration) error {
ctx, cancel := context.WithTimeout(context.Background(), timeout)
defer cancel()
testProvider := provider
if provider == "mock" {
testProvider = "fake"
}
cmd := exec.CommandContext(ctx, "go", "test", "./agent", "-run", "TestAgentProvider(ConformanceMatrix|StreamConformanceMatrix)", "-count=1", "-v")
cmd.Dir = repoRoot()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
cmd.Env = localRPCEnv(append(os.Environ(),
"GO_MICRO_AGENT_CONFORMANCE_LIVE=1",
"GO_MICRO_AGENT_CONFORMANCE_PROVIDERS="+testProvider,
))
if err := cmd.Run(); err != nil {
if ctx.Err() == context.DeadlineExceeded {
return fmt.Errorf("timed out after %s", timeout)
}
return err
}
return nil
}
@@ -0,0 +1,209 @@
package main
import (
"encoding/json"
"os"
"path/filepath"
"strings"
"testing"
"go-micro.dev/v6/ai"
)
func TestValidateSelectionAcceptsKnownProviderAndHarness(t *testing.T) {
if err := validateSelection([]string{"mock"}, []string{"provider-conformance"}); err != nil {
t.Fatalf("validateSelection returned error for known selection: %v", err)
}
}
func TestValidateSelectionRejectsUnknownProvider(t *testing.T) {
err := validateSelection([]string{"not-a-provider"}, []string{"provider-conformance"})
if err == nil {
t.Fatal("validateSelection returned nil for unknown provider")
}
if !strings.Contains(err.Error(), `unknown provider "not-a-provider"`) {
t.Fatalf("validateSelection error = %q, want unknown provider message", err)
}
}
func TestValidateSelectionRejectsUnsafeHarnessName(t *testing.T) {
err := validateSelection([]string{"mock"}, []string{"../agent-flow"})
if err == nil {
t.Fatal("validateSelection returned nil for unsafe harness name")
}
if !strings.Contains(err.Error(), `invalid harness name "../agent-flow"`) {
t.Fatalf("validateSelection error = %q, want invalid harness message", err)
}
}
func TestDefaultProvidersTracksLiveProviderSet(t *testing.T) {
got := defaultProviders()
for _, want := range []string{"anthropic", "openai", "gemini", "groq", "minimax", "mistral", "together", "atlascloud"} {
if !strings.Contains(got, want) {
t.Fatalf("defaultProviders() = %q, want %q", got, want)
}
}
if strings.Contains(got, "mock") {
t.Fatalf("defaultProviders() = %q, should not include mock in live scheduled defaults", got)
}
}
func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
rows := ai.CapabilityRows()
if len(rows) == 0 {
t.Fatal("CapabilityRows returned no providers")
}
var foundOpenAI, foundMiniMax bool
for _, row := range rows {
if row.Provider == "openai" {
foundOpenAI = true
if !row.Model || !row.Image || row.Video {
t.Fatalf("openai capabilities = %#v, want model+image only", row.Capabilities)
}
}
if row.Provider == "minimax" {
foundMiniMax = true
if !row.Model || !row.Stream || row.Image || row.Video {
t.Fatalf("minimax capabilities = %#v, want model+stream only", row.Capabilities)
}
}
}
if !foundOpenAI {
t.Fatalf("CapabilityRows = %#v, want openai row", rows)
}
if !foundMiniMax {
t.Fatalf("CapabilityRows = %#v, want minimax row", rows)
}
}
func TestMissingKeyResultsReportsEachHarness(t *testing.T) {
results := missingKeyResults("openai", []string{"agent", "plan-delegate"}, statusSkipped, "set OPENAI_API_KEY")
if len(results) != 2 {
t.Fatalf("missingKeyResults returned %d results, want 2", len(results))
}
wants := []conformanceResult{
{Provider: "openai", Harness: "agent", Phase: harnessPhase("agent"), Status: statusSkipped, Error: "set OPENAI_API_KEY"},
{Provider: "openai", Harness: "plan-delegate", Phase: harnessPhase("plan-delegate"), Status: statusSkipped, Error: "set OPENAI_API_KEY"},
}
for i, want := range wants {
if results[i] != want {
t.Fatalf("result[%d] = %#v, want %#v", i, results[i], want)
}
}
}
func TestWriteCapabilityMarkdown(t *testing.T) {
path := filepath.Join(t.TempDir(), "capabilities.md")
rows := []ai.CapabilityRow{
{Provider: "mock", Capabilities: ai.Capabilities{Model: true}},
{Provider: "vision", Capabilities: ai.Capabilities{Image: true, Video: true}},
}
if err := writeCapabilityMarkdown(path, rows); err != nil {
t.Fatalf("writeCapabilityMarkdown returned error: %v", err)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read capabilities markdown: %v", err)
}
got := string(b)
for _, want := range []string{
"| Provider | Model | Image | Video | Streaming | Tool streaming |",
"| mock | ✅ | — | — | — |",
"| vision | — | ✅ | ✅ | — |",
} {
if !strings.Contains(got, want) {
t.Fatalf("capabilities markdown = %q, want row %q", got, want)
}
}
}
func TestWriteSummaryMarkdown(t *testing.T) {
path := filepath.Join(t.TempDir(), "summary.md")
summary := conformanceSummary{
Capabilities: []ai.CapabilityRow{{Provider: "mock", Capabilities: ai.Capabilities{Model: true}}},
Results: []conformanceResult{
{Provider: "mock", Harness: "agent-flow", Phase: harnessPhase("agent-flow"), Status: statusPassed},
{Provider: "live", Status: statusSkipped, Error: "missing | key"},
},
Passed: 1,
Skipped: 1,
}
if err := writeSummaryMarkdown(path, summary); err != nil {
t.Fatalf("writeSummaryMarkdown returned error: %v", err)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read summary markdown: %v", err)
}
got := string(b)
for _, want := range []string{
"# Provider conformance summary",
"Passed: 1. Skipped providers: 1. Failed: 0.",
"Providers: —.",
"Harnesses: —.",
"| mock | ✅ | — | — | — |",
"| mock | agent-flow | workflow event + tool call | passed | — |",
"| live | — | — | skipped | missing \\| key |",
} {
if !strings.Contains(got, want) {
t.Fatalf("summary markdown = %q, want %q", got, want)
}
}
}
func TestHarnessPhaseLabelsKnownHarnesses(t *testing.T) {
if got := harnessPhase("a2a-streaming"); got != "A2A streaming + tool call" {
t.Fatalf("harnessPhase(a2a-streaming) = %q, want A2A streaming phase", got)
}
if got := harnessPhase("a2a-stream-fallback"); got != "streaming fallback + tool call" {
t.Fatalf("harnessPhase(a2a-stream-fallback) = %q, want streaming fallback phase", got)
}
if got := harnessPhase("custom"); got != "harness" {
t.Fatalf("harnessPhase(custom) = %q, want fallback phase", got)
}
}
func TestMarkdownListEscapesBackticks(t *testing.T) {
got := markdownList([]string{"agent", "bad`name"})
want := "`agent`, `bad\\`name`"
if got != want {
t.Fatalf("markdownList() = %q, want %q", got, want)
}
}
func TestWriteSummaryJSON(t *testing.T) {
path := filepath.Join(t.TempDir(), "summary.json")
summary := conformanceSummary{
Providers: []string{"mock"},
Harnesses: []string{"provider-conformance"},
Results: []conformanceResult{{
Provider: "mock",
Harness: "provider-conformance",
Status: statusPassed,
}},
Passed: 1,
}
if err := writeSummaryJSON(path, summary); err != nil {
t.Fatalf("writeSummaryJSON returned error: %v", err)
}
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read summary: %v", err)
}
if !strings.HasSuffix(string(b), "\n") {
t.Fatalf("summary JSON should end with newline: %q", b)
}
var got conformanceSummary
if err := json.Unmarshal(b, &got); err != nil {
t.Fatalf("summary JSON did not decode: %v", err)
}
if got.Passed != 1 || len(got.Results) != 1 || got.Results[0].Status != statusPassed {
t.Fatalf("summary JSON decoded as %#v, want one passed result", got)
}
}
@@ -0,0 +1,43 @@
package main
import (
"os"
"path/filepath"
"strings"
"testing"
)
func TestHarnessWorkflowSchedulesLiveProviderMatrix(t *testing.T) {
path := filepath.Join(repoRoot(), ".github", "workflows", "harness.yml")
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read harness workflow: %v", err)
}
workflow := string(b)
checks := []string{
`name: Harness (E2E)`,
`schedule:`,
`cron: "17 * * * *"`,
`workflow_dispatch:`,
`harness-live:`,
`if: github.event_name == 'schedule' || github.event_name == 'workflow_dispatch'`,
`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 }}`,
`MINIMAX_API_KEY: ${{ secrets.MINIMAX_API_KEY }}`,
`MISTRAL_API_KEY: ${{ secrets.MISTRAL_API_KEY }}`,
`TOGETHER_API_KEY: ${{ secrets.TOGETHER_API_KEY }}`,
`ATLASCLOUD_API_KEY: ${{ secrets.ATLASCLOUD_API_KEY }}`,
`-summary-json provider-conformance-summary.json`,
`-summary-markdown provider-conformance-summary.md`,
`-capabilities-markdown provider-capabilities.md`,
`actions/upload-artifact@v4`,
}
for _, want := range checks {
if !strings.Contains(workflow, want) {
t.Fatalf("harness workflow missing %q", want)
}
}
}
+601
View File
@@ -0,0 +1,601 @@
// Universe harness — a mini end-to-end world, spun up and shut down.
//
// It boots a small but real go-micro system in one process and drives a
// realistic scenario across all the moving parts:
//
// - four real services (inventory, payment, orders, notify) over RPC;
// - a DURABLE FLOW "checkout" with ordered, checkpointed steps that
// reserves, charges, confirms, then hands off to an agent;
// - a CRASH + RESUME: the payment dependency fails on first contact, the
// run is checkpointed at that step, and on resume it continues without
// re-running the steps that already completed;
// - an AGENT "concierge" with guardrails and a tool-execution wrapper,
// reached by the flow over RPC, that sends the welcome notification;
// - SCOPED STATE: the flow's runs and the agent's history land in their
// own store tables.
//
// Everything is real — registry, RPC, broker, store, the flow engine, the
// agent loop. Only the LLM is mocked, so it runs free and deterministically
// in CI. Pass -provider anthropic (with a key) to run it against a live
// model. It exits non-zero if any assertion fails, so it doubles as an
// end-to-end test.
//
// Run:
//
// go run ./internal/harness/universe
package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http/httptest"
"os"
"strings"
"sync"
"sync/atomic"
"time"
"go-micro.dev/v6/agent"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
codecbytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/internal/harness/harnessutil"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/service"
"go-micro.dev/v6/store"
)
// ---------------------------------------------------------------------------
// services
// ---------------------------------------------------------------------------
type Order struct {
Order string `json:"order" description:"Order id (required)"`
Reserved bool `json:"reserved,omitempty"`
Charged bool `json:"charged,omitempty"`
Confirmed bool `json:"confirmed,omitempty"`
}
type Inventory struct{ reserves int64 }
// Reserve holds stock for an order.
// @example {"order": "order-1"}
func (s *Inventory) Reserve(_ context.Context, req *Order, rsp *Order) error {
atomic.AddInt64(&s.reserves, 1)
fmt.Printf(" \033[32m[inventory]\033[0m reserved %s\n", req.Order)
*rsp = *req
rsp.Reserved = true
return nil
}
type Payment struct{ attempts int64 }
// Charge captures payment for an order. It fails the first time it is
// called (a transient outage) and succeeds afterwards — so the checkout
// flow crashes mid-run the first time and recovers on resume.
// @example {"order": "order-1"}
func (s *Payment) Charge(_ context.Context, req *Order, rsp *Order) error {
n := atomic.AddInt64(&s.attempts, 1)
if n == 1 {
fmt.Printf(" \033[31m[payment]\033[0m gateway timeout (attempt %d)\n", n)
return fmt.Errorf("payment gateway timeout")
}
fmt.Printf(" \033[32m[payment]\033[0m charged %s (attempt %d)\n", req.Order, n)
*rsp = *req
rsp.Charged = true
return nil
}
type Orders struct{ confirms int64 }
// Confirm finalizes an order.
// @example {"order": "order-1"}
func (s *Orders) Confirm(_ context.Context, req *Order, rsp *Order) error {
atomic.AddInt64(&s.confirms, 1)
fmt.Printf(" \033[32m[orders]\033[0m confirmed %s\n", req.Order)
*rsp = *req
rsp.Confirmed = true
return nil
}
type SendRequest struct {
To string `json:"to" description:"Recipient (required)"`
Message string `json:"message" description:"Message body (required)"`
}
type SendResponse struct {
Sent bool `json:"sent"`
}
type Notify struct {
mu sync.Mutex
sent int64
seen map[string]struct{}
lastRejected *SendRequest
}
// Send delivers a notification.
// @example {"to": "buyer@acme.com", "message": "Your order is confirmed"}
func (s *Notify) Send(_ context.Context, req *SendRequest, rsp *SendResponse) error {
if !isBuyerNotification(req) {
to, message := "", ""
if req != nil {
to, message = req.To, req.Message
}
s.recordRejected(to, message)
fmt.Printf(" \033[35m[notify]\033[0m 📨 ignored non-buyer notification to=%s %q\n", to, message)
rsp.Sent = false
return nil
}
s.recordRejected("", "")
keys := notificationDedupeKeys(req)
s.mu.Lock()
if s.seen == nil {
s.seen = make(map[string]struct{})
}
for _, key := range keys {
if _, ok := s.seen[key]; ok {
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m 📨 duplicate suppressed to=%s %q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
}
for _, key := range keys {
s.seen[key] = struct{}{}
}
s.mu.Unlock()
atomic.AddInt64(&s.sent, 1)
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s %q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
func isBuyerNotification(req *SendRequest) bool {
if req == nil {
return false
}
return canonicalBuyerRecipient(req.To) != ""
}
func (s *Notify) recordRejected(to, message string) {
s.mu.Lock()
defer s.mu.Unlock()
if strings.TrimSpace(to) == "" && strings.TrimSpace(message) == "" {
s.lastRejected = nil
return
}
s.lastRejected = &SendRequest{To: to, Message: message}
}
func (s *Notify) rejectedSummary() string {
s.mu.Lock()
defer s.mu.Unlock()
if s.lastRejected == nil {
return "no rejected notify call observed"
}
return fmt.Sprintf("last notify args to=%q message=%q", s.lastRejected.To, s.lastRejected.Message)
}
func canonicalBuyerRecipient(to string) string {
recipient := strings.ToLower(strings.TrimSpace(to))
switch recipient {
case "buyer", "buyer@acme.com":
return "buyer@acme.com"
}
if strings.HasPrefix(recipient, "buyer-of-order-") && len(recipient) > len("buyer-of-order-") {
return "buyer@acme.com"
}
for _, field := range strings.FieldsFunc(recipient, func(r rune) bool {
switch r {
case ' ', '\t', '\n', '\r', ',', ';', ':', '/', '\\', '(', ')', '[', ']', '{', '}':
return true
default:
return false
}
}) {
switch field {
case "buyer", "buyer@acme.com":
return "buyer@acme.com"
}
}
return ""
}
func notificationDedupeKeys(req *SendRequest) []string {
recipient := canonicalBuyerRecipient(req.To)
if recipient == "" {
recipient = strings.TrimSpace(req.To)
}
keys := []string{recipient + "\x00" + req.Message}
message := strings.ToLower(req.Message)
if strings.Contains(message, "confirm") {
// Live models occasionally emit equivalent confirmation copy more than
// once while a resumed checkout is completing (for example, a concise
// "order-1 confirmed" followed by a fuller buyer-facing sentence). The
// harness has one checkout order, so treat confirmation messages to the
// same buyer as the same side effect while preserving exact-message
// idempotency for all other notifications.
keys = append(keys, recipient+"\x00confirmation")
}
return keys
}
func dispatchNotifyStep(agentName string, cl client.Client, ntf *Notify) flow.StepFunc {
return func(ctx context.Context, in flow.State) (flow.State, error) {
before := atomic.LoadInt64(&ntf.sent)
out, err := dispatchBuyerNotification(ctx, agentName, cl, in)
if err != nil {
out = in
}
return completeNotifyOnObservedSideEffect(ctx, out, ntf, before, 2*time.Second, err)
}
}
func dispatchBuyerNotification(ctx context.Context, agentName string, cl client.Client, in flow.State) (flow.State, error) {
if cl == nil {
cl = client.DefaultClient
}
info, _ := ai.RunInfoFrom(ctx)
message := fmt.Sprintf(
"Checkout flow confirmed this order: %s. Use notify.Send exactly once to notify buyer@acme.com that the order is confirmed. Do not reply until the notify tool call has completed.",
strings.TrimSpace(in.String()),
)
body, _ := json.Marshal(map[string]string{"message": message, "parent_id": info.RunID})
req := cl.NewRequest(agentName, "Agent.Chat", &codecbytes.Frame{Data: body})
var rsp codecbytes.Frame
if err := cl.Call(ctx, req, &rsp); err != nil {
return in, err
}
var out struct {
Reply string `json:"reply"`
}
_ = json.Unmarshal(rsp.Data, &out)
in.Data = []byte(out.Reply)
return in, nil
}
func completeNotifyOnObservedSideEffect(ctx context.Context, in flow.State, ntf *Notify, before int64, wait time.Duration, dispatchErr error) (flow.State, error) {
deadline := time.Now().Add(wait)
for time.Now().Before(deadline) {
if atomic.LoadInt64(&ntf.sent) > before {
in.Data = []byte("Buyer notified.")
return in, nil
}
wait := time.NewTimer(25 * time.Millisecond)
select {
case <-ctx.Done():
if !wait.Stop() {
<-wait.C
}
if dispatchErr == nil {
return in, ctx.Err()
}
// A timed-out Agent.Chat call can report the caller context as done
// while the remote agent is still finishing its notify tool call.
// Keep watching for the idempotent side effect until the local settle
// window expires so a post-side-effect timeout does not strand the
// durable checkout run as pending.
case <-wait.C:
}
}
if dispatchErr != nil {
return in, dispatchErr
}
return in, fmt.Errorf("concierge completed without notifying buyer: notify count stayed at %d; expected recipient buyer@acme.com, buyer, or buyer-of-order-<id>; %s", before, ntf.rejectedSummary())
}
// ---------------------------------------------------------------------------
// mock LLM — the only fake. The concierge agent uses it to decide to notify.
// ---------------------------------------------------------------------------
type mockModel struct{ opts ai.Options }
func newMock(opts ...ai.Option) ai.Model {
m := &mockModel{}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *mockModel) Options() ai.Options { return m.opts }
func (m *mockModel) String() string { return "mock" }
func (m *mockModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return nil, fmt.Errorf("stream not supported by mock")
}
func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (*ai.Response, error) {
if strings.Contains(strings.ToLower(req.Prompt), "a2a reachability probe") {
return &ai.Response{Answer: "concierge reachable"}, nil
}
// The concierge is asked to notify the buyer. Find the notify tool and call it.
for _, t := range req.Tools {
if strings.Contains(t.Name, "Send") && m.opts.ToolHandler != nil {
m.opts.ToolHandler(ctx, ai.ToolCall{
ID: "call-1",
Name: t.Name,
Input: map[string]any{"to": "buyer@acme.com", "message": "Your order is confirmed."},
})
break
}
}
return &ai.Response{Answer: "Buyer notified."}, nil
}
// ---------------------------------------------------------------------------
// assertions
// ---------------------------------------------------------------------------
var failures int
func check(cond bool, format string, args ...any) {
if cond {
fmt.Printf(" \033[32m✓\033[0m %s\n", fmt.Sprintf(format, args...))
return
}
fmt.Printf(" \033[31m✗ %s\033[0m\n", fmt.Sprintf(format, args...))
failures++
}
// a2aReachable calls the named agent through the gateway using the A2A
// client — exercising both directions of the protocol — and reports
// whether the agent replied. The probe is intentionally side-effect-free:
// the checkout flow already proved notify tool execution, and reachability
// should not depend on a live model deciding to send another notification.
func a2aReachable(ctx context.Context, base, agent string) error {
probe := "A2A reachability probe only. Reply with the words concierge reachable. Do not call tools or send notifications."
deadline, ok := ctx.Deadline()
if !ok {
deadline = time.Now().Add(10 * time.Second)
}
var lastErr error
for attempt := 1; ; attempt++ {
if err := ctx.Err(); err != nil {
if lastErr != nil {
return fmt.Errorf("A2A reachability probe failed after %d attempt(s): %w", attempt-1, lastErr)
}
return err
}
remaining := time.Until(deadline)
if remaining <= 0 {
if lastErr != nil {
return fmt.Errorf("A2A reachability probe failed after %d attempt(s): %w", attempt-1, lastErr)
}
return context.DeadlineExceeded
}
attemptTimeout := 4 * time.Second
if remaining < attemptTimeout {
attemptTimeout = remaining
}
attemptCtx, cancel := context.WithTimeout(ctx, attemptTimeout)
reply, err := a2a.NewClient(base+"/agents/"+agent).Send(attemptCtx, probe)
cancel()
if err == nil && strings.TrimSpace(reply) != "" {
return nil
}
if err == nil {
err = fmt.Errorf("empty A2A reply")
}
lastErr = err
if time.Until(deadline) <= 0 {
return fmt.Errorf("A2A reachability probe failed after %d attempt(s): %w", attempt, lastErr)
}
time.Sleep(minDuration(200*time.Millisecond*time.Duration(attempt), time.Until(deadline)))
}
}
func minDuration(a, b time.Duration) time.Duration {
if a < b {
return a
}
return b
}
func providerKey(provider string) string {
if v := os.Getenv("MICRO_AI_API_KEY"); v != "" {
return v
}
return os.Getenv(map[string]string{
"anthropic": "ANTHROPIC_API_KEY", "openai": "OPENAI_API_KEY",
"gemini": "GEMINI_API_KEY", "groq": "GROQ_API_KEY", "mistral": "MISTRAL_API_KEY",
"together": "TOGETHER_API_KEY", "atlascloud": "ATLASCLOUD_API_KEY",
}[provider])
}
func waitFor(reg registry.Registry, names ...string) {
deadline := time.Now().Add(5 * time.Second)
for _, name := range names {
for time.Now().Before(deadline) {
if svcs, err := reg.GetService(name); err == nil && len(svcs) > 0 && len(svcs[0].Nodes) > 0 {
break
}
time.Sleep(20 * time.Millisecond)
}
}
}
func main() {
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
flag.Parse()
os.Exit(runUniverse(*provider))
}
func runUniverse(provider string) int {
failures = 0
apiKey := ""
if provider == "mock" {
ai.Register("mock", newMock)
} else if apiKey = providerKey(provider); apiKey == "" {
fmt.Printf("no API key for provider %q — set MICRO_AI_API_KEY or the provider's key env\n", provider)
return 2
}
fmt.Printf("\n\033[1mUNIVERSE — booting a mini go-micro world (provider: %s)\033[0m\n\n", provider)
// Infrastructure — all in-memory, all real.
reg := registry.NewMemoryRegistry()
br := broker.NewMemoryBroker()
if err := br.Connect(); err != nil {
fmt.Println("broker connect:", err)
return 2
}
cl := harnessutil.Client(provider, reg)
st := store.NewMemoryStore()
liveAgentOpts := harnessutil.AgentOptions(provider)
// Services.
inv, pay, ord, ntf := new(Inventory), new(Payment), new(Orders), new(Notify)
for name, h := range map[string]any{"inventory": inv, "payment": pay, "orders": ord, "notify": ntf} {
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Broker(br), service.Client(cl))
svc.Handle(h)
go svc.Run()
}
// The concierge agent: guardrails on, plus a tool-execution wrapper
// that counts calls — to prove the wrapper seam runs end-to-end.
var wrapped int64
conciergeOpts := []agent.Option{
agent.Name("concierge"),
agent.Services("notify"),
agent.Prompt("You notify buyers when their order is confirmed."),
agent.Address("127.0.0.1:0"),
agent.Provider(provider), agent.APIKey(apiKey),
agent.MaxSteps(5),
agent.WithBroker(br),
agent.WrapTool(func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
atomic.AddInt64(&wrapped, 1)
return next(ctx, call)
}
}),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(st),
}
conciergeOpts = append(conciergeOpts, liveAgentOpts...)
concierge := agent.New(conciergeOpts...)
go concierge.Run()
defer concierge.Stop()
waitFor(reg, "inventory", "payment", "orders", "notify", "concierge")
// The durable checkout flow: ordered, checkpointed steps. The last step
// hands off to the concierge agent.
checkout := flow.New("checkout",
flow.Trigger("events.order.placed"),
flow.WithCheckpoint(flow.StoreCheckpoint(st, "checkout")),
flow.Timeout(harnessutil.LiveTimeout(provider)),
flow.Steps(
flow.Step{Name: "reserve", Run: flow.Call("inventory", "Inventory.Reserve")},
flow.Step{Name: "charge", Run: flow.Call("payment", "Payment.Charge")},
flow.Step{Name: "confirm", Run: flow.Call("orders", "Orders.Confirm")},
flow.Step{Name: "notify", Run: dispatchNotifyStep("concierge", cl, ntf)},
),
)
if err := checkout.Register(reg, br, cl); err != nil {
fmt.Println("flow register:", err)
return 2
}
defer checkout.Stop()
ctx := context.Background()
// 1) An order event triggers the flow — which crashes at "charge".
fmt.Println("\033[1m> event:\033[0m events.order.placed {\"order\":\"order-1\"}")
if err := br.Publish("events.order.placed", &broker.Message{Body: []byte(`{"order":"order-1"}`)}); err != nil {
fmt.Println("publish:", err)
return 2
}
// Wait for the run to be checkpointed as failed at "charge".
var runID string
deadline := time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if pend, _ := checkout.Pending(ctx); len(pend) == 1 && pend[0].State.Stage == "charge" {
runID = pend[0].ID
break
}
time.Sleep(50 * time.Millisecond)
}
fmt.Println("\n\033[1mafter crash:\033[0m")
check(runID != "", "flow checkpointed a pending run at the failing step")
check(atomic.LoadInt64(&inv.reserves) == 1, "inventory reserved once before the crash")
check(atomic.LoadInt64(&ord.confirms) == 0, "order not confirmed yet (run stopped at charge)")
// 2) Resume — the dependency has recovered; continue from "charge".
fmt.Println("\n\033[1m> resume:\033[0m", runID)
if runID != "" {
if err := checkout.Resume(ctx, runID); err != nil {
fmt.Println("resume:", err)
}
}
// Wait for the agent (last step) to have notified.
deadline = time.Now().Add(10 * time.Second)
for time.Now().Before(deadline) {
if atomic.LoadInt64(&ntf.sent) >= 1 {
break
}
time.Sleep(50 * time.Millisecond)
}
// 3) Assert the end state of the universe.
fmt.Println("\n\033[1mafter resume:\033[0m")
check(atomic.LoadInt64(&inv.reserves) == 1, "inventory still reserved exactly once (completed step not replayed)")
check(atomic.LoadInt64(&pay.attempts) == 2, "payment attempted twice (failed once, then charged)")
check(atomic.LoadInt64(&ord.confirms) == 1, "order confirmed after resume")
check(atomic.LoadInt64(&ntf.sent) == 1, "buyer notified exactly once by the concierge agent")
check(atomic.LoadInt64(&wrapped) >= 1, "agent tool-execution wrapper observed the call")
if pend, _ := checkout.Pending(ctx); true {
check(len(pend) == 0, "no pending runs — the workflow completed durably")
}
// Scoped state landed in its own tables.
runs, _ := flow.StoreCheckpoint(st, "checkout").List(ctx)
check(len(runs) == 1 && runs[0].Status == "done", "flow run persisted in flow/checkout and marked done")
hist := agent.NewMemory(store.Scope(st, "agent", "concierge"), "history", 100).Messages()
check(len(hist) > 0, "agent history persisted in agent/concierge")
// Flows are discoverable while live.
flows, _ := reg.GetService("checkout")
check(len(flows) == 1 && flows[0].Metadata["type"] == "flow", "flow registered in the registry as type=flow")
// 4) The concierge agent is also reachable from outside, over the A2A
// protocol — its card is generated from the registry, and a task is
// translated to its Agent.Chat RPC.
gw := httptest.NewServer(a2a.New(a2a.Options{Registry: reg, Client: cl, BaseURL: "http://gw"}).Handler())
defer gw.Close()
beforeA2A := atomic.LoadInt64(&ntf.sent)
reachCtx, cancelReach := context.WithTimeout(ctx, 10*time.Second)
reachErr := a2aReachable(reachCtx, gw.URL, "concierge")
cancelReach()
check(reachErr == nil, "concierge agent reachable over the A2A gateway: %v", reachErr)
check(atomic.LoadInt64(&ntf.sent) == beforeA2A, "A2A reachability probe did not send extra buyer notifications")
fmt.Println("\n\033[1m> shutting down the universe\033[0m")
// defers stop the agent and flow (deregistering them).
if failures > 0 {
fmt.Printf("\n\033[31m✗ universe failed: %d assertion(s)\033[0m\n", failures)
return 1
}
fmt.Println("\n\033[32m✓ universe: booted, survived a crash, resumed, and shut down cleanly\033[0m")
return 0
}
+300
View File
@@ -0,0 +1,300 @@
package main
import (
"context"
"errors"
"fmt"
"net/http"
"net/http/httptest"
"sync/atomic"
"testing"
"time"
"go-micro.dev/v6/flow"
)
// TestUniverseHarnessContract makes the 0→hero harness part of the ordinary
// Go test contract. The harness boots real services, a durable workflow, an
// agent, scoped state, and the A2A gateway with only the LLM mocked; running it
// here prevents the full services → agents → workflows lifecycle from silently
// drifting while developers rely on `go test ./...`.
func TestUniverseHarnessContract(t *testing.T) {
if testing.Short() {
t.Skip("universe harness boots an end-to-end system; skipped with -short")
}
if code := runUniverse("mock"); code != 0 {
t.Fatalf("universe harness exited with code %d", code)
}
}
func TestA2AReachableRetriesTransientTimeout(t *testing.T) {
var calls int64
srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got, want := r.URL.Path, "/agents/concierge"; got != want {
t.Fatalf("path = %q, want %q", got, want)
}
if atomic.AddInt64(&calls, 1) == 1 {
time.Sleep(5 * time.Second)
return
}
w.Header().Set("Content-Type", "application/json")
fmt.Fprint(w, `{"jsonrpc":"2.0","id":1,"result":{"kind":"task","id":"task-1","contextId":"ctx-1","status":{"state":"completed"},"artifacts":[{"artifactId":"artifact-1","parts":[{"kind":"text","text":"concierge reachable"}]}]}}`)
}))
defer srv.Close()
ctx, cancel := context.WithTimeout(context.Background(), 6*time.Second)
defer cancel()
if err := a2aReachable(ctx, srv.URL, "concierge"); err != nil {
t.Fatalf("a2aReachable returned error: %v", err)
}
if got := atomic.LoadInt64(&calls); got < 2 {
t.Fatalf("A2A calls = %d, want retry after transient timeout", got)
}
}
func TestNotifyStepCompletesAfterObservedSideEffectTimeout(t *testing.T) {
ntf := new(Notify)
before := atomic.LoadInt64(&ntf.sent)
go func() {
time.Sleep(30 * time.Millisecond)
var rsp SendResponse
if err := ntf.Send(context.Background(), &SendRequest{
To: "buyer@acme.com",
Message: "Your order is confirmed.",
}, &rsp); err != nil {
t.Errorf("send notification: %v", err)
}
}()
out, err := completeNotifyOnObservedSideEffect(
context.Background(),
flow.State{Data: []byte(`{"order":"order-1"}`)},
ntf,
before,
time.Second,
errors.New("client observed timeout"),
)
if err != nil {
t.Fatalf("notify completion returned error: %v", err)
}
if got := out.String(); got != "Buyer notified." {
t.Fatalf("result = %q, want Buyer notified.", got)
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("notifications sent = %d, want 1", got)
}
var rsp SendResponse
if err := ntf.Send(context.Background(), &SendRequest{
To: "buyer@acme.com",
Message: "Your order is confirmed.",
}, &rsp); err != nil {
t.Fatalf("duplicate send: %v", err)
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("notifications sent after duplicate = %d, want 1", got)
}
}
func TestNotifyStepWaitsForObservedSideEffectAfterCanceledDispatchContext(t *testing.T) {
ntf := new(Notify)
before := atomic.LoadInt64(&ntf.sent)
ctx, cancel := context.WithCancel(context.Background())
cancel()
go func() {
time.Sleep(30 * time.Millisecond)
var rsp SendResponse
if err := ntf.Send(context.Background(), &SendRequest{
To: "buyer@acme.com",
Message: "Your order is confirmed.",
}, &rsp); err != nil {
t.Errorf("send notification: %v", err)
}
}()
out, err := completeNotifyOnObservedSideEffect(
ctx,
flow.State{Data: []byte(`{"order":"order-1"}`)},
ntf,
before,
time.Second,
errors.New("client observed timeout"),
)
if err != nil {
t.Fatalf("notify completion returned error: %v", err)
}
if got := out.String(); got != "Buyer notified." {
t.Fatalf("result = %q, want Buyer notified.", got)
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("notifications sent = %d, want 1", got)
}
}
func TestNotifyStepRejectsClaimedCompletionWithoutSideEffect(t *testing.T) {
ntf := new(Notify)
before := atomic.LoadInt64(&ntf.sent)
_, err := completeNotifyOnObservedSideEffect(
context.Background(),
flow.State{Data: []byte(`claimed success`)},
ntf,
before,
25*time.Millisecond,
nil,
)
if err == nil {
t.Fatal("notify completion returned nil, want missing buyer notification error")
}
want := `concierge completed without notifying buyer: notify count stayed at 0; expected recipient buyer@acme.com, buyer, or buyer-of-order-<id>; no rejected notify call observed`
if got := err.Error(); got != want {
t.Fatalf("error = %q, want %q", got, want)
}
}
func TestNotifySuppressesEquivalentConfirmationMessages(t *testing.T) {
ntf := new(Notify)
ctx := context.Background()
for _, req := range []*SendRequest{
{To: "buyer@acme.com", Message: "Your order order-1 has been confirmed."},
{To: "buyer@acme.com", Message: "order-1 confirmed"},
} {
var rsp SendResponse
if err := ntf.Send(ctx, req, &rsp); err != nil {
t.Fatalf("send notification %q: %v", req.Message, err)
}
if !rsp.Sent {
t.Fatalf("send notification %q did not report sent", req.Message)
}
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("equivalent confirmation notifications sent = %d, want 1", got)
}
}
func TestNotifyAcceptsBuyerAlias(t *testing.T) {
ntf := new(Notify)
ctx := context.Background()
var rsp SendResponse
if err := ntf.Send(ctx, &SendRequest{
To: "buyer",
Message: "Your order order-1 has been confirmed.",
}, &rsp); err != nil {
t.Fatalf("send buyer alias notification: %v", err)
}
if !rsp.Sent {
t.Fatal("buyer alias notification did not report sent")
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("buyer alias notifications sent = %d, want 1", got)
}
if err := ntf.Send(ctx, &SendRequest{
To: "buyer@acme.com",
Message: "order-1 confirmed",
}, &rsp); err != nil {
t.Fatalf("send canonical buyer notification: %v", err)
}
if !rsp.Sent {
t.Fatal("canonical buyer notification did not report sent")
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("alias/canonical confirmation notifications sent = %d, want 1", got)
}
}
func TestNotifyIgnoresNonBuyerRecipients(t *testing.T) {
ntf := new(Notify)
ctx := context.Background()
var rsp SendResponse
if err := ntf.Send(ctx, &SendRequest{
To: "order-1",
Message: "order-1 confirmed",
}, &rsp); err != nil {
t.Fatalf("send non-buyer notification: %v", err)
}
if rsp.Sent {
t.Fatal("non-buyer notification reported sent")
}
if got := atomic.LoadInt64(&ntf.sent); got != 0 {
t.Fatalf("non-buyer notifications sent = %d, want 0", got)
}
if err := ntf.Send(ctx, &SendRequest{
To: "buyer@acme.com",
Message: "Your order order-1 has been confirmed.",
}, &rsp); err != nil {
t.Fatalf("send buyer notification: %v", err)
}
if !rsp.Sent {
t.Fatal("buyer notification did not report sent")
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("buyer notifications sent = %d, want 1", got)
}
}
func TestNotifyAcceptsOrderScopedBuyerRecipient(t *testing.T) {
ntf := new(Notify)
ctx := context.Background()
var rsp SendResponse
if err := ntf.Send(ctx, &SendRequest{
To: "buyer-of-order-1",
Message: "order-1 confirmed",
}, &rsp); err != nil {
t.Fatalf("send order-scoped buyer notification: %v", err)
}
if !rsp.Sent {
t.Fatal("order-scoped buyer notification did not report sent")
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("order-scoped buyer notifications sent = %d, want 1", got)
}
if err := ntf.Send(ctx, &SendRequest{
To: "non-buyer",
Message: "order-1 confirmed",
}, &rsp); err != nil {
t.Fatalf("send hyphenated non-buyer notification: %v", err)
}
if rsp.Sent {
t.Fatal("hyphenated non-buyer notification reported sent")
}
if got := atomic.LoadInt64(&ntf.sent); got != 1 {
t.Fatalf("notifications sent after hyphenated non-buyer = %d, want 1", got)
}
}
func TestNotifyStepReportsRejectedRecipientDiagnostics(t *testing.T) {
ntf := new(Notify)
var rsp SendResponse
if err := ntf.Send(context.Background(), &SendRequest{
To: "order-1",
Message: "order-1 confirmed",
}, &rsp); err != nil {
t.Fatalf("send rejected notification: %v", err)
}
_, err := completeNotifyOnObservedSideEffect(
context.Background(),
flow.State{Data: []byte(`claimed success`)},
ntf,
0,
25*time.Millisecond,
nil,
)
if err == nil {
t.Fatal("notify completion returned nil, want diagnostics")
}
want := `concierge completed without notifying buyer: notify count stayed at 0; expected recipient buyer@acme.com, buyer, or buyer-of-order-<id>; last notify args to="order-1" message="order-1 confirmed"`
if got := err.Error(); got != want {
t.Fatalf("error = %q, want %q", got, want)
}
}
@@ -0,0 +1,54 @@
# 0→hero CI harness
This directory owns the no-secret reference scenario for the Go Micro
services → agents → workflows lifecycle. It is intentionally small and
scripted so CI can run it on every push without external services or model keys.
`run.sh` verifies the complete first-agent 0→hero contract together:
1. **Scaffold** — the maintained `micro new` 0→1 contract still creates
runnable services from a clean workspace.
2. **First agent**`micro agent demo`, `micro examples`, `micro agent preflight`,
`micro run`, `micro chat`, and `micro inspect agent <name>` remain available
as the documented first-agent walkthrough path.
3. **Run**`micro run` remains available as the local development entry point.
4. **Chat**`micro chat` remains available as the interactive agent entry point.
5. **Inspect/debugging**`micro inspect agent <name>`, `micro agent history <name>`,
and `micro inspect flow <name>` remain available as the local run-history
inspection step. The no-secret debugging smoke seeds durable agent run history
and memory, then runs the documented inspect/history commands without provider
credentials; `micro flow runs` preserves durable workflow history inspection.
6. **Deploy**`micro deploy --dry-run <target>` remains available as the
deployment-boundary checkpoint. The dry run resolves configured deploy targets
and services and prints the remote build/copy/systemd/health plan without
building binaries, opening SSH connections, running `rsync`, or touching
remote infrastructure.
After the CLI boundary smoke checks, the script runs the deterministic harnesses
that boot real services, agents, workflows, store-backed run history, plan/delegate,
and A2A with only the LLM mocked.
## Local and CI entry points
The default GitHub harness workflow runs this script on every push and pull
request after the install smoke check and 0→1 scaffold contract. Developers can
verify the first-agent on-ramp links and CLI command-output parity with
`make docs-wayfinding` whenever README or website first-agent breadcrumbs,
`micro agent demo`, `micro examples`, or `micro zero-to-hero` change. That
check is provider-free and fails if documented command names, guide links, or
maintained no-secret example paths drift from the CLI outputs. To verify the
installed first-run CLI seam alone, use `make install-smoke`; to run just the documented
agent debugging quickcheck with
`go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1`,
or run the same no-secret contract locally with:
```sh
make harness
```
That target intentionally exercises the first-agent docs wayfinding guard, the
install script smoke path, both 0→1 scaffold variants, the 0→hero scenario, the
event-driven agent-flow harness, and mock provider conformance, so
the public scaffold → run/chat → inspect → deploy lifecycle stays executable
outside CI as well. Live provider checks remain separate and gated by configured
API keys (`make provider-conformance` or the scheduled/manual CI job).
File diff suppressed because it is too large Load Diff
+39
View File
@@ -0,0 +1,39 @@
#!/usr/bin/env bash
set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT"
run_step() {
local name=$1
shift
printf '\n==> %s\n' "$name"
printf '+ %q' "$@"
printf '\n'
"$@"
}
# Keep the developer inner-loop boundaries executable and discoverable in CI
# without secrets or long-running daemons. Step names mirror the documented
# install → scaffold → run/chat → inspect → deploy-dry-run seams so failures
# identify the broken part of the getting-started contract.
run_step "scaffold: 0→1 service contract" \
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
run_step "run/chat/inspect: first-agent CLI boundaries" \
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestExamplesWayfindingIndexStaysLinked|TestExamplesCommandPointsAtWayfindingIndex|TestZeroToHeroCLIBoundaries|TestZeroToHeroCommandPrintsMaintainedNoSecretPath' -count=1
run_step "chat/inspect: no-secret first-agent transcript and docs" \
go test ./internal/harness/zero-to-hero-ci -run 'TestNoSecretFirstAgentTranscript|TestFirstAgentCLIChatInspectFixture|TestNoSecretFirstAgentDebuggingSmoke|TestZeroToHeroReferenceDocs|TestZeroToHeroDeployDryRunCommandSmoke|TestYourFirstAgentTutorialSmoke' -count=1
# Deterministic no-secret reference scenarios. These use the real Go Micro
# runtime and mock only the LLM provider. The support example is the maintained
# runnable 0→hero app; keep it in this CI path so its documented run/chat/inspect
# journey cannot drift from the framework.
run_step "first-agent app: runnable provider-free example" \
go test ./examples/first-agent -run TestRunFirstAgent -count=1
run_step "0→hero app: support lifecycle smoke" \
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle|TestZeroToHeroInspectTranscript' -count=1
run_step "flow history: deterministic services → agents → workflows harnesses" \
go test ./internal/harness/universe ./internal/harness/plan-delegate -run 'Test.*Harness|TestPlanDelegateEndToEnd|TestPlanDelegateFlowHandoff' -count=1
run_step "deploy dry-run: configured target plan" \
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
+109
View File
@@ -0,0 +1,109 @@
#!/bin/bash
# Install script for micro CLI
# Usage: curl -fsSL https://go-micro.dev/install.sh | sh
set -e
VERSION="${MICRO_VERSION:-latest}"
OS=$(uname -s | tr '[:upper:]' '[:lower:]')
ARCH=$(uname -m)
# Normalize architecture
case $ARCH in
x86_64|amd64) ARCH="amd64" ;;
aarch64|arm64) ARCH="arm64" ;;
armv7l) ARCH="armv7" ;;
*) echo "Unsupported architecture: $ARCH"; exit 1 ;;
esac
# Normalize OS
case $OS in
darwin) OS="darwin" ;;
linux) OS="linux" ;;
*) echo "Unsupported OS: $OS"; exit 1 ;;
esac
# Determine install directory. MICRO_INSTALL_DIR is intended for CI/local smoke
# tests that verify the installer without writing to a real system directory.
if [ -n "${MICRO_INSTALL_DIR:-}" ]; then
INSTALL_DIR="$MICRO_INSTALL_DIR"
mkdir -p "$INSTALL_DIR"
elif [ "$EUID" -eq 0 ] || [ "$(id -u)" -eq 0 ]; then
INSTALL_DIR="/usr/local/bin"
else
INSTALL_DIR="$HOME/.local/bin"
mkdir -p "$INSTALL_DIR"
fi
echo "Installing micro ${VERSION} for ${OS}/${ARCH}..."
# Download URL
if [ "$VERSION" = "latest" ]; then
URL="https://github.com/micro/go-micro/releases/latest/download/micro_${OS}_${ARCH}.tar.gz"
else
URL="https://github.com/micro/go-micro/releases/download/${VERSION}/micro_${OS}_${ARCH}.tar.gz"
fi
# Create temp directory for extraction
TMP_DIR=$(mktemp -d)
TMP_FILE="${TMP_DIR}/micro.tar.gz"
# Download, or use a local release archive supplied by the deterministic smoke
# harness. The default path still fetches the documented GitHub release artifact.
if [ -n "${MICRO_INSTALL_ARCHIVE:-}" ]; then
cp "$MICRO_INSTALL_ARCHIVE" "$TMP_FILE"
elif command -v curl &> /dev/null; then
curl -fsSL "$URL" -o "$TMP_FILE"
elif command -v wget &> /dev/null; then
wget -q "$URL" -O "$TMP_FILE"
else
echo "Error: curl or wget required"
rm -rf "$TMP_DIR"
exit 1
fi
# Extract and install
tar -xzf "$TMP_FILE" -C "$TMP_DIR"
# Handle different archive structures (binary at root or in subdirectory)
if [ -f "${TMP_DIR}/micro" ]; then
BINARY_PATH="${TMP_DIR}/micro"
elif [ -f "${TMP_DIR}/micro_${OS}_${ARCH}/micro" ]; then
BINARY_PATH="${TMP_DIR}/micro_${OS}_${ARCH}/micro"
else
# Try to find any executable named 'micro' in the extracted content
BINARY_PATH=$(find "$TMP_DIR" -name "micro" -type f -executable | head -n1)
fi
if [ -z "$BINARY_PATH" ] || [ ! -f "$BINARY_PATH" ]; then
echo "Error: Could not find micro binary in archive"
echo "Archive contents:"
tar -tzf "$TMP_FILE"
rm -rf "$TMP_DIR"
exit 1
fi
chmod +x "$BINARY_PATH"
mv "$BINARY_PATH" "$INSTALL_DIR/micro"
# Cleanup
rm -rf "$TMP_DIR"
echo ""
echo "✓ Installed micro to $INSTALL_DIR/micro"
echo ""
# Verify
if command -v micro &> /dev/null; then
micro --version
else
echo "Note: Add $INSTALL_DIR to your PATH:"
echo " export PATH=\"\$PATH:$INSTALL_DIR\""
fi
echo ""
echo "Get started:"
echo " micro new myservice # Create a new service"
echo " micro run # Run locally"
echo " micro deploy # Deploy to server"
echo ""
+396
View File
@@ -0,0 +1,396 @@
// Package test implements a testing framwork, and provides default tests.
//
// Deprecated: This package is deprecated in favor of go-micro.dev/v6/testing.
// Use the testing.Harness for a cleaner, more maintainable approach.
// See test/DEPRECATED.md for migration guide.
package test
import (
"context"
"fmt"
"sync"
"testing"
"time"
"github.com/pkg/errors"
"go-micro.dev/v6"
"go-micro.dev/v6/client"
"go-micro.dev/v6/debug/handler"
pb "go-micro.dev/v6/debug/proto"
)
var (
// ErrNoTests returns no test params are set.
ErrNoTests = errors.New("No tests to run, all values set to 0")
testTopic = "Test-Topic"
errorTopic = "Error-Topic"
)
type parTest func(name string, c client.Client, p, s int, errChan chan error)
type testFunc func(name string, c client.Client, errChan chan error)
// ServiceTestConfig allows you to easily test a service configuration by
// running predefined tests against your custom service. You only need to
// provide a function to create the service, and how many of which test you
// want to run.
//
// The default tests provided, all running with separate parallel routines are:
// - Sequential Call requests
// - Bi-directional streaming
// - Pub/Sub events brokering
//
// You can provide an array of parallel routines to run for the request and
// stream tests. They will be run as matrix tests, so with each possible combination.
// Thus, in total (p * seq) + (p * streams) tests will be run.
type ServiceTestConfig struct {
// Service name to use for the tests
Name string
// NewService function will be called to setup the new service.
// It takes in a list of options, which by default will Context and an
// AfterStart with channel to signal when the service has been started.
NewService func(name string, opts ...micro.Option) (micro.Service, error)
// Parallel is the number of prallell routines to use for the tests.
Parallel []int
// Sequential is the number of sequential requests to send per parallel process.
Sequential []int
// Streams is the nummber of streaming messages to send over the stream per routine.
Streams []int
// PubSub is the number of times to publish messages to the broker per routine.
PubSub []int
mu sync.Mutex
msgCount int
}
// Run will start the benchmark tests.
func (stc *ServiceTestConfig) Run(b *testing.B) {
if err := stc.validate(); err != nil {
b.Fatal("Failed to validate config", err)
}
// Run routines with sequential requests
stc.prepBench(b, "req", stc.runParSeqTest, stc.Sequential)
// Run routines with streams
stc.prepBench(b, "streams", stc.runParStreamTest, stc.Streams)
// Run routines with pub/sub
stc.prepBench(b, "pubsub", stc.runBrokerTest, stc.PubSub)
}
// prepBench will prepare the benmark by setting the right parameters,
// and invoking the test.
func (stc *ServiceTestConfig) prepBench(b *testing.B, tName string, test parTest, seq []int) {
par := stc.Parallel
// No requests needed
if len(seq) == 0 || seq[0] == 0 {
return
}
for _, parallel := range par {
for _, sequential := range seq {
// Create the service name for the test
name := fmt.Sprintf("%s.%dp-%d%s", stc.Name, parallel, sequential, tName)
// Run test with parallel routines making each sequential requests
test := func(name string, c client.Client, errChan chan error) {
test(name, c, parallel, sequential, errChan)
}
benchmark := func(b *testing.B) {
b.ReportAllocs()
stc.runBench(b, name, test)
}
b.Logf("----------- STARTING TEST %s -----------", name)
// Run test, return if it fails
if !b.Run(name, benchmark) {
return
}
}
}
}
// runParSeqTest will make s sequential requests in p parallel routines.
func (stc *ServiceTestConfig) runParSeqTest(name string, c client.Client, p, s int, errChan chan error) {
testParallel(p, func() {
// Make serial requests
for z := 0; z < s; z++ {
if err := testRequest(context.Background(), c, name); err != nil {
errChan <- errors.Wrapf(err, "[%s] Request failed during testRequest", name)
return
}
}
})
}
// Handle is used as a test handler.
func (stc *ServiceTestConfig) Handle(ctx context.Context, msg *pb.HealthRequest) error {
stc.mu.Lock()
stc.msgCount++
stc.mu.Unlock()
return nil
}
// HandleError is used as a test handler.
func (stc *ServiceTestConfig) HandleError(ctx context.Context, msg *pb.HealthRequest) error {
return errors.New("dummy error")
}
// runBrokerTest will publish messages to the broker to test pub/sub.
func (stc *ServiceTestConfig) runBrokerTest(name string, c client.Client, p, s int, errChan chan error) {
stc.msgCount = 0
testParallel(p, func() {
for z := 0; z < s; z++ {
msg := pb.BusMsg{Msg: "Hello from broker!"}
if err := c.Publish(context.Background(), c.NewMessage(testTopic, &msg)); err != nil {
errChan <- errors.Wrap(err, "failed to publish message to broker")
return
}
msg = pb.BusMsg{Msg: "Some message that will error"}
if err := c.Publish(context.Background(), c.NewMessage(errorTopic, &msg)); err == nil {
errChan <- errors.New("Publish is supposed to return an error, but got no error")
return
}
}
})
if stc.msgCount != s*p {
errChan <- fmt.Errorf("pub/sub does not work properly, invalid message count. Expected %d messaged, but received %d", s*p, stc.msgCount)
return
}
}
// runParStreamTest will start streaming, and send s messages parallel in p routines.
func (stc *ServiceTestConfig) runParStreamTest(name string, c client.Client, p, s int, errChan chan error) {
testParallel(p, func() {
// Create a client service
srv := pb.NewDebugService(name, c)
// Establish a connection to server over which we start streaming
bus, err := srv.MessageBus(context.Background())
if err != nil {
errChan <- errors.Wrap(err, "failed to connect to message bus")
return
}
// Start streaming requests
for z := 0; z < s; z++ {
if err := bus.Send(&pb.BusMsg{Msg: "Hack the world!"}); err != nil {
errChan <- errors.Wrap(err, "failed to send to stream")
return
}
msg, err := bus.Recv()
if err != nil {
errChan <- errors.Wrap(err, "failed to receive message from stream")
return
}
expected := "Request received!"
if msg.Msg != expected {
errChan <- fmt.Errorf("stream returned unexpected mesage. Expected '%s', but got '%s'", expected, msg.Msg)
return
}
}
})
}
// validate will make sure the provided test parameters are a legal combination.
func (stc *ServiceTestConfig) validate() error {
lp, lseq, lstr := len(stc.Parallel), len(stc.Sequential), len(stc.Streams)
if lp == 0 || (lseq == 0 && lstr == 0) {
return ErrNoTests
}
return nil
}
// runBench will create a service with the provided stc.NewService function,
// and run a benchmark on the test function.
func (stc *ServiceTestConfig) runBench(b *testing.B, name string, test testFunc) {
b.StopTimer()
// Channel to signal service has started
started := make(chan struct{})
// Context with cancel to stop the service
ctx, cancel := context.WithCancel(context.Background())
opts := []micro.Option{
micro.Context(ctx),
micro.AfterStart(func() error {
started <- struct{}{}
return nil
}),
}
// Create a new service per test
service, err := stc.NewService(name, opts...)
if err != nil {
b.Fatalf("failed to create service: %v", err)
}
// Register handler
if err := pb.RegisterDebugHandler(service.Server(), handler.NewHandler(service.Client())); err != nil {
b.Fatalf("failed to register handler during initial service setup: %v", err)
}
o := service.Options()
if err := o.Broker.Connect(); err != nil {
b.Fatal(err)
}
// a := new(testService)
if err := o.Server.Subscribe(o.Server.NewSubscriber(testTopic, stc.Handle)); err != nil {
b.Fatalf("[%s] Failed to register subscriber: %v", name, err)
}
if err := o.Server.Subscribe(o.Server.NewSubscriber(errorTopic, stc.HandleError)); err != nil {
b.Fatalf("[%s] Failed to register subscriber: %v", name, err)
}
b.Logf("# == [ Service ] ==================")
b.Logf("# * Server: %s", o.Server.String())
b.Logf("# * Client: %s", o.Client.String())
b.Logf("# * Transport: %s", o.Transport.String())
b.Logf("# * Broker: %s", o.Broker.String())
b.Logf("# * Registry: %s", o.Registry.String())
b.Logf("# * Auth: %s", o.Auth.String())
b.Logf("# * Cache: %s", o.Cache.String())
b.Logf("# ================================")
RunBenchmark(b, name, service, test, cancel, started)
}
// RunBenchmark will run benchmarks on a provided service.
//
// A test function can be provided that will be fun b.N times.
func RunBenchmark(b *testing.B, name string, service micro.Service, test testFunc,
cancel context.CancelFunc, started chan struct{}) {
b.StopTimer()
// Receive errors from routines on this channel
errChan := make(chan error, 1)
// Receive singal after service has shutdown
done := make(chan struct{})
// Start the server
go func() {
b.Logf("[%s] Starting server for benchmark", name)
if err := service.Run(); err != nil {
errChan <- errors.Wrapf(err, "[%s] Error occurred during service.Run", name)
}
done <- struct{}{}
}()
sigTerm := make(chan struct{})
// Benchmark routine
go func() {
defer func() {
b.StopTimer()
// Shutdown service
b.Logf("[%s] Shutting down", name)
cancel()
// Wait for service to be fully stopped
<-done
sigTerm <- struct{}{}
}()
// Wait for service to start
<-started
// Give the registry more time to setup
time.Sleep(time.Second)
b.Logf("[%s] Server started", name)
// Make a test call to warm the cache
for i := 0; i < 10; i++ {
if err := testRequest(context.Background(), service.Client(), name); err != nil {
errChan <- errors.Wrapf(err, "[%s] Failure during cache warmup testRequest", name)
}
}
// Check registration
services, err := service.Options().Registry.GetService(name)
if err != nil || len(services) == 0 {
errChan <- fmt.Errorf("service registration must have failed (%d services found), unable to get service: %w", len(services), err)
return
}
// Start benchmark
b.Logf("[%s] Starting benchtest", name)
b.ResetTimer()
b.StartTimer()
// Number of iterations
for i := 0; i < b.N; i++ {
test(name, service.Client(), errChan)
}
}()
// Wait for completion or catch any errors
select {
case err := <-errChan:
b.Fatal(err)
case <-sigTerm:
b.Logf("[%s] Completed benchmark", name)
}
}
// testParallel will run the test function in p parallel routines.
func testParallel(p int, test func()) {
// Waitgroup to wait for requests to finish
wg := sync.WaitGroup{}
// For concurrency
for j := 0; j < p; j++ {
wg.Add(1)
go func() {
defer wg.Done()
test()
}()
}
// Wait for test completion
wg.Wait()
}
// testRequest sends one test request.
// It calls the Debug.Health endpoint, and validates if the response returned
// contains the expected message.
func testRequest(ctx context.Context, c client.Client, name string) error {
req := c.NewRequest(
name,
"Debug.Health",
new(pb.HealthRequest),
)
rsp := new(pb.HealthResponse)
if err := c.Call(ctx, req, rsp); err != nil {
return err
}
if rsp.Status != "ok" {
return errors.New("service response: " + rsp.Status)
}
return nil
}
+231
View File
@@ -0,0 +1,231 @@
// Package test provides utilities for testing micro services.
//
// Due to go-micro's global defaults, running multiple services in one process
// requires careful isolation. This package provides helpers for the common case
// of testing a single service.
//
// Basic usage:
//
// func TestUserService(t *testing.T) {
// h := test.NewHarness(t)
// defer h.Stop()
//
// // Register your service handler
// h.Register(new(UsersHandler))
//
// // Start the harness
// h.Start()
//
// // Call the service
// var rsp UserResponse
// err := h.Call("Users.Create", &CreateRequest{Name: "Alice"}, &rsp)
// if err != nil {
// t.Fatal(err)
// }
// }
package test
import (
"context"
"fmt"
"sync"
"testing"
"time"
"go-micro.dev/v6/broker"
"go-micro.dev/v6/client"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/server"
"go-micro.dev/v6/transport"
)
// Harness provides an in-process test environment for a micro service
type Harness struct {
t *testing.T
name string
handler interface{}
registry registry.Registry
transport transport.Transport
broker broker.Broker
server server.Server
client client.Client
started bool
mu sync.Mutex
}
// NewHarness creates a new test harness
func NewHarness(t *testing.T) *Harness {
// Create isolated instances for testing
reg := registry.NewMemoryRegistry()
tr := transport.NewHTTPTransport()
br := broker.NewMemoryBroker()
return &Harness{
t: t,
name: "test",
registry: reg,
transport: tr,
broker: br,
}
}
// Name sets the service name (default: "test")
func (h *Harness) Name(name string) *Harness {
h.name = name
return h
}
// Register sets the handler for the service
func (h *Harness) Register(handler interface{}) *Harness {
h.mu.Lock()
defer h.mu.Unlock()
if h.started {
h.t.Fatal("cannot register handler after Start()")
}
h.handler = handler
return h
}
// Start starts the service
func (h *Harness) Start() {
h.mu.Lock()
defer h.mu.Unlock()
if h.started {
return
}
if h.handler == nil {
h.t.Fatal("no handler registered, call Register() first")
}
// Connect broker
if err := h.broker.Connect(); err != nil {
h.t.Fatalf("failed to connect broker: %v", err)
}
// Create server with isolated transport
h.server = server.NewServer(
server.Name(h.name),
server.Registry(h.registry),
server.Transport(h.transport),
server.Broker(h.broker),
server.Address("127.0.0.1:0"),
)
// Register handler
if err := h.server.Handle(h.server.NewHandler(h.handler)); err != nil {
h.t.Fatalf("failed to register handler: %v", err)
}
// Start server
if err := h.server.Start(); err != nil {
h.t.Fatalf("failed to start server: %v", err)
}
// Create client with same registry/transport
h.client = client.NewClient(
client.Registry(h.registry),
client.Transport(h.transport),
client.Broker(h.broker),
client.RequestTimeout(5*time.Second),
)
// Wait for registration
h.waitForService()
h.started = true
}
func (h *Harness) waitForService() {
deadline := time.Now().Add(5 * time.Second)
for time.Now().Before(deadline) {
services, err := h.registry.GetService(h.name)
if err == nil && len(services) > 0 && len(services[0].Nodes) > 0 {
return
}
time.Sleep(10 * time.Millisecond)
}
h.t.Fatalf("service %s did not register in time", h.name)
}
// Stop stops the service
func (h *Harness) Stop() {
h.mu.Lock()
defer h.mu.Unlock()
if h.server != nil {
_ = h.server.Stop()
}
if h.broker != nil {
_ = h.broker.Disconnect()
}
h.started = false
}
// Call invokes a service method
func (h *Harness) Call(endpoint string, req, rsp interface{}) error {
return h.CallContext(context.Background(), endpoint, req, rsp)
}
// CallContext invokes a service method with context
func (h *Harness) CallContext(ctx context.Context, endpoint string, req, rsp interface{}) error {
if !h.started {
return fmt.Errorf("harness not started, call Start() first")
}
request := h.client.NewRequest(h.name, endpoint, req)
return h.client.Call(ctx, request, rsp)
}
// Client returns the test client for advanced usage
func (h *Harness) Client() client.Client {
return h.client
}
// Server returns the test server for advanced usage
func (h *Harness) Server() server.Server {
return h.server
}
// Registry returns the test registry for advanced usage
func (h *Harness) Registry() registry.Registry {
return h.registry
}
// --- Assertions ---
// AssertServiceRunning checks that the service is registered
func (h *Harness) AssertServiceRunning() {
h.t.Helper()
services, err := h.registry.GetService(h.name)
if err != nil {
h.t.Errorf("service %s not found: %v", h.name, err)
return
}
if len(services) == 0 || len(services[0].Nodes) == 0 {
h.t.Errorf("service %s has no running instances", h.name)
}
}
// AssertCallSucceeds checks that a call succeeds
func (h *Harness) AssertCallSucceeds(endpoint string, req, rsp interface{}) {
h.t.Helper()
if err := h.Call(endpoint, req, rsp); err != nil {
h.t.Errorf("call %s failed: %v", endpoint, err)
}
}
// AssertCallFails checks that a call fails
func (h *Harness) AssertCallFails(endpoint string, req, rsp interface{}) {
h.t.Helper()
if err := h.Call(endpoint, req, rsp); err == nil {
h.t.Errorf("expected call %s to fail, but it succeeded", endpoint)
}
}
+111
View File
@@ -0,0 +1,111 @@
package test
import (
"context"
"testing"
)
// Simple test handler
type GreeterHandler struct{}
type HelloRequest struct {
Name string `json:"name"`
}
type HelloResponse struct {
Message string `json:"message"`
}
func (g *GreeterHandler) Hello(ctx context.Context, req *HelloRequest, rsp *HelloResponse) error {
rsp.Message = "Hello " + req.Name
return nil
}
func TestHarnessBasic(t *testing.T) {
h := NewHarness(t)
defer h.Stop()
h.Name("greeter").Register(new(GreeterHandler))
h.Start()
// Check service is running
h.AssertServiceRunning()
// Make a call
var rsp HelloResponse
err := h.Call("GreeterHandler.Hello", &HelloRequest{Name: "World"}, &rsp)
if err != nil {
t.Fatalf("call failed: %v", err)
}
if rsp.Message != "Hello World" {
t.Errorf("expected 'Hello World', got '%s'", rsp.Message)
}
}
func TestHarnessCallBeforeStart(t *testing.T) {
h := NewHarness(t)
defer h.Stop()
h.Register(new(GreeterHandler))
// Don't call Start()
var rsp HelloResponse
err := h.Call("GreeterHandler.Hello", &HelloRequest{Name: "World"}, &rsp)
if err == nil {
t.Error("expected error when calling before Start()")
}
}
func TestHarnessAssertCallSucceeds(t *testing.T) {
h := NewHarness(t)
defer h.Stop()
h.Name("greeter").Register(new(GreeterHandler))
h.Start()
var rsp HelloResponse
h.AssertCallSucceeds("GreeterHandler.Hello", &HelloRequest{Name: "Test"}, &rsp)
if rsp.Message != "Hello Test" {
t.Errorf("expected 'Hello Test', got '%s'", rsp.Message)
}
}
func TestHarnessClientAndServer(t *testing.T) {
h := NewHarness(t)
defer h.Stop()
h.Name("greeter").Register(new(GreeterHandler))
h.Start()
// Check we can access client and server
if h.Client() == nil {
t.Fatal("client is nil")
}
if h.Server() == nil {
t.Fatal("server is nil")
}
if h.Registry() == nil {
t.Fatal("registry is nil")
}
}
func TestHarnessWithContext(t *testing.T) {
h := NewHarness(t)
defer h.Stop()
h.Name("greeter").Register(new(GreeterHandler))
h.Start()
ctx := context.Background()
var rsp HelloResponse
err := h.CallContext(ctx, "GreeterHandler.Hello", &HelloRequest{Name: "Context"}, &rsp)
if err != nil {
t.Fatalf("call with context failed: %v", err)
}
if rsp.Message != "Hello Context" {
t.Errorf("expected 'Hello Context', got '%s'", rsp.Message)
}
}
+165
View File
@@ -0,0 +1,165 @@
// addr provides functions to retrieve local IP addresses from device interfaces.
package addr
import (
"net"
"github.com/pkg/errors"
)
var (
// ErrIPNotFound no IP address found, and explicit IP not provided.
ErrIPNotFound = errors.New("no IP address found, and explicit IP not provided")
)
// IsLocal checks whether an IP belongs to one of the device's interfaces.
func IsLocal(addr string) bool {
// Extract the host
host, _, err := net.SplitHostPort(addr)
if err == nil {
addr = host
}
if addr == "localhost" {
return true
}
// Check against all local ips
for _, ip := range IPs() {
if addr == ip {
return true
}
}
return false
}
// Extract returns a valid IP address. If the address provided is a valid
// address, it will be returned directly. Otherwise, the available interfaces
// will be iterated over to find an IP address, preferably private.
func Extract(addr string) (string, error) {
// if addr is already specified then it's directly returned
if len(addr) > 0 && (addr != "0.0.0.0" && addr != "[::]" && addr != "::") {
return addr, nil
}
var (
addrs []net.Addr
loAddrs []net.Addr
)
ifaces, err := net.Interfaces()
if err != nil {
return "", errors.Wrap(err, "failed to get interfaces")
}
for _, iface := range ifaces {
ifaceAddrs, err := iface.Addrs()
if err != nil {
// ignore error, interface can disappear from system
continue
}
if iface.Flags&net.FlagLoopback != 0 {
loAddrs = append(loAddrs, ifaceAddrs...)
continue
}
addrs = append(addrs, ifaceAddrs...)
}
// Add loopback addresses to the end of the list
addrs = append(addrs, loAddrs...)
// Try to find private IP in list, public IP otherwise
ip, err := findIP(addrs)
if err != nil {
return "", err
}
return ip.String(), nil
}
// IPs returns all available interface IP addresses.
func IPs() []string {
ifaces, err := net.Interfaces()
if err != nil {
return nil
}
var ipAddrs []string
for _, i := range ifaces {
addrs, err := i.Addrs()
if err != nil {
continue
}
for _, addr := range addrs {
var ip net.IP
switch v := addr.(type) {
case *net.IPNet:
ip = v.IP
case *net.IPAddr:
ip = v.IP
}
if ip == nil {
continue
}
ipAddrs = append(ipAddrs, ip.String())
}
}
return ipAddrs
}
// findIP will return the first private IP available in the list.
// If no private IP is available it will return the first public IP, if present.
// If no public IP is available, it will return the first loopback IP, if present.
func findIP(addresses []net.Addr) (net.IP, error) {
var publicIP net.IP
var localIP net.IP
for _, rawAddr := range addresses {
var ip net.IP
switch addr := rawAddr.(type) {
case *net.IPAddr:
ip = addr.IP
case *net.IPNet:
ip = addr.IP
default:
continue
}
if ip.IsLoopback() {
if localIP == nil {
localIP = ip
}
continue
}
if !ip.IsPrivate() {
if publicIP == nil {
publicIP = ip
}
continue
}
// Return private IP if available
return ip, nil
}
// Return public or virtual IP
if len(publicIP) > 0 {
return publicIP, nil
}
// Return local IP
if len(localIP) > 0 {
return localIP, nil
}
return nil, ErrIPNotFound
}
+132
View File
@@ -0,0 +1,132 @@
package addr
import (
"github.com/stretchr/testify/assert"
"net"
"testing"
)
func TestIsLocal(t *testing.T) {
testData := []struct {
addr string
expect bool
}{
{"localhost", true},
{"localhost:8080", true},
{"127.0.0.1", true},
{"127.0.0.1:1001", true},
{"80.1.1.1", false},
}
for _, d := range testData {
res := IsLocal(d.addr)
if res != d.expect {
t.Fatalf("expected %t got %t", d.expect, res)
}
}
}
func TestExtractor(t *testing.T) {
testData := []struct {
addr string
expect string
parse bool
}{
{"127.0.0.1", "127.0.0.1", false},
{"10.0.0.1", "10.0.0.1", false},
{"", "", true},
{"0.0.0.0", "", true},
{"[::]", "", true},
}
for _, d := range testData {
addr, err := Extract(d.addr)
if err != nil {
t.Errorf("Unexpected error %v", err)
}
if d.parse {
ip := net.ParseIP(addr)
if ip == nil {
t.Error("Unexpected nil IP")
}
} else if addr != d.expect {
t.Errorf("Expected %s got %s", d.expect, addr)
}
}
}
func TestFindIP(t *testing.T) {
localhost, _ := net.ResolveIPAddr("ip", "127.0.0.1")
localhostIPv6, _ := net.ResolveIPAddr("ip", "::1")
privateIP, _ := net.ResolveIPAddr("ip", "10.0.0.1")
publicIP, _ := net.ResolveIPAddr("ip", "100.0.0.1")
publicIPv6, _ := net.ResolveIPAddr("ip", "2001:0db8:85a3:0000:0000:8a2e:0370:7334")
testCases := []struct {
addrs []net.Addr
ip net.IP
errMsg string
}{
{
addrs: []net.Addr{},
ip: nil,
errMsg: ErrIPNotFound.Error(),
},
{
addrs: []net.Addr{localhost},
ip: localhost.IP,
},
{
addrs: []net.Addr{localhost, localhostIPv6},
ip: localhost.IP,
},
{
addrs: []net.Addr{localhostIPv6},
ip: localhostIPv6.IP,
},
{
addrs: []net.Addr{privateIP, localhost},
ip: privateIP.IP,
},
{
addrs: []net.Addr{privateIP, publicIP, localhost},
ip: privateIP.IP,
},
{
addrs: []net.Addr{publicIP, privateIP, localhost},
ip: privateIP.IP,
},
{
addrs: []net.Addr{publicIP, localhost},
ip: publicIP.IP,
},
{
addrs: []net.Addr{publicIP, localhostIPv6},
ip: publicIP.IP,
},
{
addrs: []net.Addr{localhostIPv6, publicIP},
ip: publicIP.IP,
},
{
addrs: []net.Addr{localhostIPv6, publicIPv6, publicIP},
ip: publicIPv6.IP,
},
{
addrs: []net.Addr{publicIP, publicIPv6},
ip: publicIP.IP,
},
}
for _, tc := range testCases {
ip, err := findIP(tc.addrs)
if tc.errMsg == "" {
assert.Nil(t, err)
assert.Equal(t, tc.ip.String(), ip.String())
} else {
assert.NotNil(t, err)
assert.Equal(t, tc.errMsg, err.Error())
}
}
}
+16
View File
@@ -0,0 +1,16 @@
// Package backoff provides backoff functionality
package backoff
import (
"math"
"time"
)
// Do is a function x^e multiplied by a factor of 0.1 second.
// Result is limited to 2 minute.
func Do(attempts int) time.Duration {
if attempts > 13 {
return 2 * time.Minute
}
return time.Duration(math.Pow(float64(attempts), math.E)) * time.Millisecond * 100
}
+21
View File
@@ -0,0 +1,21 @@
package buf
import (
"bytes"
)
type buffer struct {
*bytes.Buffer
}
func (b *buffer) Close() error {
b.Reset()
return nil
}
func New(b *bytes.Buffer) *buffer {
if b == nil {
b = bytes.NewBuffer(nil)
}
return &buffer{b}
}
+57
View File
@@ -0,0 +1,57 @@
package grpc
import (
"fmt"
"strings"
)
// ServiceMethod converts a gRPC method to a Go method
// Input:
// Foo.Bar, /Foo/Bar, /package.Foo/Bar, /a.package.Foo/Bar
// Output:
// [Foo, Bar].
func ServiceMethod(m string) (string, string, error) {
if len(m) == 0 {
return "", "", fmt.Errorf("malformed method name: %q", m)
}
// grpc method
if m[0] == '/' {
// [ , Foo, Bar]
// [ , package.Foo, Bar]
// [ , a.package.Foo, Bar]
parts := strings.Split(m, "/")
if len(parts) != 3 || len(parts[1]) == 0 || len(parts[2]) == 0 {
return "", "", fmt.Errorf("malformed method name: %q", m)
}
service := strings.Split(parts[1], ".")
return service[len(service)-1], parts[2], nil
}
// non grpc method
parts := strings.Split(m, ".")
// expect [Foo, Bar]
if len(parts) != 2 {
return "", "", fmt.Errorf("malformed method name: %q", m)
}
return parts[0], parts[1], nil
}
// ServiceFromMethod returns the service
// /service.Foo/Bar => service.
func ServiceFromMethod(m string) string {
if len(m) == 0 {
return m
}
if m[0] != '/' {
return m
}
parts := strings.Split(m, "/")
if len(parts) < 3 {
return m
}
parts = strings.Split(parts[1], ".")
return strings.Join(parts[:len(parts)-1], ".")
}
+46
View File
@@ -0,0 +1,46 @@
package grpc
import (
"testing"
)
func TestServiceMethod(t *testing.T) {
type testCase struct {
input string
service string
method string
err bool
}
methods := []testCase{
{"Foo.Bar", "Foo", "Bar", false},
{"/Foo/Bar", "Foo", "Bar", false},
{"/package.Foo/Bar", "Foo", "Bar", false},
{"/a.package.Foo/Bar", "Foo", "Bar", false},
{"a.package.Foo/Bar", "", "", true},
{"/Foo/Bar/Baz", "", "", true},
{"Foo.Bar.Baz", "", "", true},
}
for _, test := range methods {
service, method, err := ServiceMethod(test.input)
if err != nil && test.err == true {
continue
}
// unexpected error
if err != nil && test.err == false {
t.Fatalf("unexpected err %v for %+v", err, test)
}
// expecter error
if test.err == true && err == nil {
t.Fatalf("expected error for %+v: got service: %s method: %s", test, service, method)
}
if service != test.service {
t.Fatalf("wrong service for %+v: got service: %s method: %s", test, service, method)
}
if method != test.method {
t.Fatalf("wrong method for %+v: got service: %s method: %s", test, service, method)
}
}
}
+72
View File
@@ -0,0 +1,72 @@
package http
import (
"context"
"encoding/json"
"fmt"
"net/http"
"strings"
"go-micro.dev/v6/logger"
"go-micro.dev/v6/metadata"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/selector"
)
// Write sets the status and body on a http ResponseWriter.
func Write(w http.ResponseWriter, contentType string, status int, body string) {
w.Header().Set("Content-Length", fmt.Sprintf("%v", len(body)))
w.Header().Set("Content-Type", contentType)
w.WriteHeader(status)
fmt.Fprintf(w, `%v`, body)
}
// WriteBadRequestError sets a 400 status code.
func WriteBadRequestError(w http.ResponseWriter, err error) {
rawBody, err := json.Marshal(map[string]string{
"error": err.Error(),
})
if err != nil {
WriteInternalServerError(w, err)
return
}
Write(w, "application/json", 400, string(rawBody))
}
// WriteInternalServerError sets a 500 status code.
func WriteInternalServerError(w http.ResponseWriter, err error) {
rawBody, err := json.Marshal(map[string]string{
"error": err.Error(),
})
if err != nil {
logger.Log(logger.ErrorLevel, err)
return
}
Write(w, "application/json", 500, string(rawBody))
}
func NewRoundTripper(opts ...Option) http.RoundTripper {
options := Options{
Registry: registry.DefaultRegistry,
}
for _, o := range opts {
o(&options)
}
return &roundTripper{
rt: http.DefaultTransport,
st: selector.Random,
opts: options,
}
}
// RequestToContext puts the `Authorization` header bearer token into context
// so calls to services will be authorized.
func RequestToContext(r *http.Request) context.Context {
ctx := context.Background()
md := make(metadata.Metadata)
for k, v := range r.Header {
md[k] = strings.Join(v, ",")
}
return metadata.NewContext(ctx, md)
}
+80
View File
@@ -0,0 +1,80 @@
package http
import (
"io"
"net"
"net/http"
"testing"
"go-micro.dev/v6/registry"
)
func TestRoundTripper(t *testing.T) {
m := registry.NewMemoryRegistry()
rt := NewRoundTripper(
WithRegistry(m),
)
http.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) {
w.Write([]byte(`hello world`))
})
l, err := net.Listen("tcp", "127.0.0.1:0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
go http.Serve(l, nil)
m.Register(&registry.Service{
Name: "example.com",
Nodes: []*registry.Node{
{
Id: "1",
Address: l.Addr().String(),
},
},
})
req, err := http.NewRequest(http.MethodGet, "http://example.com", nil)
if err != nil {
t.Fatal(err)
}
w, err := rt.RoundTrip(req)
if err != nil {
t.Fatal(err)
}
b, err := io.ReadAll(w.Body)
if err != nil {
t.Fatal(err)
}
w.Body.Close()
if string(b) != "hello world" {
t.Fatal("response is", string(b))
}
// test http request
c := &http.Client{
Transport: rt,
}
rsp, err := c.Get("http://example.com")
if err != nil {
t.Fatal(err)
}
b, err = io.ReadAll(rsp.Body)
if err != nil {
t.Fatal(err)
}
rsp.Body.Close()
if string(b) != "hello world" {
t.Fatal("response is", string(b))
}
}
+17
View File
@@ -0,0 +1,17 @@
package http
import (
"go-micro.dev/v6/registry"
)
type Options struct {
Registry registry.Registry
}
type Option func(*Options)
func WithRegistry(r registry.Registry) Option {
return func(o *Options) {
o.Registry = r
}
}
+39
View File
@@ -0,0 +1,39 @@
package http
import (
"errors"
"net/http"
"go-micro.dev/v6/selector"
)
type roundTripper struct {
rt http.RoundTripper
st selector.Strategy
opts Options
}
func (r *roundTripper) RoundTrip(req *http.Request) (*http.Response, error) {
s, err := r.opts.Registry.GetService(req.URL.Host)
if err != nil {
return nil, err
}
next := r.st(s)
// rudimentary retry 3 times
for i := 0; i < 3; i++ {
n, err := next()
if err != nil {
continue
}
req.URL.Host = n.Address
w, err := r.rt.RoundTrip(req)
if err != nil {
continue
}
return w, nil
}
return nil, errors.New("failed request")
}
+17
View File
@@ -0,0 +1,17 @@
// Package jitter provides a random jitter
package jitter
import (
"math/rand"
"time"
)
var (
r = rand.New(rand.NewSource(time.Now().UnixNano()))
)
// Do returns a random time to jitter with max cap specified.
func Do(d time.Duration) time.Duration {
v := r.Float64() * float64(d.Nanoseconds())
return time.Duration(v)
}
+23
View File
@@ -0,0 +1,23 @@
# Compiled Object files, Static and Dynamic libs (Shared Objects)
*.o
*.a
*.so
# Folders
_obj
_test
# Architecture specific extensions/prefixes
*.[568vq]
[568vq].out
*.cgo1.go
*.cgo2.c
_cgo_defun.c
_cgo_gotypes.go
_cgo_export.*
_testmain.go
*.exe
*.test
+507
View File
@@ -0,0 +1,507 @@
package mdns
import (
"context"
"fmt"
"net"
"strings"
"sync"
"time"
"github.com/miekg/dns"
"go-micro.dev/v6/logger"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
// ServiceEntry is returned after we query for a service.
type ServiceEntry struct {
Name string
Host string
Info string
AddrV4 net.IP
AddrV6 net.IP
InfoFields []string
Addr net.IP // @Deprecated
Port int
TTL int
Type uint16
hasTXT bool
sent bool
}
// complete is used to check if we have all the info we need.
func (s *ServiceEntry) complete() bool {
return (len(s.AddrV4) > 0 || len(s.AddrV6) > 0 || len(s.Addr) > 0) && s.Port != 0 && s.hasTXT
}
// QueryParam is used to customize how a Lookup is performed.
type QueryParam struct {
Context context.Context // Context
Interface *net.Interface // Multicast interface to use
Entries chan<- *ServiceEntry // Entries Channel
Service string // Service to lookup
Domain string // Lookup domain, default "local"
Timeout time.Duration // Lookup timeout, default 1 second. Ignored if Context is provided
Type uint16 // Lookup type, defaults to dns.TypePTR
WantUnicastResponse bool // Unicast response desired, as per 5.4 in RFC
}
// DefaultParams is used to return a default set of QueryParam's.
func DefaultParams(service string) *QueryParam {
return &QueryParam{
Service: service,
Domain: "local",
Timeout: time.Second,
Entries: make(chan *ServiceEntry),
WantUnicastResponse: false, // TODO(reddaly): Change this default.
}
}
// Query looks up a given service, in a domain, waiting at most
// for a timeout before finishing the query. The results are streamed
// to a channel. Sends will not block, so clients should make sure to
// either read or buffer.
func Query(params *QueryParam) error {
// Create a new client
client, err := newClient()
if err != nil {
return err
}
defer client.Close()
// Set the multicast interface
if params.Interface != nil {
if err := client.setInterface(params.Interface, false); err != nil {
return err
}
}
// Ensure defaults are set
if params.Domain == "" {
params.Domain = "local"
}
if params.Context == nil {
if params.Timeout == 0 {
params.Timeout = time.Second
}
var cancel context.CancelFunc
params.Context, cancel = context.WithTimeout(context.Background(), params.Timeout)
defer cancel()
}
// Run the query
return client.query(params)
}
// Listen listens indefinitely for multicast updates.
func Listen(entries chan<- *ServiceEntry, exit chan struct{}) error {
// Create a new client
client, err := newClient()
if err != nil {
return err
}
defer client.Close()
_ = client.setInterface(nil, true)
// Start listening for response packets
msgCh := make(chan *dns.Msg, 32)
go client.recv(client.ipv4UnicastConn, msgCh)
go client.recv(client.ipv6UnicastConn, msgCh)
go client.recv(client.ipv4MulticastConn, msgCh)
go client.recv(client.ipv6MulticastConn, msgCh)
ip := make(map[string]*ServiceEntry)
for {
select {
case <-exit:
return nil
case <-client.closedCh:
return nil
case m := <-msgCh:
e := messageToEntry(m, ip)
if e == nil {
continue
}
// Check if this entry is complete
if e.complete() {
if e.sent {
continue
}
e.sent = true
entries <- e
ip = make(map[string]*ServiceEntry)
} else {
// Fire off a node specific query
m := new(dns.Msg)
m.SetQuestion(e.Name, dns.TypePTR)
m.RecursionDesired = false
if err := client.sendQuery(m); err != nil {
logger.Logf(logger.ErrorLevel, "[mdns] failed to query instance %s: %v", e.Name, err)
}
}
}
}
}
// Lookup is the same as Query, however it uses all the default parameters.
func Lookup(service string, entries chan<- *ServiceEntry) error {
params := DefaultParams(service)
params.Entries = entries
return Query(params)
}
// Client provides a query interface that can be used to
// search for service providers using mDNS.
type client struct {
ipv4UnicastConn *net.UDPConn
ipv6UnicastConn *net.UDPConn
ipv4MulticastConn *net.UDPConn
ipv6MulticastConn *net.UDPConn
closedCh chan struct{} // TODO(reddaly): This doesn't appear to be used.
closeLock sync.Mutex
closed bool
}
// NewClient creates a new mdns Client that can be used to query
// for records.
func newClient() (*client, error) {
// TODO(reddaly): At least attempt to bind to the port required in the spec.
// Create a IPv4 listener
uconn4, err4 := net.ListenUDP("udp4", &net.UDPAddr{IP: net.IPv4zero, Port: 0})
uconn6, err6 := net.ListenUDP("udp6", &net.UDPAddr{IP: net.IPv6zero, Port: 0})
if err4 != nil && err6 != nil {
logger.Logf(logger.ErrorLevel, "[mdns] failed to bind to udp port: %v %v", err4, err6)
}
if uconn4 == nil && uconn6 == nil {
return nil, fmt.Errorf("failed to bind to any unicast udp port")
}
if uconn4 == nil {
uconn4 = &net.UDPConn{}
}
if uconn6 == nil {
uconn6 = &net.UDPConn{}
}
mconn4, err4 := net.ListenUDP("udp4", mdnsWildcardAddrIPv4)
mconn6, err6 := net.ListenUDP("udp6", mdnsWildcardAddrIPv6)
if err4 != nil && err6 != nil {
logger.Logf(logger.ErrorLevel, "[mdns] failed to bind to udp port: %v %v", err4, err6)
}
if mconn4 == nil && mconn6 == nil {
return nil, fmt.Errorf("failed to bind to any multicast udp port")
}
if mconn4 == nil {
mconn4 = &net.UDPConn{}
}
if mconn6 == nil {
mconn6 = &net.UDPConn{}
}
p1 := ipv4.NewPacketConn(mconn4)
p2 := ipv6.NewPacketConn(mconn6)
_ = p1.SetMulticastLoopback(true)
_ = p2.SetMulticastLoopback(true)
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
var errCount1, errCount2 int
for _, iface := range ifaces {
if err := p1.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv4}); err != nil {
errCount1++
}
if err := p2.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil {
errCount2++
}
}
if len(ifaces) == errCount1 && len(ifaces) == errCount2 {
return nil, fmt.Errorf("failed to join multicast group on all interfaces")
}
c := &client{
ipv4MulticastConn: mconn4,
ipv6MulticastConn: mconn6,
ipv4UnicastConn: uconn4,
ipv6UnicastConn: uconn6,
closedCh: make(chan struct{}),
}
return c, nil
}
// Close is used to cleanup the client.
func (c *client) Close() error {
c.closeLock.Lock()
defer c.closeLock.Unlock()
if c.closed {
return nil
}
c.closed = true
close(c.closedCh)
if c.ipv4UnicastConn != nil {
c.ipv4UnicastConn.Close()
}
if c.ipv6UnicastConn != nil {
c.ipv6UnicastConn.Close()
}
if c.ipv4MulticastConn != nil {
c.ipv4MulticastConn.Close()
}
if c.ipv6MulticastConn != nil {
c.ipv6MulticastConn.Close()
}
return nil
}
// setInterface is used to set the query interface, uses system
// default if not provided.
func (c *client) setInterface(iface *net.Interface, loopback bool) error {
p := ipv4.NewPacketConn(c.ipv4UnicastConn)
if err := p.JoinGroup(iface, &net.UDPAddr{IP: mdnsGroupIPv4}); err != nil {
return err
}
p2 := ipv6.NewPacketConn(c.ipv6UnicastConn)
if err := p2.JoinGroup(iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil {
return err
}
p = ipv4.NewPacketConn(c.ipv4MulticastConn)
if err := p.JoinGroup(iface, &net.UDPAddr{IP: mdnsGroupIPv4}); err != nil {
return err
}
p2 = ipv6.NewPacketConn(c.ipv6MulticastConn)
if err := p2.JoinGroup(iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil {
return err
}
if loopback {
_ = p.SetMulticastLoopback(true)
_ = p2.SetMulticastLoopback(true)
}
return nil
}
// query is used to perform a lookup and stream results.
func (c *client) query(params *QueryParam) error {
// Create the service name
serviceAddr := fmt.Sprintf("%s.%s.", trimDot(params.Service), trimDot(params.Domain))
// Start listening for response packets
msgCh := make(chan *dns.Msg, 32)
go c.recv(c.ipv4UnicastConn, msgCh)
go c.recv(c.ipv6UnicastConn, msgCh)
go c.recv(c.ipv4MulticastConn, msgCh)
go c.recv(c.ipv6MulticastConn, msgCh)
// Send the query
m := new(dns.Msg)
if params.Type == dns.TypeNone {
m.SetQuestion(serviceAddr, dns.TypePTR)
} else {
m.SetQuestion(serviceAddr, params.Type)
}
// RFC 6762, section 18.12. Repurposing of Top Bit of qclass in Question
// Section
//
// In the Question Section of a Multicast DNS query, the top bit of the qclass
// field is used to indicate that unicast responses are preferred for this
// particular question. (See Section 5.4.)
if params.WantUnicastResponse {
m.Question[0].Qclass |= 1 << 15
}
m.RecursionDesired = false
if err := c.sendQuery(m); err != nil {
return err
}
// Map the in-progress responses
inprogress := make(map[string]*ServiceEntry)
for {
select {
case resp := <-msgCh:
inp := messageToEntry(resp, inprogress)
if inp == nil {
continue
}
if len(resp.Question) == 0 || resp.Question[0].Name != m.Question[0].Name {
// discard anything which we've not asked for
continue
}
// Check if this entry is complete
if inp.complete() {
if inp.sent {
continue
}
inp.sent = true
select {
case params.Entries <- inp:
case <-params.Context.Done():
return nil
}
} else {
// Fire off a node specific query
m := new(dns.Msg)
m.SetQuestion(inp.Name, inp.Type)
m.RecursionDesired = false
if err := c.sendQuery(m); err != nil {
logger.Logf(logger.ErrorLevel, "[mdns] failed to query instance %s: %v", inp.Name, err)
}
}
case <-params.Context.Done():
return nil
}
}
}
// sendQuery is used to multicast a query out.
func (c *client) sendQuery(q *dns.Msg) error {
buf, err := q.Pack()
if err != nil {
return err
}
if c.ipv4UnicastConn != nil {
_, _ = c.ipv4UnicastConn.WriteToUDP(buf, ipv4Addr)
}
if c.ipv6UnicastConn != nil {
_, _ = c.ipv6UnicastConn.WriteToUDP(buf, ipv6Addr)
}
return nil
}
// recv is used to receive until we get a shutdown.
func (c *client) recv(l *net.UDPConn, msgCh chan *dns.Msg) {
if l == nil {
return
}
buf := make([]byte, 65536)
for {
c.closeLock.Lock()
if c.closed {
c.closeLock.Unlock()
return
}
c.closeLock.Unlock()
n, err := l.Read(buf)
if err != nil {
continue
}
msg := new(dns.Msg)
if err := msg.Unpack(buf[:n]); err != nil {
continue
}
select {
case msgCh <- msg:
case <-c.closedCh:
return
}
}
}
// ensureName is used to ensure the named node is in progress.
func ensureName(inprogress map[string]*ServiceEntry, name string, typ uint16) *ServiceEntry {
if inp, ok := inprogress[name]; ok {
return inp
}
inp := &ServiceEntry{
Name: name,
Type: typ,
}
inprogress[name] = inp
return inp
}
// alias is used to setup an alias between two entries.
func alias(inprogress map[string]*ServiceEntry, src, dst string, typ uint16) {
srcEntry := ensureName(inprogress, src, typ)
inprogress[dst] = srcEntry
}
func messageToEntry(m *dns.Msg, inprogress map[string]*ServiceEntry) *ServiceEntry {
var inp *ServiceEntry
for _, answer := range append(m.Answer, m.Extra...) {
// TODO(reddaly): Check that response corresponds to serviceAddr?
switch rr := answer.(type) {
case *dns.PTR:
// Create new entry for this
inp = ensureName(inprogress, rr.Ptr, rr.Hdr.Rrtype)
if inp.complete() {
continue
}
case *dns.SRV:
// Check for a target mismatch
if rr.Target != rr.Hdr.Name {
alias(inprogress, rr.Hdr.Name, rr.Target, rr.Hdr.Rrtype)
}
// Get the port
inp = ensureName(inprogress, rr.Hdr.Name, rr.Hdr.Rrtype)
if inp.complete() {
continue
}
inp.Host = rr.Target
inp.Port = int(rr.Port)
case *dns.TXT:
// Pull out the txt
inp = ensureName(inprogress, rr.Hdr.Name, rr.Hdr.Rrtype)
if inp.complete() {
continue
}
inp.Info = strings.Join(rr.Txt, "|")
inp.InfoFields = rr.Txt
inp.hasTXT = true
case *dns.A:
// Pull out the IP
inp = ensureName(inprogress, rr.Hdr.Name, rr.Hdr.Rrtype)
if inp.complete() {
continue
}
inp.Addr = rr.A // @Deprecated
inp.AddrV4 = rr.A
case *dns.AAAA:
// Pull out the IP
inp = ensureName(inprogress, rr.Hdr.Name, rr.Hdr.Rrtype)
if inp.complete() {
continue
}
inp.Addr = rr.AAAA // @Deprecated
inp.AddrV6 = rr.AAAA
}
if inp != nil {
inp.TTL = int(answer.Header().Ttl)
}
}
return inp
}
+85
View File
@@ -0,0 +1,85 @@
package mdns
import "github.com/miekg/dns"
// DNSSDService is a service that complies with the DNS-SD (RFC 6762) and MDNS
// (RFC 6762) specs for local, multicast-DNS-based discovery.
//
// DNSSDService implements the Zone interface and wraps an MDNSService instance.
// To deploy an mDNS service that is compliant with DNS-SD, it's recommended to
// register only the wrapped instance with the server.
//
// Example usage:
//
// service := &mdns.DNSSDService{
// MDNSService: &mdns.MDNSService{
// Instance: "My Foobar Service",
// Service: "_foobar._tcp",
// Port: 8000,
// }
// }
// server, err := mdns.NewServer(&mdns.Config{Zone: service})
// if err != nil {
// log.Fatalf("Error creating server: %v", err)
// }
// defer server.Shutdown()
type DNSSDService struct {
MDNSService *MDNSService
}
// Records returns DNS records in response to a DNS question.
//
// This function returns the DNS response of the underlying MDNSService
// instance. It also returns a PTR record for a request for "
// _services._dns-sd._udp.<Domain>", as described in section 9 of RFC 6763
// ("Service Type Enumeration"), to allow browsing of the underlying MDNSService
// instance.
func (s *DNSSDService) Records(q dns.Question) []dns.RR {
var recs []dns.RR
if q.Name == "_services._dns-sd._udp."+s.MDNSService.Domain+"." {
recs = s.dnssdMetaQueryRecords(q)
}
return append(recs, s.MDNSService.Records(q)...)
}
// dnssdMetaQueryRecords returns the DNS records in response to a "meta-query"
// issued to browse for DNS-SD services, as per section 9. of RFC6763.
//
// A meta-query has a name of the form "_services._dns-sd._udp.<Domain>" where
// Domain is a fully-qualified domain, such as "local.".
func (s *DNSSDService) dnssdMetaQueryRecords(q dns.Question) []dns.RR {
// Intended behavior, as described in the RFC:
// ...it may be useful for network administrators to find the list of
// advertised service types on the network, even if those Service Names
// are just opaque identifiers and not particularly informative in
// isolation.
//
// For this purpose, a special meta-query is defined. A DNS query for PTR
// records with the name "_services._dns-sd._udp.<Domain>" yields a set of
// PTR records, where the rdata of each PTR record is the two-abel
// <Service> name, plus the same domain, e.g., "_http._tcp.<Domain>".
// Including the domain in the PTR rdata allows for slightly better name
// compression in Unicast DNS responses, but only the first two labels are
// relevant for the purposes of service type enumeration. These two-label
// service types can then be used to construct subsequent Service Instance
// Enumeration PTR queries, in this <Domain> or others, to discover
// instances of that service type.
return []dns.RR{
&dns.PTR{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: defaultTTL,
},
Ptr: s.MDNSService.serviceAddr,
},
}
}
// Announcement returns DNS records that should be broadcast during the initial
// availability of the service, as described in section 8.3 of RFC 6762.
// TODO(reddaly): Add this when Announcement is added to the mdns.Zone interface.
// func (s *DNSSDService) Announcement() []dns.RR {
// return s.MDNSService.Announcement()
//}
+39
View File
@@ -0,0 +1,39 @@
package mdns
import (
"reflect"
"testing"
"github.com/miekg/dns"
)
func TestDNSSDServiceRecords(t *testing.T) {
s := &DNSSDService{
MDNSService: &MDNSService{
serviceAddr: "_foobar._tcp.local.",
Domain: "local",
},
}
q := dns.Question{
Name: "_services._dns-sd._udp.local.",
Qtype: dns.TypePTR,
Qclass: dns.ClassINET,
}
recs := s.Records(q)
if got, want := len(recs), 1; got != want {
t.Fatalf("s.Records(%v) returned %v records, want %v", q, got, want)
}
want := dns.RR(&dns.PTR{
Hdr: dns.RR_Header{
Name: "_services._dns-sd._udp.local.",
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: defaultTTL,
},
Ptr: "_foobar._tcp.local.",
})
if got := recs[0]; !reflect.DeepEqual(got, want) {
t.Errorf("s.Records()[0] = %v, want %v", got, want)
}
}
+518
View File
@@ -0,0 +1,518 @@
package mdns
import (
"fmt"
"math/rand"
"net"
"sync"
"sync/atomic"
"time"
"github.com/miekg/dns"
log "go-micro.dev/v6/logger"
"golang.org/x/net/ipv4"
"golang.org/x/net/ipv6"
)
var (
mdnsGroupIPv4 = net.ParseIP("224.0.0.251")
mdnsGroupIPv6 = net.ParseIP("ff02::fb")
// mDNS wildcard addresses.
mdnsWildcardAddrIPv4 = &net.UDPAddr{
IP: net.ParseIP("224.0.0.0"),
Port: 5353,
}
mdnsWildcardAddrIPv6 = &net.UDPAddr{
IP: net.ParseIP("ff02::"),
Port: 5353,
}
// mDNS endpoint addresses.
ipv4Addr = &net.UDPAddr{
IP: mdnsGroupIPv4,
Port: 5353,
}
ipv6Addr = &net.UDPAddr{
IP: mdnsGroupIPv6,
Port: 5353,
}
)
// GetMachineIP is a func which returns the outbound IP of this machine.
// Used by the server to determine whether to attempt send the response on a local address.
type GetMachineIP func() net.IP
// Config is used to configure the mDNS server.
type Config struct {
// Zone must be provided to support responding to queries
Zone Zone
// Iface if provided binds the multicast listener to the given
// interface. If not provided, the system default multicase interface
// is used.
Iface *net.Interface
// GetMachineIP is a function to return the IP of the local machine
GetMachineIP GetMachineIP
// Port If it is not 0, replace the port 5353 with this port number.
Port int
// LocalhostChecking if enabled asks the server to also send responses to 0.0.0.0 if the target IP
// is this host (as defined by GetMachineIP). Useful in case machine is on a VPN which blocks comms on non standard ports
LocalhostChecking bool
}
// Server is an mDNS server used to listen for mDNS queries and respond if we
// have a matching local record.
type Server struct {
config *Config
ipv4List *net.UDPConn
ipv6List *net.UDPConn
shutdownCh chan struct{}
outboundIP net.IP
wg sync.WaitGroup
shutdownLock sync.Mutex
shutdown bool
}
// NewServer is used to create a new mDNS server from a config.
func NewServer(config *Config) (*Server, error) {
setCustomPort(config.Port)
// Create the listeners
// Create wildcard connections (because :5353 can be already taken by other apps)
ipv4List, _ := net.ListenUDP("udp4", mdnsWildcardAddrIPv4)
ipv6List, _ := net.ListenUDP("udp6", mdnsWildcardAddrIPv6)
if ipv4List == nil && ipv6List == nil {
return nil, fmt.Errorf("[ERR] mdns: Failed to bind to any udp port")
}
if ipv4List == nil {
ipv4List = &net.UDPConn{}
}
if ipv6List == nil {
ipv6List = &net.UDPConn{}
}
// Join multicast groups to receive announcements
p1 := ipv4.NewPacketConn(ipv4List)
p2 := ipv6.NewPacketConn(ipv6List)
_ = p1.SetMulticastLoopback(true)
_ = p2.SetMulticastLoopback(true)
if config.Iface != nil {
if err := p1.JoinGroup(config.Iface, &net.UDPAddr{IP: mdnsGroupIPv4}); err != nil {
return nil, err
}
if err := p2.JoinGroup(config.Iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil {
return nil, err
}
} else {
ifaces, err := net.Interfaces()
if err != nil {
return nil, err
}
errCount1, errCount2 := 0, 0
for _, iface := range ifaces {
if err := p1.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv4}); err != nil {
errCount1++
}
if err := p2.JoinGroup(&iface, &net.UDPAddr{IP: mdnsGroupIPv6}); err != nil {
errCount2++
}
}
if len(ifaces) == errCount1 && len(ifaces) == errCount2 {
return nil, fmt.Errorf("failed to join multicast group on all interfaces")
}
}
ipFunc := getOutboundIP
if config.GetMachineIP != nil {
ipFunc = config.GetMachineIP
}
s := &Server{
config: config,
ipv4List: ipv4List,
ipv6List: ipv6List,
shutdownCh: make(chan struct{}),
outboundIP: ipFunc(),
}
go s.recv(s.ipv4List)
go s.recv(s.ipv6List)
s.wg.Add(1)
go s.probe()
return s, nil
}
// Shutdown is used to shutdown the listener.
func (s *Server) Shutdown() error {
s.shutdownLock.Lock()
defer s.shutdownLock.Unlock()
if s.shutdown {
return nil
}
s.shutdown = true
close(s.shutdownCh)
_ = s.unregister()
if s.ipv4List != nil {
s.ipv4List.Close()
}
if s.ipv6List != nil {
s.ipv6List.Close()
}
s.wg.Wait()
return nil
}
// recv is a long running routine to receive packets from an interface.
func (s *Server) recv(c *net.UDPConn) {
if c == nil {
return
}
buf := make([]byte, 65536)
for {
s.shutdownLock.Lock()
if s.shutdown {
s.shutdownLock.Unlock()
return
}
s.shutdownLock.Unlock()
n, from, err := c.ReadFrom(buf)
if err != nil {
continue
}
if err := s.parsePacket(buf[:n], from); err != nil {
log.Errorf("[ERR] mdns: Failed to handle query: %v", err)
}
}
}
// parsePacket is used to parse an incoming packet.
func (s *Server) parsePacket(packet []byte, from net.Addr) error {
var msg dns.Msg
if err := msg.Unpack(packet); err != nil {
log.Errorf("[ERR] mdns: Failed to unpack packet: %v", err)
return err
}
// TODO: This is a bit of a hack
// We decided to ignore some mDNS answers for the time being
// See: https://tools.ietf.org/html/rfc6762#section-7.2
msg.Truncated = false
return s.handleQuery(&msg, from)
}
// handleQuery is used to handle an incoming query.
func (s *Server) handleQuery(query *dns.Msg, from net.Addr) error {
if query.Opcode != dns.OpcodeQuery {
// "In both multicast query and multicast response messages, the OPCODE MUST
// be zero on transmission (only standard queries are currently supported
// over multicast). Multicast DNS messages received with an OPCODE other
// than zero MUST be silently ignored." Note: OpcodeQuery == 0
return fmt.Errorf("mdns: received query with non-zero Opcode %v: %v", query.Opcode, *query)
}
if query.Rcode != 0 {
// "In both multicast query and multicast response messages, the Response
// Code MUST be zero on transmission. Multicast DNS messages received with
// non-zero Response Codes MUST be silently ignored."
return fmt.Errorf("mdns: received query with non-zero Rcode %v: %v", query.Rcode, *query)
}
// TODO(reddaly): Handle "TC (Truncated) Bit":
// In query messages, if the TC bit is set, it means that additional
// Known-Answer records may be following shortly. A responder SHOULD
// record this fact, and wait for those additional Known-Answer records,
// before deciding whether to respond. If the TC bit is clear, it means
// that the querying host has no additional Known Answers.
if query.Truncated {
return fmt.Errorf("[ERR] mdns: support for DNS requests with high truncated bit not implemented: %v", *query)
}
var unicastAnswer, multicastAnswer []dns.RR
// Handle each question
for _, q := range query.Question {
mrecs, urecs := s.handleQuestion(q)
multicastAnswer = append(multicastAnswer, mrecs...)
unicastAnswer = append(unicastAnswer, urecs...)
}
// See section 18 of RFC 6762 for rules about DNS headers.
resp := func(unicast bool) *dns.Msg {
// 18.1: ID (Query Identifier)
// 0 for multicast response, query.Id for unicast response
id := uint16(0)
if unicast {
id = query.Id
}
var answer []dns.RR
if unicast {
answer = unicastAnswer
} else {
answer = multicastAnswer
}
if len(answer) == 0 {
return nil
}
return &dns.Msg{
MsgHdr: dns.MsgHdr{
Id: id,
// 18.2: QR (Query/Response) Bit - must be set to 1 in response.
Response: true,
// 18.3: OPCODE - must be zero in response (OpcodeQuery == 0)
Opcode: dns.OpcodeQuery,
// 18.4: AA (Authoritative Answer) Bit - must be set to 1
Authoritative: true,
// The following fields must all be set to 0:
// 18.5: TC (TRUNCATED) Bit
// 18.6: RD (Recursion Desired) Bit
// 18.7: RA (Recursion Available) Bit
// 18.8: Z (Zero) Bit
// 18.9: AD (Authentic Data) Bit
// 18.10: CD (Checking Disabled) Bit
// 18.11: RCODE (Response Code)
},
// 18.12 pertains to questions (handled by handleQuestion)
// 18.13 pertains to resource records (handled by handleQuestion)
// 18.14: Name Compression - responses should be compressed (though see
// caveats in the RFC), so set the Compress bit (part of the dns library
// API, not part of the DNS packet) to true.
Compress: true,
Question: query.Question,
Answer: answer,
}
}
if mresp := resp(false); mresp != nil {
if err := s.sendResponse(mresp, from); err != nil {
return fmt.Errorf("mdns: error sending multicast response: %v", err)
}
}
if uresp := resp(true); uresp != nil {
if err := s.sendResponse(uresp, from); err != nil {
return fmt.Errorf("mdns: error sending unicast response: %v", err)
}
}
return nil
}
// handleQuestion is used to handle an incoming question
//
// The response to a question may be transmitted over multicast, unicast, or
// both. The return values are DNS records for each transmission type.
func (s *Server) handleQuestion(q dns.Question) (multicastRecs, unicastRecs []dns.RR) {
records := s.config.Zone.Records(q)
if len(records) == 0 {
return nil, nil
}
// Handle unicast and multicast responses.
// TODO(reddaly): The decision about sending over unicast vs. multicast is not
// yet fully compliant with RFC 6762. For example, the unicast bit should be
// ignored if the records in question are close to TTL expiration. For now,
// we just use the unicast bit to make the decision, as per the spec:
// RFC 6762, section 18.12. Repurposing of Top Bit of qclass in Question
// Section
//
// In the Question Section of a Multicast DNS query, the top bit of the
// qclass field is used to indicate that unicast responses are preferred
// for this particular question. (See Section 5.4.)
if q.Qclass&(1<<15) != 0 {
return nil, records
}
return records, nil
}
func (s *Server) probe() {
defer s.wg.Done()
sd, ok := s.config.Zone.(*MDNSService)
if !ok {
return
}
name := fmt.Sprintf("%s.%s.%s.", sd.Instance, trimDot(sd.Service), trimDot(sd.Domain))
q := new(dns.Msg)
q.SetQuestion(name, dns.TypePTR)
q.RecursionDesired = false
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: name,
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
Ttl: defaultTTL,
},
Priority: 0,
Weight: 0,
Port: uint16(sd.Port),
Target: sd.HostName,
}
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: name,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: defaultTTL,
},
Txt: sd.TXT,
}
q.Ns = []dns.RR{srv, txt}
randomizer := rand.New(rand.NewSource(time.Now().UnixNano()))
for i := 0; i < 3; i++ {
if err := s.SendMulticast(q); err != nil {
log.Errorf("[ERR] mdns: failed to send probe:", err.Error())
}
time.Sleep(time.Duration(randomizer.Intn(250)) * time.Millisecond)
}
resp := new(dns.Msg)
resp.Response = true
// set for query
q.SetQuestion(name, dns.TypeANY)
resp.Answer = append(resp.Answer, s.config.Zone.Records(q.Question[0])...)
// reset
q.SetQuestion(name, dns.TypePTR)
// From RFC6762
// The Multicast DNS responder MUST send at least two unsolicited
// responses, one second apart. To provide increased robustness against
// packet loss, a responder MAY send up to eight unsolicited responses,
// provided that the interval between unsolicited responses increases by
// at least a factor of two with every response sent.
timeout := 1 * time.Second
timer := time.NewTimer(timeout)
for i := 0; i < 3; i++ {
if err := s.SendMulticast(resp); err != nil {
log.Errorf("[ERR] mdns: failed to send announcement:", err.Error())
}
select {
case <-timer.C:
timeout *= 2
timer.Reset(timeout)
case <-s.shutdownCh:
timer.Stop()
return
}
}
}
// SendMulticast us used to send a multicast response packet.
func (s *Server) SendMulticast(msg *dns.Msg) error {
buf, err := msg.Pack()
if err != nil {
return err
}
if s.ipv4List != nil {
_, _ = s.ipv4List.WriteToUDP(buf, ipv4Addr)
}
if s.ipv6List != nil {
_, _ = s.ipv6List.WriteToUDP(buf, ipv6Addr)
}
return nil
}
// sendResponse is used to send a response packet.
func (s *Server) sendResponse(resp *dns.Msg, from net.Addr) error {
// TODO(reddaly): Respect the unicast argument, and allow sending responses
// over multicast.
buf, err := resp.Pack()
if err != nil {
return err
}
// Determine the socket to send from
addr := from.(*net.UDPAddr)
conn := s.ipv4List
backupTarget := net.IPv4zero
if addr.IP.To4() == nil {
conn = s.ipv6List
backupTarget = net.IPv6zero
}
_, err = conn.WriteToUDP(buf, addr)
// If the address we're responding to is this machine then we can also attempt sending on 0.0.0.0
// This covers the case where this machine is using a VPN and certain ports are blocked so the response never gets there
// Sending two responses is OK
if s.config.LocalhostChecking && addr.IP.Equal(s.outboundIP) {
// ignore any errors, this is best efforts
_, _ = conn.WriteToUDP(buf, &net.UDPAddr{IP: backupTarget, Port: addr.Port})
}
return err
}
func (s *Server) unregister() error {
sd, ok := s.config.Zone.(*MDNSService)
if !ok {
return nil
}
atomic.StoreUint32(&sd.TTL, 0)
name := fmt.Sprintf("%s.%s.%s.", sd.Instance, trimDot(sd.Service), trimDot(sd.Domain))
q := new(dns.Msg)
q.SetQuestion(name, dns.TypeANY)
resp := new(dns.Msg)
resp.Response = true
resp.Answer = append(resp.Answer, s.config.Zone.Records(q.Question[0])...)
return s.SendMulticast(resp)
}
func setCustomPort(port int) {
if port != 0 {
if mdnsWildcardAddrIPv4.Port != port {
mdnsWildcardAddrIPv4.Port = port
}
if mdnsWildcardAddrIPv6.Port != port {
mdnsWildcardAddrIPv6.Port = port
}
if ipv4Addr.Port != port {
ipv4Addr.Port = port
}
if ipv6Addr.Port != port {
ipv6Addr.Port = port
}
}
}
func getOutboundIP() net.IP {
conn, err := net.Dial("udp", "8.8.8.8:80")
if err != nil {
// no net connectivity maybe so fallback
return nil
}
defer conn.Close()
localAddr := conn.LocalAddr().(*net.UDPAddr)
return localAddr.IP
}
+61
View File
@@ -0,0 +1,61 @@
package mdns
import (
"testing"
"time"
)
func TestServer_StartStop(t *testing.T) {
s := makeService(t)
serv, err := NewServer(&Config{Zone: s, LocalhostChecking: true})
if err != nil {
t.Fatalf("err: %v", err)
}
defer serv.Shutdown()
}
func TestServer_Lookup(t *testing.T) {
serv, err := NewServer(&Config{Zone: makeServiceWithServiceName(t, "_foobar._tcp"), LocalhostChecking: true})
if err != nil {
t.Fatalf("err: %v", err)
}
defer serv.Shutdown()
entries := make(chan *ServiceEntry, 1)
found := false
doneCh := make(chan struct{})
go func() {
select {
case e := <-entries:
if e.Name != "hostname._foobar._tcp.local." {
t.Errorf("bad: %v", e)
}
if e.Port != 80 {
t.Errorf("bad: %v", e)
}
if e.Info != "Local web server" {
t.Errorf("bad: %v", e)
}
found = true
case <-time.After(80 * time.Millisecond):
t.Errorf("timeout")
}
close(doneCh)
}()
params := &QueryParam{
Service: "_foobar._tcp",
Domain: "local",
Timeout: 50 * time.Millisecond,
Entries: entries,
}
err = Query(params)
if err != nil {
t.Fatalf("err: %v", err)
}
<-doneCh
if !found {
t.Fatalf("record not found")
}
}
+309
View File
@@ -0,0 +1,309 @@
package mdns
import (
"fmt"
"net"
"os"
"strings"
"sync/atomic"
"github.com/miekg/dns"
)
const (
// defaultTTL is the default TTL value in returned DNS records in seconds.
defaultTTL = 120
)
// Zone is the interface used to integrate with the server and
// to serve records dynamically.
type Zone interface {
// Records returns DNS records in response to a DNS question.
Records(q dns.Question) []dns.RR
}
// MDNSService is used to export a named service by implementing a Zone.
type MDNSService struct {
Instance string // Instance name (e.g. "hostService name")
Service string // Service name (e.g. "_http._tcp.")
Domain string // If blank, assumes "local"
HostName string // Host machine DNS name (e.g. "mymachine.net.")
serviceAddr string // Fully qualified service address
instanceAddr string // Fully qualified instance address
enumAddr string // _services._dns-sd._udp.<domain>
IPs []net.IP // IP addresses for the service's host
TXT []string // Service TXT records
Port int // Service Port
TTL uint32
}
// validateFQDN returns an error if the passed string is not a fully qualified
// hdomain name (more specifically, a hostname).
func validateFQDN(s string) error {
if len(s) == 0 {
return fmt.Errorf("FQDN must not be blank")
}
if s[len(s)-1] != '.' {
return fmt.Errorf("FQDN must end in period: %s", s)
}
// TODO(reddaly): Perform full validation.
return nil
}
// NewMDNSService returns a new instance of MDNSService.
//
// If domain, hostName, or ips is set to the zero value, then a default value
// will be inferred from the operating system.
//
// TODO(reddaly): This interface may need to change to account for "unique
// record" conflict rules of the mDNS protocol. Upon startup, the server should
// check to ensure that the instance name does not conflict with other instance
// names, and, if required, select a new name. There may also be conflicting
// hostName A/AAAA records.
func NewMDNSService(instance, service, domain, hostName string, port int, ips []net.IP, txt []string) (*MDNSService, error) {
// Sanity check inputs
if instance == "" {
return nil, fmt.Errorf("missing service instance name")
}
if service == "" {
return nil, fmt.Errorf("missing service name")
}
if port == 0 {
return nil, fmt.Errorf("missing service port")
}
// Set default domain
if domain == "" {
domain = "local."
}
if err := validateFQDN(domain); err != nil {
return nil, fmt.Errorf("domain %q is not a fully-qualified domain name: %v", domain, err)
}
// Get host information if no host is specified.
if hostName == "" {
var err error
hostName, err = os.Hostname()
if err != nil {
return nil, fmt.Errorf("could not determine host: %v", err)
}
hostName = fmt.Sprintf("%s.", hostName)
}
if err := validateFQDN(hostName); err != nil {
return nil, fmt.Errorf("hostName %q is not a fully-qualified domain name: %v", hostName, err)
}
if len(ips) == 0 {
var err error
ips, err = net.LookupIP(trimDot(hostName))
if err != nil {
// Try appending the host domain suffix and lookup again
// (required for Linux-based hosts)
tmpHostName := fmt.Sprintf("%s%s", hostName, domain)
ips, err = net.LookupIP(trimDot(tmpHostName))
if err != nil {
return nil, fmt.Errorf("could not determine host IP addresses for %s", hostName)
}
}
}
for _, ip := range ips {
if ip.To4() == nil && ip.To16() == nil {
return nil, fmt.Errorf("invalid IP address in IPs list: %v", ip)
}
}
return &MDNSService{
Instance: instance,
Service: service,
Domain: domain,
HostName: hostName,
Port: port,
IPs: ips,
TXT: txt,
TTL: defaultTTL,
serviceAddr: fmt.Sprintf("%s.%s.", trimDot(service), trimDot(domain)),
instanceAddr: fmt.Sprintf("%s.%s.%s.", instance, trimDot(service), trimDot(domain)),
enumAddr: fmt.Sprintf("_services._dns-sd._udp.%s.", trimDot(domain)),
}, nil
}
// trimDot is used to trim the dots from the start or end of a string.
func trimDot(s string) string {
return strings.Trim(s, ".")
}
// Records returns DNS records in response to a DNS question.
func (m *MDNSService) Records(q dns.Question) []dns.RR {
switch q.Name {
case m.enumAddr:
return m.serviceEnum(q)
case m.serviceAddr:
return m.serviceRecords(q)
case m.instanceAddr:
return m.instanceRecords(q)
case m.HostName:
if q.Qtype == dns.TypeA || q.Qtype == dns.TypeAAAA {
return m.instanceRecords(q)
}
fallthrough
default:
return nil
}
}
func (m *MDNSService) serviceEnum(q dns.Question) []dns.RR {
switch q.Qtype {
case dns.TypeANY:
fallthrough
case dns.TypePTR:
rr := &dns.PTR{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: atomic.LoadUint32(&m.TTL),
},
Ptr: m.serviceAddr,
}
return []dns.RR{rr}
default:
return nil
}
}
// serviceRecords is called when the query matches the service name.
func (m *MDNSService) serviceRecords(q dns.Question) []dns.RR {
switch q.Qtype {
case dns.TypeANY:
fallthrough
case dns.TypePTR:
// Build a PTR response for the service
rr := &dns.PTR{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypePTR,
Class: dns.ClassINET,
Ttl: atomic.LoadUint32(&m.TTL),
},
Ptr: m.instanceAddr,
}
servRec := []dns.RR{rr}
// Get the instance records
instRecs := m.instanceRecords(dns.Question{
Name: m.instanceAddr,
Qtype: dns.TypeANY,
})
// Return the service record with the instance records
return append(servRec, instRecs...)
default:
return nil
}
}
// serviceRecords is called when the query matches the instance name.
func (m *MDNSService) instanceRecords(q dns.Question) []dns.RR {
switch q.Qtype {
case dns.TypeANY:
// Get the SRV, which includes A and AAAA
recs := m.instanceRecords(dns.Question{
Name: m.instanceAddr,
Qtype: dns.TypeSRV,
})
// Add the TXT record
recs = append(recs, m.instanceRecords(dns.Question{
Name: m.instanceAddr,
Qtype: dns.TypeTXT,
})...)
return recs
case dns.TypeA:
var rr []dns.RR
for _, ip := range m.IPs {
if ip4 := ip.To4(); ip4 != nil {
rr = append(rr, &dns.A{
Hdr: dns.RR_Header{
Name: m.HostName,
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: atomic.LoadUint32(&m.TTL),
},
A: ip4,
})
}
}
return rr
case dns.TypeAAAA:
var rr []dns.RR
for _, ip := range m.IPs {
if ip.To4() != nil {
// TODO(reddaly): IPv4 addresses could be encoded in IPv6 format and
// putinto AAAA records, but the current logic puts ipv4-encodable
// addresses into the A records exclusively. Perhaps this should be
// configurable?
continue
}
if ip16 := ip.To16(); ip16 != nil {
rr = append(rr, &dns.AAAA{
Hdr: dns.RR_Header{
Name: m.HostName,
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: atomic.LoadUint32(&m.TTL),
},
AAAA: ip16,
})
}
}
return rr
case dns.TypeSRV:
// Create the SRV Record
srv := &dns.SRV{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeSRV,
Class: dns.ClassINET,
Ttl: atomic.LoadUint32(&m.TTL),
},
Priority: 10,
Weight: 1,
Port: uint16(m.Port),
Target: m.HostName,
}
recs := []dns.RR{srv}
// Add the A record
recs = append(recs, m.instanceRecords(dns.Question{
Name: m.instanceAddr,
Qtype: dns.TypeA,
})...)
// Add the AAAA record
recs = append(recs, m.instanceRecords(dns.Question{
Name: m.instanceAddr,
Qtype: dns.TypeAAAA,
})...)
return recs
case dns.TypeTXT:
txt := &dns.TXT{
Hdr: dns.RR_Header{
Name: q.Name,
Rrtype: dns.TypeTXT,
Class: dns.ClassINET,
Ttl: atomic.LoadUint32(&m.TTL),
},
Txt: m.TXT,
}
return []dns.RR{txt}
}
return nil
}
+275
View File
@@ -0,0 +1,275 @@
package mdns
import (
"bytes"
"net"
"reflect"
"testing"
"github.com/miekg/dns"
)
func makeService(t *testing.T) *MDNSService {
return makeServiceWithServiceName(t, "_http._tcp")
}
func makeServiceWithServiceName(t *testing.T, service string) *MDNSService {
m, err := NewMDNSService(
"hostname",
service,
"local.",
"testhost.",
80, // port
[]net.IP{net.IP([]byte{192, 168, 0, 42}), net.ParseIP("2620:0:1000:1900:b0c2:d0b2:c411:18bc")},
[]string{"Local web server"}) // TXT
if err != nil {
t.Fatalf("err: %v", err)
}
return m
}
func TestNewMDNSService_BadParams(t *testing.T) {
for _, test := range []struct {
testName string
hostName string
domain string
}{
{
"NewMDNSService should fail when passed hostName that is not a legal fully-qualified domain name",
"hostname", // not legal FQDN - should be "hostname." or "hostname.local.", etc.
"local.", // legal
},
{
"NewMDNSService should fail when passed domain that is not a legal fully-qualified domain name",
"hostname.", // legal
"local", // should be "local."
},
} {
_, err := NewMDNSService(
"instance name",
"_http._tcp",
test.domain,
test.hostName,
80, // port
[]net.IP{net.IP([]byte{192, 168, 0, 42})},
[]string{"Local web server"}) // TXT
if err == nil {
t.Fatalf("%s: error expected, but got none", test.testName)
}
}
}
func TestMDNSService_BadAddr(t *testing.T) {
s := makeService(t)
q := dns.Question{
Name: "random",
Qtype: dns.TypeANY,
}
recs := s.Records(q)
if len(recs) != 0 {
t.Fatalf("bad: %v", recs)
}
}
func TestMDNSService_ServiceAddr(t *testing.T) {
s := makeService(t)
q := dns.Question{
Name: "_http._tcp.local.",
Qtype: dns.TypeANY,
}
recs := s.Records(q)
if got, want := len(recs), 5; got != want {
t.Fatalf("got %d records, want %d: %v", got, want, recs)
}
if ptr, ok := recs[0].(*dns.PTR); !ok {
t.Errorf("recs[0] should be PTR record, got: %v, all records: %v", recs[0], recs)
} else if got, want := ptr.Ptr, "hostname._http._tcp.local."; got != want {
t.Fatalf("bad PTR record %v: got %v, want %v", ptr, got, want)
}
if _, ok := recs[1].(*dns.SRV); !ok {
t.Errorf("recs[1] should be SRV record, got: %v, all reccords: %v", recs[1], recs)
}
if _, ok := recs[2].(*dns.A); !ok {
t.Errorf("recs[2] should be A record, got: %v, all records: %v", recs[2], recs)
}
if _, ok := recs[3].(*dns.AAAA); !ok {
t.Errorf("recs[3] should be AAAA record, got: %v, all records: %v", recs[3], recs)
}
if _, ok := recs[4].(*dns.TXT); !ok {
t.Errorf("recs[4] should be TXT record, got: %v, all records: %v", recs[4], recs)
}
q.Qtype = dns.TypePTR
if recs2 := s.Records(q); !reflect.DeepEqual(recs, recs2) {
t.Fatalf("PTR question should return same result as ANY question: ANY => %v, PTR => %v", recs, recs2)
}
}
func TestMDNSService_InstanceAddr_ANY(t *testing.T) {
s := makeService(t)
q := dns.Question{
Name: "hostname._http._tcp.local.",
Qtype: dns.TypeANY,
}
recs := s.Records(q)
if len(recs) != 4 {
t.Fatalf("bad: %v", recs)
}
if _, ok := recs[0].(*dns.SRV); !ok {
t.Fatalf("bad: %v", recs[0])
}
if _, ok := recs[1].(*dns.A); !ok {
t.Fatalf("bad: %v", recs[1])
}
if _, ok := recs[2].(*dns.AAAA); !ok {
t.Fatalf("bad: %v", recs[2])
}
if _, ok := recs[3].(*dns.TXT); !ok {
t.Fatalf("bad: %v", recs[3])
}
}
func TestMDNSService_InstanceAddr_SRV(t *testing.T) {
s := makeService(t)
q := dns.Question{
Name: "hostname._http._tcp.local.",
Qtype: dns.TypeSRV,
}
recs := s.Records(q)
if len(recs) != 3 {
t.Fatalf("bad: %v", recs)
}
srv, ok := recs[0].(*dns.SRV)
if !ok {
t.Fatalf("bad: %v", recs[0])
}
if _, ok := recs[1].(*dns.A); !ok {
t.Fatalf("bad: %v", recs[1])
}
if _, ok := recs[2].(*dns.AAAA); !ok {
t.Fatalf("bad: %v", recs[2])
}
if srv.Port != uint16(s.Port) {
t.Fatalf("bad: %v", recs[0])
}
}
func TestMDNSService_InstanceAddr_A(t *testing.T) {
s := makeService(t)
q := dns.Question{
Name: "hostname._http._tcp.local.",
Qtype: dns.TypeA,
}
recs := s.Records(q)
if len(recs) != 1 {
t.Fatalf("bad: %v", recs)
}
a, ok := recs[0].(*dns.A)
if !ok {
t.Fatalf("bad: %v", recs[0])
}
if !bytes.Equal(a.A, []byte{192, 168, 0, 42}) {
t.Fatalf("bad: %v", recs[0])
}
}
func TestMDNSService_InstanceAddr_AAAA(t *testing.T) {
s := makeService(t)
q := dns.Question{
Name: "hostname._http._tcp.local.",
Qtype: dns.TypeAAAA,
}
recs := s.Records(q)
if len(recs) != 1 {
t.Fatalf("bad: %v", recs)
}
a4, ok := recs[0].(*dns.AAAA)
if !ok {
t.Fatalf("bad: %v", recs[0])
}
ip6 := net.ParseIP("2620:0:1000:1900:b0c2:d0b2:c411:18bc")
if got := len(ip6); got != net.IPv6len {
t.Fatalf("test IP failed to parse (len = %d, want %d)", got, net.IPv6len)
}
if !a4.AAAA.Equal(ip6) {
t.Fatalf("bad: %v", recs[0])
}
}
func TestMDNSService_InstanceAddr_TXT(t *testing.T) {
s := makeService(t)
q := dns.Question{
Name: "hostname._http._tcp.local.",
Qtype: dns.TypeTXT,
}
recs := s.Records(q)
if len(recs) != 1 {
t.Fatalf("bad: %v", recs)
}
txt, ok := recs[0].(*dns.TXT)
if !ok {
t.Fatalf("bad: %v", recs[0])
}
if got, want := txt.Txt, s.TXT; !reflect.DeepEqual(got, want) {
t.Fatalf("TXT record mismatch for %v: got %v, want %v", recs[0], got, want)
}
}
func TestMDNSService_HostNameQuery(t *testing.T) {
s := makeService(t)
for _, test := range []struct {
q dns.Question
want []dns.RR
}{
{
dns.Question{Name: "testhost.", Qtype: dns.TypeA},
[]dns.RR{&dns.A{
Hdr: dns.RR_Header{
Name: "testhost.",
Rrtype: dns.TypeA,
Class: dns.ClassINET,
Ttl: 120,
},
A: net.IP([]byte{192, 168, 0, 42}),
}},
},
{
dns.Question{Name: "testhost.", Qtype: dns.TypeAAAA},
[]dns.RR{&dns.AAAA{
Hdr: dns.RR_Header{
Name: "testhost.",
Rrtype: dns.TypeAAAA,
Class: dns.ClassINET,
Ttl: 120,
},
AAAA: net.ParseIP("2620:0:1000:1900:b0c2:d0b2:c411:18bc"),
}},
},
} {
if got := s.Records(test.q); !reflect.DeepEqual(got, test.want) {
t.Errorf("hostname query failed: s.Records(%v) = %v, want %v", test.q, got, test.want)
}
}
}
func TestMDNSService_serviceEnum_PTR(t *testing.T) {
s := makeService(t)
q := dns.Question{
Name: "_services._dns-sd._udp.local.",
Qtype: dns.TypePTR,
}
recs := s.Records(q)
if len(recs) != 1 {
t.Fatalf("bad: %v", recs)
}
if ptr, ok := recs[0].(*dns.PTR); !ok {
t.Errorf("recs[0] should be PTR record, got: %v, all records: %v", recs[0], recs)
} else if got, want := ptr.Ptr, "_http._tcp.local."; got != want {
t.Fatalf("bad PTR record %v: got %v, want %v", ptr, got, want)
}
}
+119
View File
@@ -0,0 +1,119 @@
package net
import (
"errors"
"fmt"
"net"
"os"
"strconv"
"strings"
)
// HostPort format addr and port suitable for dial.
func HostPort(addr string, port interface{}) string {
host := addr
if strings.Count(addr, ":") > 0 {
host = fmt.Sprintf("[%s]", addr)
}
// when port is blank or 0, host is a queue name
if v, ok := port.(string); ok && v == "" {
return host
} else if v, ok := port.(int); ok && v == 0 && net.ParseIP(host) == nil {
return host
}
return fmt.Sprintf("%s:%v", host, port)
}
// Listen takes addr:portmin-portmax and binds to the first available port
// Example: Listen("localhost:5000-6000", fn).
func Listen(addr string, fn func(string) (net.Listener, error)) (net.Listener, error) {
if strings.Count(addr, ":") == 1 && strings.Count(addr, "-") == 0 {
return fn(addr)
}
// host:port || host:min-max
host, ports, err := net.SplitHostPort(addr)
if err != nil {
return nil, err
}
// try to extract port range
prange := strings.Split(ports, "-")
// single port
if len(prange) < 2 {
return fn(addr)
}
// we have a port range
// extract min port
min, err := strconv.Atoi(prange[0])
if err != nil {
return nil, errors.New("unable to extract port range")
}
// extract max port
max, err := strconv.Atoi(prange[1])
if err != nil {
return nil, errors.New("unable to extract port range")
}
// range the ports
for port := min; port <= max; port++ {
// try bind to host:port
ln, err := fn(HostPort(host, port))
if err == nil {
return ln, nil
}
// hit max port
if port == max {
return nil, err
}
}
// why are we here?
return nil, fmt.Errorf("unable to bind to %s", addr)
}
// Proxy returns the proxy and the address if it exits.
func Proxy(service string, address []string) (string, []string, bool) {
var hasProxy bool
// get proxy. we parse out address if present
if prx := os.Getenv("MICRO_PROXY"); len(prx) > 0 {
// default name
if prx == "service" {
prx = "go.micro.proxy"
address = nil
}
// check if its an address
if v := strings.Split(prx, ":"); len(v) > 1 {
address = []string{prx}
}
service = prx
hasProxy = true
return service, address, hasProxy
}
if prx := os.Getenv("MICRO_NETWORK"); len(prx) > 0 {
// default name
if prx == "service" {
prx = "go.micro.network"
}
service = prx
hasProxy = true
}
if prx := os.Getenv("MICRO_NETWORK_ADDRESS"); len(prx) > 0 {
address = []string{prx}
hasProxy = true
}
return service, address, hasProxy
}
+61
View File
@@ -0,0 +1,61 @@
package net
import (
"net"
"os"
"testing"
)
func TestListen(t *testing.T) {
fn := func(addr string) (net.Listener, error) {
return net.Listen("tcp", addr)
}
// try to create a number of listeners
for i := 0; i < 10; i++ {
l, err := Listen("localhost:10000-11000", fn)
if err != nil {
t.Fatal(err)
}
defer l.Close()
}
// TODO nats case test
// natsAddr := "_INBOX.bID2CMRvlNp0vt4tgNBHWf"
// Expect addr DO NOT has extra ":" at the end!
}
// TestProxyEnv checks whether we have proxy/network settings in env.
func TestProxyEnv(t *testing.T) {
service := "foo"
address := []string{"bar"}
s, a, ok := Proxy(service, address)
if ok {
t.Fatal("Should not have proxy", s, a, ok)
}
test := func(key, val, expectSrv, expectAddr string) {
// set env
os.Setenv(key, val)
s, a, ok := Proxy(service, address)
if !ok {
t.Fatal("Expected proxy")
}
if len(expectSrv) > 0 && s != expectSrv {
t.Fatal("Expected proxy service", expectSrv, "got", s)
}
if len(expectAddr) > 0 {
if len(a) == 0 || a[0] != expectAddr {
t.Fatal("Expected proxy address", expectAddr, "got", a)
}
}
os.Unsetenv(key)
}
test("MICRO_PROXY", "service", "go.micro.proxy", "")
test("MICRO_NETWORK", "service", "go.micro.network", "")
test("MICRO_NETWORK_ADDRESS", "10.0.0.1:8081", "", "10.0.0.1:8081")
}
+159
View File
@@ -0,0 +1,159 @@
package pool
import (
"errors"
"sync"
"time"
"github.com/google/uuid"
"go-micro.dev/v6/transport"
)
type pool struct {
tr transport.Transport
closeTimeout time.Duration
conns map[string][]*poolConn
mu sync.Mutex
size int
ttl time.Duration
}
type poolConn struct {
transport.Client
closeTimeout time.Duration
created time.Time
id string
}
func newPool(options Options) *pool {
return &pool{
size: options.Size,
tr: options.Transport,
ttl: options.TTL,
closeTimeout: options.CloseTimeout,
conns: make(map[string][]*poolConn),
}
}
func (p *pool) Close() error {
p.mu.Lock()
defer p.mu.Unlock()
var err error
for k, c := range p.conns {
for _, conn := range c {
if nerr := conn.close(); nerr != nil {
err = nerr
}
}
delete(p.conns, k)
}
return err
}
// NoOp the Close since we manage it.
func (p *poolConn) Close() error {
return nil
}
func (p *poolConn) Id() string {
return p.id
}
func (p *poolConn) Created() time.Time {
return p.created
}
func (p *pool) Get(addr string, opts ...transport.DialOption) (Conn, error) {
p.mu.Lock()
conns := p.conns[addr]
// While we have conns check age and then return one
// otherwise we'll create a new conn
for len(conns) > 0 {
conn := conns[len(conns)-1]
conns = conns[:len(conns)-1]
p.conns[addr] = conns
// If conn is old kill it and move on
if d := time.Since(conn.Created()); d > p.ttl {
if err := conn.close(); err != nil {
p.mu.Unlock()
c, errConn := p.newConn(addr, opts)
if errConn != nil {
return nil, errConn
}
return c, err
}
continue
}
// We got a good conn, lets unlock and return it
p.mu.Unlock()
return conn, nil
}
p.mu.Unlock()
return p.newConn(addr, opts)
}
func (p *pool) newConn(addr string, opts []transport.DialOption) (Conn, error) {
// create new conn
c, err := p.tr.Dial(addr, opts...)
if err != nil {
return nil, err
}
return &poolConn{
Client: c,
id: uuid.New().String(),
closeTimeout: p.closeTimeout,
created: time.Now(),
}, nil
}
func (p *pool) Release(conn Conn, err error) error {
// don't store the conn if it has errored
if err != nil {
return conn.(*poolConn).close()
}
// otherwise put it back for reuse
p.mu.Lock()
defer p.mu.Unlock()
conns := p.conns[conn.Remote()]
if len(conns) >= p.size {
return conn.(*poolConn).close()
}
p.conns[conn.Remote()] = append(conns, conn.(*poolConn))
return nil
}
func (p *poolConn) close() error {
ch := make(chan error)
go func() {
defer close(ch)
ch <- p.Client.Close()
}()
t := time.NewTimer(p.closeTimeout)
var err error
select {
case <-t.C:
err = errors.New("unable to close in time")
case err = <-ch:
t.Stop()
}
return err
}
+88
View File
@@ -0,0 +1,88 @@
package pool
import (
"testing"
"time"
"go-micro.dev/v6/transport"
)
func testPool(t *testing.T, size int, ttl time.Duration) {
// mock transport
tr := transport.NewMemoryTransport()
options := Options{
TTL: ttl,
Size: size,
Transport: tr,
}
// zero pool
p := newPool(options)
// listen
l, err := tr.Listen(":0")
if err != nil {
t.Fatal(err)
}
defer l.Close()
// accept loop
go func() {
for {
if err := l.Accept(func(s transport.Socket) {
for {
var msg transport.Message
if err := s.Recv(&msg); err != nil {
return
}
if err := s.Send(&msg); err != nil {
return
}
}
}); err != nil {
return
}
}
}()
for i := 0; i < 10; i++ {
// get a conn
c, err := p.Get(l.Addr())
if err != nil {
t.Fatal(err)
}
msg := &transport.Message{
Body: []byte(`hello world`),
}
if err := c.Send(msg); err != nil {
t.Fatal(err)
}
var rcv transport.Message
if err := c.Recv(&rcv); err != nil {
t.Fatal(err)
}
if string(rcv.Body) != string(msg.Body) {
t.Fatalf("got %v, expected %v", rcv.Body, msg.Body)
}
// release the conn
p.Release(c, nil)
p.mu.Lock()
if i := len(p.conns[l.Addr()]); i > size {
p.mu.Unlock()
t.Fatalf("pool size %d is greater than expected %d", i, size)
}
p.mu.Unlock()
}
}
func TestClientPool(t *testing.T) {
testPool(t, 0, time.Minute)
testPool(t, 2, time.Minute)
}
+40
View File
@@ -0,0 +1,40 @@
package pool
import (
"time"
"go-micro.dev/v6/transport"
)
type Options struct {
Transport transport.Transport
TTL time.Duration
CloseTimeout time.Duration
Size int
}
type Option func(*Options)
func Size(i int) Option {
return func(o *Options) {
o.Size = i
}
}
func Transport(t transport.Transport) Option {
return func(o *Options) {
o.Transport = t
}
}
func TTL(t time.Duration) Option {
return func(o *Options) {
o.TTL = t
}
}
func CloseTimeout(t time.Duration) Option {
return func(o *Options) {
o.CloseTimeout = t
}
}
+38
View File
@@ -0,0 +1,38 @@
// Package pool is a connection pool
package pool
import (
"time"
"go-micro.dev/v6/transport"
)
// Pool is an interface for connection pooling.
type Pool interface {
// Close the pool
Close() error
// Get a connection
Get(addr string, opts ...transport.DialOption) (Conn, error)
// Release the connection
Release(c Conn, status error) error
}
// Conn interface represents a pool connection.
type Conn interface {
// unique id of connection
Id() string
// time it was created
Created() time.Time
// embedded connection
transport.Client
}
// NewPool will return a new pool object.
func NewPool(opts ...Option) Pool {
var options Options
for _, o := range opts {
o(&options)
}
return newPool(options)
}
+148
View File
@@ -0,0 +1,148 @@
package registry
import (
"go-micro.dev/v6/registry"
)
func addNodes(old, neu []*registry.Node) []*registry.Node {
nodes := make([]*registry.Node, len(neu))
// add all new nodes
for i, n := range neu {
node := *n
nodes[i] = &node
}
// look at old nodes
for _, o := range old {
var exists bool
// check against new nodes
for _, n := range nodes {
// ids match then skip
if o.Id == n.Id {
exists = true
break
}
}
// keep old node
if !exists {
node := *o
nodes = append(nodes, &node)
}
}
return nodes
}
func delNodes(old, del []*registry.Node) []*registry.Node {
var nodes []*registry.Node
for _, o := range old {
var rem bool
for _, n := range del {
if o.Id == n.Id {
rem = true
break
}
}
if !rem {
nodes = append(nodes, o)
}
}
return nodes
}
// CopyService make a copy of service.
func CopyService(service *registry.Service) *registry.Service {
// copy service
s := new(registry.Service)
*s = *service
// copy nodes
nodes := make([]*registry.Node, len(service.Nodes))
for j, node := range service.Nodes {
n := new(registry.Node)
*n = *node
nodes[j] = n
}
s.Nodes = nodes
// copy endpoints
eps := make([]*registry.Endpoint, len(service.Endpoints))
for j, ep := range service.Endpoints {
e := new(registry.Endpoint)
*e = *ep
eps[j] = e
}
s.Endpoints = eps
return s
}
// Copy makes a copy of services.
func Copy(current []*registry.Service) []*registry.Service {
services := make([]*registry.Service, len(current))
for i, service := range current {
services[i] = CopyService(service)
}
return services
}
// Merge merges two lists of services and returns a new copy.
func Merge(olist []*registry.Service, nlist []*registry.Service) []*registry.Service {
var srv []*registry.Service
for _, n := range nlist {
var seen bool
for _, o := range olist {
if o.Version == n.Version {
sp := new(registry.Service)
// make copy
*sp = *o
// set nodes
sp.Nodes = addNodes(o.Nodes, n.Nodes)
// mark as seen
seen = true
srv = append(srv, sp)
break
} else {
sp := new(registry.Service)
// make copy
*sp = *o
srv = append(srv, sp)
}
}
if !seen {
srv = append(srv, Copy([]*registry.Service{n})...)
}
}
return srv
}
// Remove removes services and returns a new copy.
func Remove(old, del []*registry.Service) []*registry.Service {
var services []*registry.Service
for _, o := range old {
srv := new(registry.Service)
*srv = *o
var rem bool
for _, s := range del {
if srv.Version == s.Version {
srv.Nodes = delNodes(srv.Nodes, s.Nodes)
if len(srv.Nodes) == 0 {
rem = true
}
}
}
if !rem {
services = append(services, srv)
}
}
return services
}
+78
View File
@@ -0,0 +1,78 @@
package registry
import (
"os"
"testing"
"go-micro.dev/v6/registry"
)
func TestRemove(t *testing.T) {
services := []*registry.Service{
{
Name: "foo",
Version: "1.0.0",
Nodes: []*registry.Node{
{
Id: "foo-123",
Address: "localhost:9999",
},
},
},
{
Name: "foo",
Version: "1.0.0",
Nodes: []*registry.Node{
{
Id: "foo-123",
Address: "localhost:6666",
},
},
},
}
servs := Remove([]*registry.Service{services[0]}, []*registry.Service{services[1]})
if i := len(servs); i > 0 {
t.Errorf("Expected 0 nodes, got %d: %+v", i, servs)
}
if len(os.Getenv("IN_TRAVIS_CI")) == 0 {
t.Logf("Services %+v", servs)
}
}
func TestRemoveNodes(t *testing.T) {
services := []*registry.Service{
{
Name: "foo",
Version: "1.0.0",
Nodes: []*registry.Node{
{
Id: "foo-123",
Address: "localhost:9999",
},
{
Id: "foo-321",
Address: "localhost:6666",
},
},
},
{
Name: "foo",
Version: "1.0.0",
Nodes: []*registry.Node{
{
Id: "foo-123",
Address: "localhost:6666",
},
},
},
}
nodes := delNodes(services[0].Nodes, services[1].Nodes)
if i := len(nodes); i != 1 {
t.Errorf("Expected only 1 node, got %d: %+v", i, nodes)
}
if len(os.Getenv("IN_TRAVIS_CI")) == 0 {
t.Logf("Nodes %+v", nodes)
}
}
+139
View File
@@ -0,0 +1,139 @@
// Package ring provides a simple ring buffer for storing local data
package ring
import (
"sync"
"time"
"github.com/google/uuid"
)
// Buffer is ring buffer.
type Buffer struct {
streams map[string]*Stream
vals []*Entry
size int
sync.RWMutex
}
// Entry is ring buffer data entry.
type Entry struct {
Value interface{}
Timestamp time.Time
}
// Stream is used to stream the buffer.
type Stream struct {
// Buffered entries
Entries chan *Entry
// Stop channel
Stop chan bool
// Id of the stream
Id string
}
// Put adds a new value to ring buffer.
func (b *Buffer) Put(v interface{}) {
b.Lock()
defer b.Unlock()
// append to values
entry := &Entry{
Value: v,
Timestamp: time.Now(),
}
b.vals = append(b.vals, entry)
// trim if bigger than size required
if len(b.vals) > b.size {
b.vals = b.vals[1:]
}
// send to every stream
for _, stream := range b.streams {
select {
case <-stream.Stop:
delete(b.streams, stream.Id)
close(stream.Entries)
case stream.Entries <- entry:
}
}
}
// Get returns the last n entries.
func (b *Buffer) Get(n int) []*Entry {
b.RLock()
defer b.RUnlock()
// reset any invalid values
if n > len(b.vals) || n < 0 {
n = len(b.vals)
}
// create a delta
delta := len(b.vals) - n
// return the delta set
return b.vals[delta:]
}
// Return the entries since a specific time.
func (b *Buffer) Since(t time.Time) []*Entry {
b.RLock()
defer b.RUnlock()
// return all the values
if t.IsZero() {
return b.vals
}
// if its in the future return nothing
if time.Since(t).Seconds() < 0.0 {
return nil
}
for i, v := range b.vals {
// find the starting point
d := v.Timestamp.Sub(t)
// return the values
if d.Seconds() > 0.0 {
return b.vals[i:]
}
}
return nil
}
// Stream logs from the buffer
// Close the channel when you want to stop.
func (b *Buffer) Stream() (<-chan *Entry, chan bool) {
b.Lock()
defer b.Unlock()
entries := make(chan *Entry, 128)
id := uuid.New().String()
stop := make(chan bool)
b.streams[id] = &Stream{
Id: id,
Entries: entries,
Stop: stop,
}
return entries, stop
}
// Size returns the size of the ring buffer.
func (b *Buffer) Size() int {
return b.size
}
// New returns a new buffer of the given size.
func New(i int) *Buffer {
return &Buffer{
size: i,
streams: make(map[string]*Stream),
}
}
+79
View File
@@ -0,0 +1,79 @@
package ring
import (
"testing"
"time"
)
func TestBuffer(t *testing.T) {
b := New(10)
// test one value
b.Put("foo")
v := b.Get(1)
if val := v[0].Value.(string); val != "foo" {
t.Fatalf("expected foo got %v", val)
}
b = New(10)
// test 10 values
for i := 0; i < 10; i++ {
b.Put(i)
}
d := time.Now()
v = b.Get(10)
for i := 0; i < 10; i++ {
val := v[i].Value.(int)
if val != i {
t.Fatalf("expected %d got %d", i, val)
}
}
// test more values
for i := 0; i < 10; i++ {
v := i * 2
b.Put(v)
}
v = b.Get(10)
for i := 0; i < 10; i++ {
val := v[i].Value.(int)
expect := i * 2
if val != expect {
t.Fatalf("expected %d got %d", expect, val)
}
}
// sleep 100 ms
time.Sleep(time.Millisecond * 100)
// assume we'll get everything
v = b.Since(d)
if len(v) != 10 {
t.Fatalf("expected 10 entries but got %d", len(v))
}
// write 1 more entry
d = time.Now()
b.Put(100)
// sleep 100 ms
time.Sleep(time.Millisecond * 100)
v = b.Since(d)
if len(v) != 1 {
t.Fatalf("expected 1 entries but got %d", len(v))
}
if v[0].Value.(int) != 100 {
t.Fatalf("expected value 100 got %v", v[0])
}
}
+13
View File
@@ -0,0 +1,13 @@
package signal
import (
"os"
"syscall"
)
// ShutDownSingals returns all the signals that are being watched for to shut down services.
func Shutdown() []os.Signal {
return []os.Signal{
syscall.SIGTERM, syscall.SIGINT, syscall.SIGQUIT, syscall.SIGKILL,
}
}
+62
View File
@@ -0,0 +1,62 @@
package socket
import (
"sync"
)
type Pool struct {
pool map[string]*Socket
sync.RWMutex
}
func (p *Pool) Get(id string) (*Socket, bool) {
// attempt to get existing socket
p.RLock()
socket, ok := p.pool[id]
if ok {
p.RUnlock()
return socket, ok
}
p.RUnlock()
// save socket
p.Lock()
defer p.Unlock()
// double checked locking
socket, ok = p.pool[id]
if ok {
return socket, ok
}
// create new socket
socket = New(id)
p.pool[id] = socket
// return socket
return socket, false
}
func (p *Pool) Release(s *Socket) {
p.Lock()
defer p.Unlock()
// close the socket
s.Close()
delete(p.pool, s.id)
}
// Close the pool and delete all the sockets.
func (p *Pool) Close() {
p.Lock()
defer p.Unlock()
for id, sock := range p.pool {
sock.Close()
delete(p.pool, id)
}
}
// NewPool returns a new socket pool.
func NewPool() *Pool {
return &Pool{
pool: make(map[string]*Socket),
}
}
+117
View File
@@ -0,0 +1,117 @@
// Package socket provides a pseudo socket
package socket
import (
"io"
"go-micro.dev/v6/transport"
)
// Socket is our pseudo socket for transport.Socket.
type Socket struct {
// closed
closed chan bool
// send chan
send chan *transport.Message
// recv chan
recv chan *transport.Message
id string
// remote addr
remote string
// local addr
local string
}
func (s *Socket) SetLocal(l string) {
s.local = l
}
func (s *Socket) SetRemote(r string) {
s.remote = r
}
// Accept passes a message to the socket which will be processed by the call to Recv.
func (s *Socket) Accept(m *transport.Message) error {
select {
case s.recv <- m:
return nil
case <-s.closed:
return io.EOF
}
}
// Process takes the next message off the send queue created by a call to Send.
func (s *Socket) Process(m *transport.Message) error {
select {
case msg := <-s.send:
*m = *msg
case <-s.closed:
// see if we need to drain
select {
case msg := <-s.send:
*m = *msg
return nil
default:
return io.EOF
}
}
return nil
}
func (s *Socket) Remote() string {
return s.remote
}
func (s *Socket) Local() string {
return s.local
}
func (s *Socket) Send(m *transport.Message) error {
// send a message
select {
case s.send <- m:
case <-s.closed:
return io.EOF
}
return nil
}
func (s *Socket) Recv(m *transport.Message) error {
// receive a message
select {
case msg := <-s.recv:
// set message
*m = *msg
case <-s.closed:
return io.EOF
}
// return nil
return nil
}
// Close closes the socket.
func (s *Socket) Close() error {
select {
case <-s.closed:
// no op
default:
close(s.closed)
}
return nil
}
// New returns a new pseudo socket which can be used in the place of a transport socket.
// Messages are sent to the socket via Accept and receives from the socket via Process.
// SetLocal/SetRemote should be called before using the socket.
func New(id string) *Socket {
return &Socket{
id: id,
closed: make(chan bool),
local: "local",
remote: "remote",
send: make(chan *transport.Message, 128),
recv: make(chan *transport.Message, 128),
}
}
+58
View File
@@ -0,0 +1,58 @@
package test
import (
"go-micro.dev/v6/registry"
)
var (
// Data is a set of mock registry data.
Data = map[string][]*registry.Service{
"foo": {
{
Name: "foo",
Version: "1.0.0",
Nodes: []*registry.Node{
{
Id: "foo-1.0.0-123",
Address: "localhost:9999",
},
{
Id: "foo-1.0.0-321",
Address: "localhost:9999",
},
},
},
{
Name: "foo",
Version: "1.0.1",
Nodes: []*registry.Node{
{
Id: "foo-1.0.1-321",
Address: "localhost:6666",
},
},
},
{
Name: "foo",
Version: "1.0.3",
Nodes: []*registry.Node{
{
Id: "foo-1.0.3-345",
Address: "localhost:8888",
},
},
},
},
}
)
// EmptyChannel will empty out a error channel by checking if an error is
// present, and if so return the error.
func EmptyChannel(c chan error) error {
select {
case err := <-c:
return err
default:
return nil
}
}
+123
View File
@@ -0,0 +1,123 @@
// Package tls provides TLS utilities for go-micro.
package tls
import (
"bytes"
"crypto/ecdsa"
"crypto/elliptic"
"crypto/rand"
"crypto/tls"
"crypto/x509"
"crypto/x509/pkix"
"encoding/pem"
"math/big"
"net"
"os"
"time"
)
// Config returns the default TLS config.
//
// As of v6, certificate verification is ON by default (secure by default).
// This is the safe choice now that an agent — not just a human on a
// trusted network — can reach an endpoint.
//
// For development against self-signed certificates, set
// MICRO_TLS_INSECURE=true to skip verification, or call InsecureConfig()
// directly. (In v5 the default was the reverse: insecure unless
// MICRO_TLS_SECURE=true was set. That env var is no longer needed.)
func Config() *tls.Config {
// Opt out of verification for local/dev against self-signed certs.
if os.Getenv("MICRO_TLS_INSECURE") == "true" {
return &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
}
}
// Secure by default.
return &tls.Config{
InsecureSkipVerify: false,
MinVersion: tls.VersionTLS12,
}
}
// SecureConfig returns a TLS config with certificate verification enabled.
// Use this when you have proper CA-signed certificates.
func SecureConfig() *tls.Config {
return &tls.Config{
InsecureSkipVerify: false,
MinVersion: tls.VersionTLS12,
}
}
// InsecureConfig returns a TLS config with certificate verification disabled.
// WARNING: Only use for development/testing.
func InsecureConfig() *tls.Config {
return &tls.Config{
InsecureSkipVerify: true,
MinVersion: tls.VersionTLS12,
}
}
// Certificate generates a self-signed certificate for the given hosts.
// Note: These certs are for development only. For production, use proper
// CA-signed certificates or a service mesh.
func Certificate(host ...string) (tls.Certificate, error) {
priv, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader)
if err != nil {
return tls.Certificate{}, err
}
notBefore := time.Now()
notAfter := notBefore.Add(time.Hour * 24 * 365)
serialNumberLimit := new(big.Int).Lsh(big.NewInt(1), 128)
serialNumber, err := rand.Int(rand.Reader, serialNumberLimit)
if err != nil {
return tls.Certificate{}, err
}
template := x509.Certificate{
SerialNumber: serialNumber,
Subject: pkix.Name{
Organization: []string{"Micro"},
},
NotBefore: notBefore,
NotAfter: notAfter,
KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature,
ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth},
BasicConstraintsValid: true,
}
for _, h := range host {
if ip := net.ParseIP(h); ip != nil {
template.IPAddresses = append(template.IPAddresses, ip)
} else {
template.DNSNames = append(template.DNSNames, h)
}
}
template.IsCA = true
template.KeyUsage |= x509.KeyUsageCertSign
derBytes, err := x509.CreateCertificate(rand.Reader, &template, &template, &priv.PublicKey, priv)
if err != nil {
return tls.Certificate{}, err
}
// create public key
certOut := bytes.NewBuffer(nil)
_ = pem.Encode(certOut, &pem.Block{Type: "CERTIFICATE", Bytes: derBytes})
// create private key
keyOut := bytes.NewBuffer(nil)
b, err := x509.MarshalECPrivateKey(priv)
if err != nil {
return tls.Certificate{}, err
}
_ = pem.Encode(keyOut, &pem.Block{Type: "EC PRIVATE KEY", Bytes: b})
return tls.X509KeyPair(certOut.Bytes(), keyOut.Bytes())
}
+103
View File
@@ -0,0 +1,103 @@
package tls
import (
"os"
"testing"
)
func TestConfig(t *testing.T) {
tests := []struct {
name string
envVar string
envValue string
wantInsecure bool
description string
}{
{
name: "secure_by_default",
envVar: "",
envValue: "",
wantInsecure: false,
description: "v6: certificate verification is on by default",
},
{
name: "insecure_opt_out",
envVar: "MICRO_TLS_INSECURE",
envValue: "true",
wantInsecure: true,
description: "MICRO_TLS_INSECURE=true skips verification (dev/self-signed)",
},
{
name: "insecure_disabled_stays_secure",
envVar: "MICRO_TLS_INSECURE",
envValue: "false",
wantInsecure: false,
description: "MICRO_TLS_INSECURE=false stays secure",
},
}
for _, tt := range tests {
t.Run(tt.name, func(t *testing.T) {
// Clean up environment
os.Unsetenv("MICRO_TLS_SECURE")
os.Unsetenv("MICRO_TLS_INSECURE")
// Suppress warning in tests
os.Setenv("IN_TRAVIS_CI", "yes")
defer os.Unsetenv("IN_TRAVIS_CI")
// Set environment variable if specified
if tt.envVar != "" {
os.Setenv(tt.envVar, tt.envValue)
defer os.Unsetenv(tt.envVar)
}
config := Config()
if config == nil {
t.Fatal("Config() returned nil")
}
if config.InsecureSkipVerify != tt.wantInsecure {
t.Errorf("%s: InsecureSkipVerify = %v, want %v",
tt.description, config.InsecureSkipVerify, tt.wantInsecure)
}
// Verify MinVersion is set correctly
if config.MinVersion == 0 {
t.Error("MinVersion should be set")
}
})
}
}
func TestSecureConfig(t *testing.T) {
config := SecureConfig()
if config == nil {
t.Fatal("SecureConfig() returned nil")
}
if config.InsecureSkipVerify {
t.Error("SecureConfig should have InsecureSkipVerify set to false")
}
if config.MinVersion == 0 {
t.Error("MinVersion should be set")
}
}
func TestInsecureConfig(t *testing.T) {
config := InsecureConfig()
if config == nil {
t.Fatal("InsecureConfig() returned nil")
}
if !config.InsecureSkipVerify {
t.Error("InsecureConfig should have InsecureSkipVerify set to true")
}
if config.MinVersion == 0 {
t.Error("MinVersion should be set")
}
}
+173
View File
@@ -0,0 +1,173 @@
package wrapper
import (
"context"
"strings"
"go-micro.dev/v6/auth"
"go-micro.dev/v6/client"
"go-micro.dev/v6/debug/stats"
"go-micro.dev/v6/debug/trace"
"go-micro.dev/v6/metadata"
"go-micro.dev/v6/server"
"go-micro.dev/v6/transport/headers"
)
type fromServiceWrapper struct {
client.Client
// headers to inject
headers metadata.Metadata
}
func (f *fromServiceWrapper) setHeaders(ctx context.Context) context.Context {
// don't overwrite keys
return metadata.MergeContext(ctx, f.headers, false)
}
func (f *fromServiceWrapper) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
ctx = f.setHeaders(ctx)
return f.Client.Call(ctx, req, rsp, opts...)
}
func (f *fromServiceWrapper) Stream(ctx context.Context, req client.Request, opts ...client.CallOption) (client.Stream, error) {
ctx = f.setHeaders(ctx)
return f.Client.Stream(ctx, req, opts...)
}
func (f *fromServiceWrapper) Publish(ctx context.Context, p client.Message, opts ...client.PublishOption) error {
ctx = f.setHeaders(ctx)
return f.Client.Publish(ctx, p, opts...)
}
// FromService wraps a client to inject service and auth metadata.
func FromService(name string, c client.Client) client.Client {
return &fromServiceWrapper{
c,
metadata.Metadata{
headers.Prefix + "From-Service": name,
},
}
}
// HandlerStats wraps a server handler to generate request/error stats.
func HandlerStats(stats stats.Stats) server.HandlerWrapper {
// return a handler wrapper
return func(h server.HandlerFunc) server.HandlerFunc {
// return a function that returns a function
return func(ctx context.Context, req server.Request, rsp interface{}) error {
// execute the handler
err := h(ctx, req, rsp)
// record the stats
_ = stats.Record(err)
// return the error
return err
}
}
}
type traceWrapper struct {
client.Client
trace trace.Tracer
name string
}
func (c *traceWrapper) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
newCtx, s := c.trace.Start(ctx, req.Service()+"."+req.Endpoint())
s.Type = trace.SpanTypeRequestOutbound
err := c.Client.Call(newCtx, req, rsp, opts...)
if err != nil {
s.Metadata["error"] = err.Error()
}
// finish the trace
_ = c.trace.Finish(s)
return err
}
// TraceCall is a call tracing wrapper.
func TraceCall(name string, t trace.Tracer, c client.Client) client.Client {
return &traceWrapper{
name: name,
trace: t,
Client: c,
}
}
// TraceHandler wraps a server handler to perform tracing.
func TraceHandler(t trace.Tracer) server.HandlerWrapper {
// return a handler wrapper
return func(h server.HandlerFunc) server.HandlerFunc {
// return a function that returns a function
return func(ctx context.Context, req server.Request, rsp interface{}) error {
// don't store traces for debug
if strings.HasPrefix(req.Endpoint(), "Debug.") {
return h(ctx, req, rsp)
}
// get the span
newCtx, s := t.Start(ctx, req.Service()+"."+req.Endpoint())
s.Type = trace.SpanTypeRequestInbound
err := h(newCtx, req, rsp)
if err != nil {
s.Metadata["error"] = err.Error()
}
// finish
_ = t.Finish(s)
return err
}
}
}
func AuthCall(a func() auth.Auth, c client.Client) client.Client {
return &authWrapper{Client: c, auth: a}
}
type authWrapper struct {
client.Client
auth func() auth.Auth
}
func (a *authWrapper) Call(ctx context.Context, req client.Request, rsp interface{}, opts ...client.CallOption) error {
// parse the options
var options client.CallOptions
for _, o := range opts {
o(&options)
}
// check to see if the authorization header has already been set.
// We dont't override the header unless the ServiceToken option has
// been specified or the header wasn't provided
if _, ok := metadata.Get(ctx, "Authorization"); ok && !options.ServiceToken {
return a.Client.Call(ctx, req, rsp, opts...)
}
// if auth is nil we won't be able to get an access token, so we execute
// the request without one.
aa := a.auth()
if aa == nil {
return a.Client.Call(ctx, req, rsp, opts...)
}
// set the namespace header if it has not been set (e.g. on a service to service request)
if _, ok := metadata.Get(ctx, headers.Namespace); !ok {
ctx = metadata.Set(ctx, headers.Namespace, aa.Options().Namespace)
}
// check to see if we have a valid access token
aaOpts := aa.Options()
if aaOpts.Token != nil && !aaOpts.Token.Expired() {
ctx = metadata.Set(ctx, "Authorization", auth.BearerScheme+aaOpts.Token.AccessToken)
return a.Client.Call(ctx, req, rsp, opts...)
}
// call without an auth token
return a.Client.Call(ctx, req, rsp, opts...)
}
+52
View File
@@ -0,0 +1,52 @@
package wrapper
import (
"context"
"testing"
"go-micro.dev/v6/metadata"
)
func TestWrapper(t *testing.T) {
testData := []struct {
existing metadata.Metadata
headers metadata.Metadata
overwrite bool
}{
{
existing: metadata.Metadata{},
headers: metadata.Metadata{
"Foo": "bar",
},
overwrite: true,
},
{
existing: metadata.Metadata{
"Foo": "bar",
},
headers: metadata.Metadata{
"Foo": "baz",
},
overwrite: false,
},
}
for _, d := range testData {
c := &fromServiceWrapper{
headers: d.headers,
}
ctx := metadata.NewContext(context.Background(), d.existing)
ctx = c.setHeaders(ctx)
md, _ := metadata.FromContext(ctx)
for k, v := range d.headers {
if d.overwrite && md[k] != v {
t.Fatalf("Expected %s=%s got %s=%s", k, v, k, md[k])
}
if !d.overwrite && md[k] != d.existing[k] {
t.Fatalf("Expected %s=%s got %s=%s", k, d.existing[k], k, md[k])
}
}
}
}
+1
View File
@@ -0,0 +1 @@
_site
+6
View File
@@ -0,0 +1,6 @@
# frozen_string_literal: true
source "https://rubygems.org"
# gem "rails"
gem 'github-pages', group: :jekyll_plugins
+282
View File
@@ -0,0 +1,282 @@
GEM
remote: https://rubygems.org/
specs:
activesupport (7.2.2.1)
base64
benchmark (>= 0.3)
bigdecimal
concurrent-ruby (~> 1.0, >= 1.3.1)
connection_pool (>= 2.2.5)
drb
i18n (>= 1.6, < 2)
logger (>= 1.4.2)
minitest (>= 5.1)
securerandom (>= 0.3)
tzinfo (~> 2.0, >= 2.0.5)
addressable (2.8.7)
public_suffix (>= 2.0.2, < 7.0)
base64 (0.2.0)
benchmark (0.4.0)
bigdecimal (3.1.9)
coffee-script (2.4.1)
coffee-script-source
execjs
coffee-script-source (1.12.2)
colorator (1.1.0)
commonmarker (0.23.11)
concurrent-ruby (1.3.5)
connection_pool (2.5.3)
csv (3.3.4)
dnsruby (1.72.4)
base64 (~> 0.2.0)
logger (~> 1.6.5)
simpleidn (~> 0.2.1)
drb (2.2.3)
em-websocket (0.5.3)
eventmachine (>= 0.12.9)
http_parser.rb (~> 0)
ethon (0.16.0)
ffi (>= 1.15.0)
eventmachine (1.2.7)
execjs (2.10.0)
faraday (2.13.1)
faraday-net_http (>= 2.0, < 3.5)
json
logger
faraday-net_http (3.4.0)
net-http (>= 0.5.0)
ffi (1.17.2)
forwardable-extended (2.6.0)
gemoji (4.1.0)
github-pages (232)
github-pages-health-check (= 1.18.2)
jekyll (= 3.10.0)
jekyll-avatar (= 0.8.0)
jekyll-coffeescript (= 1.2.2)
jekyll-commonmark-ghpages (= 0.5.1)
jekyll-default-layout (= 0.1.5)
jekyll-feed (= 0.17.0)
jekyll-gist (= 1.5.0)
jekyll-github-metadata (= 2.16.1)
jekyll-include-cache (= 0.2.1)
jekyll-mentions (= 1.6.0)
jekyll-optional-front-matter (= 0.3.2)
jekyll-paginate (= 1.1.0)
jekyll-readme-index (= 0.3.0)
jekyll-redirect-from (= 0.16.0)
jekyll-relative-links (= 0.6.1)
jekyll-remote-theme (= 0.4.3)
jekyll-sass-converter (= 1.5.2)
jekyll-seo-tag (= 2.8.0)
jekyll-sitemap (= 1.4.0)
jekyll-swiss (= 1.0.0)
jekyll-theme-architect (= 0.2.0)
jekyll-theme-cayman (= 0.2.0)
jekyll-theme-dinky (= 0.2.0)
jekyll-theme-hacker (= 0.2.0)
jekyll-theme-leap-day (= 0.2.0)
jekyll-theme-merlot (= 0.2.0)
jekyll-theme-midnight (= 0.2.0)
jekyll-theme-minimal (= 0.2.0)
jekyll-theme-modernist (= 0.2.0)
jekyll-theme-primer (= 0.6.0)
jekyll-theme-slate (= 0.2.0)
jekyll-theme-tactile (= 0.2.0)
jekyll-theme-time-machine (= 0.2.0)
jekyll-titles-from-headings (= 0.5.3)
jemoji (= 0.13.0)
kramdown (= 2.4.0)
kramdown-parser-gfm (= 1.1.0)
liquid (= 4.0.4)
mercenary (~> 0.3)
minima (= 2.5.1)
nokogiri (>= 1.16.2, < 2.0)
rouge (= 3.30.0)
terminal-table (~> 1.4)
webrick (~> 1.8)
github-pages-health-check (1.18.2)
addressable (~> 2.3)
dnsruby (~> 1.60)
octokit (>= 4, < 8)
public_suffix (>= 3.0, < 6.0)
typhoeus (~> 1.3)
html-pipeline (2.14.3)
activesupport (>= 2)
nokogiri (>= 1.4)
http_parser.rb (0.8.0)
i18n (1.14.7)
concurrent-ruby (~> 1.0)
jekyll (3.10.0)
addressable (~> 2.4)
colorator (~> 1.0)
csv (~> 3.0)
em-websocket (~> 0.5)
i18n (>= 0.7, < 2)
jekyll-sass-converter (~> 1.0)
jekyll-watch (~> 2.0)
kramdown (>= 1.17, < 3)
liquid (~> 4.0)
mercenary (~> 0.3.3)
pathutil (~> 0.9)
rouge (>= 1.7, < 4)
safe_yaml (~> 1.0)
webrick (>= 1.0)
jekyll-avatar (0.8.0)
jekyll (>= 3.0, < 5.0)
jekyll-coffeescript (1.2.2)
coffee-script (~> 2.2)
coffee-script-source (~> 1.12)
jekyll-commonmark (1.4.0)
commonmarker (~> 0.22)
jekyll-commonmark-ghpages (0.5.1)
commonmarker (>= 0.23.7, < 1.1.0)
jekyll (>= 3.9, < 4.0)
jekyll-commonmark (~> 1.4.0)
rouge (>= 2.0, < 5.0)
jekyll-default-layout (0.1.5)
jekyll (>= 3.0, < 5.0)
jekyll-feed (0.17.0)
jekyll (>= 3.7, < 5.0)
jekyll-gist (1.5.0)
octokit (~> 4.2)
jekyll-github-metadata (2.16.1)
jekyll (>= 3.4, < 5.0)
octokit (>= 4, < 7, != 4.4.0)
jekyll-include-cache (0.2.1)
jekyll (>= 3.7, < 5.0)
jekyll-mentions (1.6.0)
html-pipeline (~> 2.3)
jekyll (>= 3.7, < 5.0)
jekyll-optional-front-matter (0.3.2)
jekyll (>= 3.0, < 5.0)
jekyll-paginate (1.1.0)
jekyll-readme-index (0.3.0)
jekyll (>= 3.0, < 5.0)
jekyll-redirect-from (0.16.0)
jekyll (>= 3.3, < 5.0)
jekyll-relative-links (0.6.1)
jekyll (>= 3.3, < 5.0)
jekyll-remote-theme (0.4.3)
addressable (~> 2.0)
jekyll (>= 3.5, < 5.0)
jekyll-sass-converter (>= 1.0, <= 3.0.0, != 2.0.0)
rubyzip (>= 1.3.0, < 3.0)
jekyll-sass-converter (1.5.2)
sass (~> 3.4)
jekyll-seo-tag (2.8.0)
jekyll (>= 3.8, < 5.0)
jekyll-sitemap (1.4.0)
jekyll (>= 3.7, < 5.0)
jekyll-swiss (1.0.0)
jekyll-theme-architect (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-cayman (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-dinky (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-hacker (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-leap-day (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-merlot (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-midnight (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-minimal (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-modernist (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-primer (0.6.0)
jekyll (> 3.5, < 5.0)
jekyll-github-metadata (~> 2.9)
jekyll-seo-tag (~> 2.0)
jekyll-theme-slate (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-tactile (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-theme-time-machine (0.2.0)
jekyll (> 3.5, < 5.0)
jekyll-seo-tag (~> 2.0)
jekyll-titles-from-headings (0.5.3)
jekyll (>= 3.3, < 5.0)
jekyll-watch (2.2.1)
listen (~> 3.0)
jemoji (0.13.0)
gemoji (>= 3, < 5)
html-pipeline (~> 2.2)
jekyll (>= 3.0, < 5.0)
json (2.12.0)
kramdown (2.4.0)
rexml
kramdown-parser-gfm (1.1.0)
kramdown (~> 2.0)
liquid (4.0.4)
listen (3.9.0)
rb-fsevent (~> 0.10, >= 0.10.3)
rb-inotify (~> 0.9, >= 0.9.10)
logger (1.6.6)
mercenary (0.3.6)
mini_portile2 (2.8.9)
minima (2.5.1)
jekyll (>= 3.5, < 5.0)
jekyll-feed (~> 0.9)
jekyll-seo-tag (~> 2.1)
minitest (5.25.5)
net-http (0.6.0)
uri
nokogiri (1.18.8)
mini_portile2 (~> 2.8.2)
racc (~> 1.4)
octokit (4.25.1)
faraday (>= 1, < 3)
sawyer (~> 0.9)
pathutil (0.16.2)
forwardable-extended (~> 2.6)
public_suffix (5.1.1)
racc (1.8.1)
rb-fsevent (0.11.2)
rb-inotify (0.11.1)
ffi (~> 1.0)
rexml (3.4.1)
rouge (3.30.0)
rubyzip (2.4.1)
safe_yaml (1.0.5)
sass (3.7.4)
sass-listen (~> 4.0.0)
sass-listen (4.0.0)
rb-fsevent (~> 0.9, >= 0.9.4)
rb-inotify (~> 0.9, >= 0.9.7)
sawyer (0.9.2)
addressable (>= 2.3.5)
faraday (>= 0.17.3, < 3)
securerandom (0.4.1)
simpleidn (0.2.3)
terminal-table (1.8.0)
unicode-display_width (~> 1.1, >= 1.1.1)
typhoeus (1.4.1)
ethon (>= 0.9.0)
tzinfo (2.0.6)
concurrent-ruby (~> 1.0)
unicode-display_width (1.8.0)
uri (1.0.3)
webrick (1.9.1)
PLATFORMS
x86_64-linux
DEPENDENCIES
github-pages
BUNDLED WITH
2.3.18
+3
View File
@@ -0,0 +1,3 @@
# Website
The Go Micro website including docs
+14
View File
@@ -0,0 +1,14 @@
title: Docs
description: "A Go microservices framework"
baseurl: "" # served at root; docs under /docs/ paths
url: "https://go-micro.dev" # domain host
# Enable syntax highlighting
highlighter: rouge
markdown: kramdown
kramdown:
input: GFM
syntax_highlighter: rouge
syntax_highlighter_opts:
line_numbers: false
+111
View File
@@ -0,0 +1,111 @@
core:
- title: Overview
url: /docs/
- title: Getting Started
url: /docs/getting-started.html
- title: Install Troubleshooting
url: /docs/guides/install-troubleshooting.html
- title: AI Integration
url: /docs/ai-integration.html
- title: No-secret First Agent
url: /docs/guides/no-secret-first-agent.html
- title: Your First Agent
url: /docs/guides/your-first-agent.html
- title: 0→hero Reference
url: /docs/guides/zero-to-hero.html
- title: MCP & AI Agents
url: /docs/mcp.html
- title: Deployment
url: /docs/deployment.html
- title: Architecture
url: /docs/architecture.html
- title: Configuration
url: /docs/config.html
- title: Observability
url: /docs/observability.html
interfaces:
- title: Registry
url: /docs/registry.html
- title: Broker
url: /docs/broker.html
- title: Transport
url: /docs/transport.html
- title: Store
url: /docs/store.html
- title: Plugins
url: /docs/plugins.html
examples:
- title: Learn by Example
url: /docs/examples/
- title: Real-World Examples
url: /docs/examples/realworld/
guides:
- title: Debugging your agent
url: /docs/guides/debugging-agents.html
- title: micro loop quickstart
url: /docs/guides/micro-loop.html
- title: Plan & Delegate
url: /docs/guides/plan-delegate.html
- title: Agent Guardrails
url: /docs/guides/agent-guardrails.html
- title: Agents and Workflows
url: /docs/guides/agents-and-workflows.html
- title: Agent Integration Patterns
url: /docs/guides/agent-patterns.html
- title: The Agent Harness
url: /docs/guides/agent-harness.html
- title: Agent Loops
url: /docs/guides/agent-loops.html
- title: Agent2Agent (A2A)
url: /docs/guides/a2a-protocol.html
- title: AI Provider Guide
url: /docs/guides/ai-provider-guide.html
- title: Provider Conformance
url: /docs/guides/provider-conformance.html
- title: Atlas Cloud Integration
url: /docs/guides/atlascloud-integration.html
- title: Payments (x402)
url: /docs/guides/x402-payments.html
- title: Comparison
url: /docs/guides/comparison.html
- title: Migration Guides
url: /docs/guides/migration/
project:
- title: Commercial Support
url: /docs/support.html
- title: ADR Index
url: /docs/architecture/
- title: Contributing
url: /docs/contributing.html
- title: Roadmap
url: /docs/roadmap.html
- title: Get Badge
url: /badge.html
- title: Server (optional)
url: /docs/server.html
search_order:
- /docs/guides/install-troubleshooting.html
- /docs/guides/your-first-agent.html
- /docs/guides/zero-to-hero.html
- /docs/guides/debugging-agents.html
- /docs/guides/micro-loop.html
- /docs/getting-started.html
- /docs/mcp.html
- /docs/architecture.html
- /docs/config.html
- /docs/observability.html
- /docs/registry.html
- /docs/broker.html
- /docs/transport.html
- /docs/store.html
- /docs/plugins.html
- /docs/examples/
- /docs/examples/realworld/
- /docs/guides/provider-conformance.html
- /docs/guides/comparison.html
- /docs/guides/migration/
- /docs/architecture/
- /docs/contributing.html
- /docs/roadmap.html
- /docs/support.html
- /docs/server.html
@@ -0,0 +1,5 @@
<a href="/docs/">Docs</a> &middot;
<a href="/blog/">Blog</a> &middot;
<a href="https://github.com/micro/go-micro">GitHub</a> &middot;
<a href="https://discord.gg/G8Gk5j3uXr">Discord</a> &middot;
<a href="/support">Support</a>
@@ -0,0 +1,6 @@
<footer class="footer">
<div>&copy; {{ site.time | date: '%Y' }} Go Micro. Apache 2.0 Licensed.</div>
<div>
{% include footer-links.html %}
</div>
</footer>
@@ -0,0 +1,6 @@
<nav class="nav">
<a class="nav-brand" href="/">Go Micro</a>
<div class="nav-links">
{% include nav-links.html %}
</div>
</nav>
@@ -0,0 +1,5 @@
<a href="/docs/">Docs</a>
<a href="/blog/">Blog</a>
<a href="/support"{% if page.nav_active == 'support' %} class="active"{% endif %}>Support</a>
<a href="https://discord.gg/G8Gk5j3uXr">Discord</a>
<a href="https://github.com/micro/go-micro">GitHub</a>
+87
View File
@@ -0,0 +1,87 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if page.title %}{{ page.title }} | {% endif %}Go Micro Blog</title>
<meta name="description" content="{{ page.description | default: 'Go Micro Blog - News, updates, and tutorials for the Go Micro framework' }}">
<style>
:root {
--bg: #ffffff;
--border: #e5e5e5;
--accent: #0366d6;
}
* { box-sizing: border-box; }
body { font-family: system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; margin:0; background: var(--bg); color:#1a1a1a; line-height: 1.7; }
a { color: var(--accent); text-decoration:none; }
a:hover { text-decoration:underline; }
/* Nav — matches landing page */
.site-nav { display:flex; align-items:center; justify-content:space-between; max-width: 1100px; margin:0 auto; padding:.75rem 1.5rem; border-bottom: 1px solid var(--border); position:sticky; top:0; z-index:20; background:#fff; }
.nav-brand { display:flex; align-items:center; gap:.5rem; font-weight:700; font-size:1.1rem; color:#1a1a1a; text-decoration:none; }
.nav-brand img { height:32px; }
.nav-brand:hover { text-decoration: none; }
.nav-links { display:flex; align-items:center; gap:1.25rem; font-size:.9rem; }
.nav-links a { color:#555; font-weight:500; }
.nav-links a:hover { color: var(--accent); text-decoration:none; }
/* Blog container */
.container { max-width: 760px; margin: 0 auto; padding: 2.5rem 1.5rem 4rem; }
.blog-header { margin-bottom: 2rem; padding-bottom: 1rem; border-bottom: 1px solid var(--border); }
.blog-header h1 { margin: 0 0 0.5rem; font-size: 2rem; color: #0d1117; }
.blog-header .meta { color: #888; font-size: 0.9rem; }
/* Article */
article h1 { font-size: 2.25rem; margin: 0 0 0.5rem; line-height: 1.3; color: #0d1117; }
article .meta { color: #888; font-size: 0.9rem; margin-bottom: 2rem; }
article h2 { font-size: 1.5rem; margin-top: 2.5rem; color: #0d1117; }
article h3 { font-size: 1.25rem; margin-top: 2rem; }
article p { margin: 1rem 0; }
article ul, article ol { margin: 1rem 0; padding-left: 1.5rem; }
article li { margin: 0.5rem 0; }
article img { border-radius: 8px; max-width: 100%; }
/* Code */
pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace; }
pre { background:#f6f8fa; border:1px solid #d0d7de; padding:1rem; border-radius:6px; overflow-x:auto; font-size: 0.88rem; }
code { background: #f6f8fa; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.9em; }
pre code { background: none; padding: 0; }
blockquote { margin: 1.5rem 0; padding: 0.5rem 1rem; border-left: 4px solid var(--accent); background: #f6f8fa; border-radius: 0 6px 6px 0; }
blockquote p { margin: 0; }
table { border-collapse:collapse; width:100%; }
th, td { border:1px solid #d0d7de; padding:.5rem .6rem; text-align:left; }
th { background:#f6f8fa; font-weight: 600; }
.post-nav { margin-top: 3rem; padding-top: 1.5rem; border-top: 1px solid var(--border); display: flex; justify-content: space-between; font-size: 0.9rem; }
/* Footer — matches landing page */
.site-footer { max-width: 760px; margin: 0 auto; padding: 2rem 1.5rem; border-top: 1px solid var(--border); font-size: .85rem; color: #666; display:flex; justify-content:space-between; flex-wrap:wrap; gap:1rem; }
.site-footer a { color: var(--accent); }
@media (max-width: 600px) {
.container { padding: 1.5rem 1rem; }
article h1 { font-size: 1.75rem; }
.site-nav { padding: 0.5rem 1rem; }
.nav-links { gap: 0.75rem; font-size: 0.8rem; }
.site-footer { flex-direction: column; text-align: center; }
}
</style>
</head>
<body>
<nav class="site-nav">
<a class="nav-brand" href="/">Go Micro</a>
<div class="nav-links">
{% include nav-links.html %}
</div>
</nav>
<div class="container">
{{ content }}
</div>
<footer class="site-footer">
<div>&copy; {{ site.time | date: '%Y' }} Go Micro. Apache 2.0 Licensed.</div>
<div>
{% include footer-links.html %}
</div>
</footer>
</body>
</html>
+198
View File
@@ -0,0 +1,198 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% if page.title %}{{ page.title }} | {% endif %}Go Micro Documentation</title>
<style>
:root {
--bg: #ffffff;
--border: #e5e5e5;
--sidebar-width: 240px;
--accent: #0366d6;
}
* { box-sizing: border-box; }
body { font-family: system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif; margin:0; background: var(--bg); color:#1a1a1a; line-height: 1.6; }
a { color: var(--accent); text-decoration:none; }
a:hover { text-decoration:underline; }
/* Nav — matches landing page */
.site-nav { display:flex; align-items:center; justify-content:space-between; max-width: 1100px; margin:0 auto; padding:.75rem 1.5rem; border-bottom: 1px solid var(--border); position:sticky; top:0; z-index:20; background:#fff; }
.nav-brand { display:flex; align-items:center; gap:.5rem; font-weight:700; font-size:1.1rem; color:#1a1a1a; text-decoration:none; }
.nav-brand img { height:32px; }
.nav-brand:hover { text-decoration: none; }
.nav-links { display:flex; align-items:center; gap:1.25rem; font-size:.9rem; }
.nav-links a { color:#555; font-weight:500; }
.nav-links a:hover { color: var(--accent); text-decoration:none; }
.nav-left { display:flex; align-items:center; gap:.5rem; }
.menu-toggle { display:none; background:none; border:none; color: var(--accent); cursor:pointer; font-size:1.4rem; line-height:1; padding:0 .25rem; }
/* Layout */
.layout { display:flex; align-items:flex-start; max-width: 1100px; margin:0 auto; padding:0 1.25rem 4rem; }
.sidebar { width:var(--sidebar-width); padding:1.5rem .75rem 2rem; border-right:1px solid var(--border); position:sticky; top:55px; max-height:calc(100vh - 55px); overflow:auto; font-size:.88rem; flex-shrink: 0; }
.sidebar h4 { margin:1.4rem 0 .5rem; font-size:.7rem; text-transform:uppercase; letter-spacing:.06em; color:#888; font-weight: 600; }
.sidebar ul { list-style:none; margin:0; padding:0; }
.sidebar li { margin:.3rem 0; }
.sidebar a { color:#444; display:block; padding:.3rem .5rem; border-radius:5px; transition: background .15s; }
.sidebar a:hover { background:#f0f4ff; color: var(--accent); text-decoration: none; }
.content { flex:1; min-width:0; padding:2rem 2.5rem; }
.content .markdown-body { max-width: 100%; }
.content h1 { font-size: 2rem; margin-bottom: 0.5rem; color: #0d1117; }
.content h2 { font-size: 1.4rem; margin-top: 2.5rem; color: #0d1117; }
.content h3 { font-size: 1.15rem; margin-top: 2rem; }
.content p { margin: 1rem 0; }
.content img { border-radius: 8px; }
/* Code */
pre, code { font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, "Liberation Mono", monospace; }
pre { background:#f6f8fa; border:1px solid #d0d7de; padding:.9rem 1rem; border-radius:6px; overflow-x:auto; max-width:100%; font-size: 0.88rem; }
code { word-wrap: break-word; background: #f6f8fa; padding: 0.1rem 0.3rem; border-radius: 3px; font-size: 0.9em; }
pre code { word-wrap: normal; background: none; padding: 0; }
/* Tables */
table { border-collapse:collapse; width:100%; overflow-x:auto; display:block; }
th, td { border:1px solid #d0d7de; padding:.5rem .6rem; text-align:left; }
th { background:#f6f8fa; font-weight: 600; }
img { max-width:100%; height:auto; }
/* Footer — matches landing page */
.site-footer { max-width:1100px; margin:0 auto; padding:2rem 1.5rem; border-top:1px solid var(--border); font-size:.85rem; color:#666; display:flex; justify-content:space-between; flex-wrap:wrap; gap:1rem; }
.site-footer a { color: var(--accent); }
/* Dark mode */
body.dark { --bg:#0d1117; --border:#30363d; color:#e6edf3; background:#0d1117; }
body.dark .site-nav, body.dark .sidebar, body.dark .content, body.dark .site-footer { background:#0d1117; }
body.dark .nav-brand, body.dark .sidebar a { color: #e6edf3; }
body.dark a { color:#58a6ff; }
body.dark pre { background:#161b22; border-color:#30363d; }
body.dark code { background:#161b22; }
body.dark .sidebar a:hover { background:#161b22; }
body.dark th { background:#161b22; }
.sidebar-overlay { display:none; position:fixed; inset:0; background:rgba(0,0,0,0.5); z-index:25; }
.sidebar-overlay.active { display:block; }
@media (max-width: 900px) {
.site-nav { padding:.5rem 1rem; }
.nav-links { gap:.75rem; font-size:.8rem; }
.menu-toggle { display:block; }
.layout { padding:0; }
.sidebar { position:fixed; left:-100%; top:55px; bottom:0; width:280px; max-width:85vw; background:var(--bg); border-right:1px solid var(--border); z-index:30; transition:left 0.3s ease; overflow-y:auto; padding:1rem; }
.sidebar.active { left:0; }
.content { width:100%; padding:1.5rem 1rem; }
.content h1 { font-size:1.75rem; margin-top:0; }
.content h2 { font-size:1.4rem; }
.content h3 { font-size:1.15rem; }
pre { padding:.6rem; font-size:.8rem; overflow-x:auto; white-space:pre; }
table { font-size:.85rem; }
.site-footer { flex-direction: column; text-align: center; padding:1.5rem 1rem; font-size:.75rem; }
}
@media (max-width: 480px) {
.nav-links { gap:.5rem; font-size:.75rem; }
.content { padding:1rem .75rem; }
.content h1 { font-size:1.5rem; }
.content h2 { font-size:1.25rem; }
pre { font-size:.75rem; padding:.5rem; }
.sidebar { width:100%; max-width:100%; }
}
</style>
</head>
<body>
<nav class="site-nav">
<div class="nav-left">
<button class="menu-toggle" id="menuToggle" aria-label="Toggle sidebar"></button>
<a class="nav-brand" href="/">Go Micro</a>
</div>
<div class="nav-links">
{% include nav-links.html %}
</div>
</nav>
<div class="sidebar-overlay" id="sidebarOverlay"></div>
<div class="layout">
<aside class="sidebar">
{% assign nav = site.data.navigation %}
{% for section in nav %}
{% unless section[0] == 'search_order' %}
<h4>{{ section[0] | capitalize }}</h4>
<ul>
{% for item in section[1] %}
<li><a href="{{ item.url }}" {% if item.url == page.url %}style="font-weight:600; color: var(--accent);"{% endif %}>{{ item.title }}</a></li>
{% endfor %}
</ul>
{% endunless %}
{% endfor %}
</aside>
<main class="content markdown-body">
{% assign crumbs = page.url | split:'/' %}
{% assign docs_root = site.baseurl | append: '/' %}
{% if page.url != docs_root and page.url contains docs_root %}
<nav style="font-size:.75rem; margin-bottom:1rem; color: #888;">
<a href="/docs/">Docs</a>
{% capture path_acc %}/docs{% endcapture %}
{% for c in crumbs %}
{% if forloop.index0 > 1 and c != '' %}
{% capture path_acc %}{{ path_acc }}/{{ c }}{% endcapture %}
<a href="{{ path_acc }}/">{{ c | replace:'.html','' | replace:'index','' | replace:'realworld','Real-World' | replace:'guides','Guides' | replace:'migration','Migration' | replace:'architecture','Architecture' | replace:'examples','Examples' | replace:'config','Configuration' | replace:'observability','Observability' | capitalize }}</a>
{% endif %}
{% endfor %}
</nav>
{% endif %}
{{ content }}
{% assign order = site.data.navigation.search_order %}
{% if page.url %}
{% assign current_index = -1 %}
{% for u in order %}
{% if u == page.url %}{% assign current_index = forloop.index0 %}{% endif %}
{% endfor %}
{% if current_index != -1 %}
<hr style="margin:2.5rem 0;" />
<div style="display:flex; justify-content:space-between; font-size:.85rem;">
<div>
{% if current_index > 0 %}
{% assign prev_url = order[current_index | minus: 1] %}
<a href="{{ prev_url }}">&larr; Previous</a>
{% endif %}
</div>
<div>
{% assign next_index = current_index | plus: 1 %}
{% if next_index < order.size %}
{% assign next_url = order[next_index] %}
<a href="{{ next_url }}">Next &rarr;</a>
{% endif %}
</div>
</div>
{% endif %}
{% endif %}
</main>
</div>
<footer class="site-footer">
<div>&copy; {{ site.time | date: '%Y' }} Go Micro. Apache 2.0 Licensed.</div>
<div>
{% include footer-links.html %}
</div>
</footer>
<script>
(function(){
const key='gm.dark';
function apply(){
if(localStorage.getItem(key)==='1') document.body.classList.add('dark');
else document.body.classList.remove('dark');
}
apply();
var btn=document.getElementById('dark-toggle');
if(btn) btn.addEventListener('click', function(){
localStorage.setItem(key, localStorage.getItem(key)==='1' ? '0' : '1');
apply();
});
var menuToggle = document.getElementById('menuToggle');
var sidebar = document.querySelector('.sidebar');
var overlay = document.getElementById('sidebarOverlay');
if(menuToggle && sidebar && overlay){
menuToggle.addEventListener('click', function(){ sidebar.classList.toggle('active'); overlay.classList.toggle('active'); });
overlay.addEventListener('click', function(){ sidebar.classList.remove('active'); overlay.classList.remove('active'); });
sidebar.querySelectorAll('a').forEach(function(link){ link.addEventListener('click', function(){ sidebar.classList.remove('active'); overlay.classList.remove('active'); }); });
}
})();
</script>
</body>
</html>
+202
View File
@@ -0,0 +1,202 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Powered by Go Micro Badge</title>
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body {
font-family: system-ui,-apple-system,"Segoe UI",Roboto,"Helvetica Neue",Arial,sans-serif;
line-height: 1.6;
color: #333;
background: #f6f8fa;
padding: 2rem 1rem;
}
.container {
max-width: 800px;
margin: 0 auto;
background: white;
padding: 3rem 2.5rem;
border-radius: 12px;
box-shadow: 0 4px 12px rgba(0,0,0,0.08);
}
h1 {
font-size: 2rem;
margin-bottom: 0.5rem;
color: #1a1a1a;
}
.subtitle {
color: #666;
font-size: 1.1rem;
margin-bottom: 2rem;
}
h2 {
font-size: 1.5rem;
margin: 2rem 0 1rem;
color: #0366d6;
}
h3 {
font-size: 1.15rem;
margin: 1.5rem 0 0.75rem;
color: #333;
}
.badge-preview {
background: #f8f9fa;
border: 1px solid #e5e5e5;
border-radius: 8px;
padding: 1.5rem;
margin: 1rem 0;
text-align: center;
}
.badge-preview img {
display: inline-block;
margin: 0.5rem 0;
}
pre {
background: #f6f8fa;
border: 1px solid #d0d7de;
border-radius: 6px;
padding: 1rem;
overflow-x: auto;
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
font-size: 0.9rem;
margin: 0.75rem 0;
}
code {
font-family: ui-monospace, SFMono-Regular, Menlo, Consolas, monospace;
background: #f6f8fa;
padding: 0.2rem 0.4rem;
border-radius: 3px;
font-size: 0.9rem;
}
pre code {
background: none;
padding: 0;
}
.back-link {
display: inline-block;
margin-bottom: 1.5rem;
color: #0366d6;
text-decoration: none;
font-weight: 500;
}
.back-link:hover {
text-decoration: underline;
}
.guideline {
background: #e8f4fd;
border-left: 4px solid #0366d6;
padding: 1rem 1.25rem;
margin: 1.5rem 0;
border-radius: 4px;
}
.guideline ul {
margin: 0.5rem 0 0 1.5rem;
}
.guideline li {
margin: 0.25rem 0;
}
@media (max-width: 600px) {
.container { padding: 2rem 1.5rem; }
h1 { font-size: 1.5rem; }
}
</style>
</head>
<body>
<div class="container">
<a href="/" class="back-link">← Back to Home</a>
<h1>Powered by Go Micro Badge</h1>
<p class="subtitle">Show your support and let others know your project is built with Go Micro!</p>
<h2>Badges</h2>
<h3>Dark Badge</h3>
<div class="badge-preview">
<a href="https://go-micro.dev" target="_blank">
<img src="https://img.shields.io/badge/Powered%20by-Go%20Micro-0366d6?style=for-the-badge&logo=go&logoColor=white" alt="Powered by Go Micro">
</a>
</div>
<pre><code>[![Powered by Go Micro](https://img.shields.io/badge/Powered%20by-Go%20Micro-0366d6?style=for-the-badge&logo=go&logoColor=white)](https://go-micro.dev)</code></pre>
<h3>Light Badge</h3>
<div class="badge-preview">
<a href="https://go-micro.dev" target="_blank">
<img src="https://img.shields.io/badge/Powered%20by-Go%20Micro-00ADD8?style=flat&logo=go&logoColor=white" alt="Powered by Go Micro">
</a>
</div>
<pre><code>[![Powered by Go Micro](https://img.shields.io/badge/Powered%20by-Go%20Micro-00ADD8?style=flat&logo=go&logoColor=white)](https://go-micro.dev)</code></pre>
<h3>Compact Badge</h3>
<div class="badge-preview">
<a href="https://go-micro.dev" target="_blank">
<img src="https://img.shields.io/badge/Go-Micro-0366d6?style=flat-square" alt="Go Micro">
</a>
</div>
<pre><code>[![Go Micro](https://img.shields.io/badge/Go-Micro-0366d6?style=flat-square)](https://go-micro.dev)</code></pre>
<h2>HTML Badges</h2>
<h3>Standard HTML Badge</h3>
<div class="badge-preview">
<a href="https://go-micro.dev" target="_blank">
<img src="https://img.shields.io/badge/Powered%20by-Go%20Micro-0366d6?style=for-the-badge&logo=go&logoColor=white" alt="Powered by Go Micro">
</a>
</div>
<pre><code>&lt;a href="https://go-micro.dev" target="_blank"&gt;
&lt;img src="https://img.shields.io/badge/Powered%20by-Go%20Micro-0366d6?style=for-the-badge&logo=go&logoColor=white" alt="Powered by Go Micro"&gt;
&lt;/a&gt;</code></pre>
<h3>Custom SVG Badge</h3>
<div class="badge-preview">
<a href="https://go-micro.dev" style="display:inline-block;text-decoration:none;">
<svg width="160" height="32" xmlns="http://www.w3.org/2000/svg">
<rect width="160" height="32" rx="6" fill="#0366d6"/>
<text x="16" y="21" font-family="system-ui,-apple-system,sans-serif" font-size="13" font-weight="600" fill="white">
Powered by Go Micro
</text>
</svg>
</a>
</div>
<pre><code>&lt;a href="https://go-micro.dev" style="display:inline-block;text-decoration:none;"&gt;
&lt;svg width="160" height="32" xmlns="http://www.w3.org/2000/svg"&gt;
&lt;rect width="160" height="32" rx="6" fill="#0366d6"/&gt;
&lt;text x="16" y="21" font-family="system-ui,-apple-system,sans-serif" font-size="13" font-weight="600" fill="white"&gt;
Powered by Go Micro
&lt;/text&gt;
&lt;/svg&gt;
&lt;/a&gt;</code></pre>
<h2>Usage</h2>
<p>Add one of these badges to your README.md, documentation, or website footer to show that your project uses Go Micro.</p>
<h3>Example README</h3>
<pre><code># My Awesome Project
![Project Logo](logo.png)
[![Powered by Go Micro](https://img.shields.io/badge/Powered%20by-Go%20Micro-0366d6?style=for-the-badge&logo=go&logoColor=white)](https://go-micro.dev)
My project does amazing things using Go Micro microservices framework.
## Features
- Fast and scalable
- Built with Go Micro
- Production ready</code></pre>
<div class="guideline">
<h3>Badge Guidelines</h3>
<ul>
<li>Link the badge to <code>https://go-micro.dev</code> to help others discover Go Micro</li>
<li>Use the badge prominently in your README</li>
<li>Consider adding it to your project website footer</li>
<li>Feel free to customize the colors to match your brand</li>
</ul>
</div>
<h2>Showcase Your Project</h2>
<p>Built something cool with Go Micro? <a href="https://github.com/micro/go-micro/issues/new" target="_blank">Open an issue</a> to get featured on our homepage!</p>
</div>
</body>
</html>
+133
View File
@@ -0,0 +1,133 @@
---
layout: blog
title: Introducing micro deploy
permalink: /blog/1
description: Deploy your Go Micro services to any Linux server with a single command
---
# Introducing micro deploy
<img src="/images/generated/blog-deploy.jpg" alt="Introducing micro deploy" style="width: 100%; border-radius: 8px; margin: 1rem 0 1.5rem;" />
*January 27, 2026 • By the Go Micro Team*
We're excited to announce **micro deploy** in Go Micro v5.13.0 — a simple way to deploy your services to any Linux server.
## The Problem
Go Micro has always been great for building microservices:
```bash
micro new myservice
cd myservice
micro run
```
But getting those services to production? That was on you. You'd need to figure out Docker, Kubernetes, or write your own deployment scripts.
We tried to solve this with Micro v3 — a full platform-as-a-service. But it was too much. Too complex. Nobody wanted another platform to manage.
## The Solution
The new approach is simple: **systemd + SSH**.
Every Linux server has systemd. It's battle-tested, it manages processes, it restarts them when they crash, it handles logging. Why reinvent it?
### One-Time Server Setup
```bash
ssh user@server
curl -fsSL https://go-micro.dev/install.sh | sh
sudo micro init --server
```
This creates:
- `/opt/micro/bin/` — where your binaries live
- `/opt/micro/config/` — environment files
- A systemd template for managing services
### Deploy
```bash
micro deploy user@server
```
That's it. The command:
1. Builds your services for Linux
2. Copies binaries via SSH
3. Configures systemd services
4. Verifies everything is running
### Manage
```bash
micro status --remote user@server
micro logs --remote user@server
micro logs myservice --remote user@server -f
```
## Named Deploy Targets
Add deploy targets to your `micro.mu`:
```
service users
path ./users
port 8081
service web
path ./web
port 8080
deploy prod
ssh deploy@prod.example.com
deploy staging
ssh deploy@staging.example.com
```
Then:
```bash
micro deploy prod
micro deploy staging
```
## Philosophy
- **systemd is the standard** — don't fight it, use it
- **SSH is the transport** — no custom agents or protocols
- **Errors guide you** — every failure tells you how to fix it
- **No platform** — just your server, your services
## What's Next?
This is just the beginning. We're thinking about:
- **Secrets management** — integrating with vault/sops
- **Multi-server deploys** — deploy to a fleet
- **Metrics** — Prometheus endpoints out of the box
- **Rolling updates** — zero-downtime deployments
## Try It
```bash
go install go-micro.dev/v5/cmd/micro@v5.13.0
micro new myapp
cd myapp
micro run
# When you're ready to deploy:
micro deploy user@your-server
```
See the [deployment guide](/docs/deployment.html) for full documentation.
---
*Go Micro is an open source framework for distributed systems development in Go. [Star us on GitHub](https://github.com/micro/go-micro).*
<div class="post-nav">
<div><a href="/blog/">← All Posts</a></div>
<div></div>
</div>
+183
View File
@@ -0,0 +1,183 @@
---
layout: blog
title: "micro chat: Talk to Your Services"
permalink: /blog/10
description: "Introducing micro chat — an interactive CLI that discovers your services, turns them into tools, and lets you orchestrate them through natural language."
---
# micro chat: Talk to Your Services
<img src="/images/generated/developer-experience.jpg" alt="micro chat terminal" style="width: 100%; border-radius: 8px; margin: 1rem 0 1.5rem;" />
*May 29, 2026 • By the Go Micro Team*
We built `micro chat` — a CLI that lets you talk to your microservices through an LLM. It discovers every service in the registry, exposes each endpoint as a tool, and lets a model decide which RPCs to call based on what you ask.
```bash
ANTHROPIC_API_KEY=sk-ant-... micro chat --provider anthropic
> list all users
→ called users_Users_List({})
There are 3 users: Alice, Bob, and Charlie.
> send a welcome email to Alice
→ called email_Email_Send({"to":"alice@example.com","template":"welcome"})
Done, welcome email sent to Alice.
> how many orders were placed this week?
→ called orders_Orders_Count({"since":"2026-05-22"})
There were 47 orders placed this week.
```
No glue code. No API wrappers. No tool definitions. You write normal Go services with doc comments, and `micro chat` turns them into things an LLM can call.
## How It Works
Three building blocks, stacked:
**1. `ai.Tools`** discovers services from the registry and creates typed tool definitions:
```go
tools := ai.NewTools(service.Registry())
discovered, _ := tools.Discover()
// discovered = []ai.Tool with name, description, parameters for each endpoint
```
**2. `ai.History`** tracks the conversation across turns so the LLM has context:
```go
hist := ai.NewHistory(50)
resp, _ := m.Generate(ctx, &ai.Request{Prompt: "list all users", Tools: discovered, Messages: hist.Messages()})
// Next prompt remembers this exchange
```
**3. `ai.Model`** calls the LLM. Seven providers, same interface:
```go
m := ai.New("anthropic", ai.WithAPIKey(key))
// or: "openai", "gemini", "atlascloud", "groq", "mistral", "together"
```
`micro chat` just wires these together with a REPL loop. The whole command is ~170 lines.
## Multi-Turn Conversations
`micro chat` remembers context across turns. You can ask follow-up questions without repeating yourself:
```
> list all users
There are 3 users: Alice (admin), Bob (user), Charlie (user).
> which ones are admins?
Alice is the only admin.
> change Bob's role to admin too
→ called users_Users_Update({"id":"bob-123","role":"admin"})
Done. Bob is now an admin.
```
Type `reset` to clear the conversation history and start fresh. The history limit is 50 messages by default — old messages are dropped FIFO when you hit the limit.
## Using It
Install or update the CLI:
```bash
go install go-micro.dev/v5/cmd/micro@latest
```
Start your services:
```bash
micro run
```
Chat with them:
```bash
# With Anthropic Claude
ANTHROPIC_API_KEY=sk-ant-... micro chat --provider anthropic
# With OpenAI
OPENAI_API_KEY=sk-... micro chat --provider openai
# With Atlas Cloud
ATLASCLOUD_API_KEY=... micro chat --provider atlascloud
# With any provider via base URL
micro chat --provider openai --base_url https://api.groq.com/openai --api_key $KEY
```
### Single Prompt Mode
For scripting or one-shot queries, use `--prompt`:
```bash
micro chat --provider anthropic --prompt "list all services"
```
### Environment Variables
If you don't want to pass flags every time:
```bash
export MICRO_AI_PROVIDER=anthropic
export ANTHROPIC_API_KEY=sk-ant-...
micro chat
```
## What Makes It Work
The key insight is that go-micro services are **already described**. The registry stores endpoint names, request/response types, and field metadata. Doc comments on handlers become tool descriptions. `@example` tags provide usage hints to the LLM.
```go
// CreateUser creates a new user account with the given details.
// @example {"name": "Alice", "email": "alice@example.com", "role": "admin"}
func (h *Users) CreateUser(ctx context.Context, req *pb.CreateRequest, rsp *pb.CreateResponse) error {
// ...
}
```
The `ai.Tools` package reads all of this from the registry and translates it into the tool format that LLMs understand. The better your doc comments, the better the LLM uses your services.
## Using It Programmatically
`micro chat` is a CLI, but the building blocks work in your own code:
```go
import (
"go-micro.dev/v5/ai"
_ "go-micro.dev/v5/ai/anthropic"
)
tools := ai.NewTools(service.Registry())
discovered, _ := tools.Discover()
m := ai.New("anthropic",
ai.WithAPIKey(key),
ai.WithTools(tools),
)
hist := ai.NewHistory(50)
resp, _ := m.Generate(ctx, &ai.Request{Prompt: userInput, Tools: discovered, Messages: hist.Messages()})
fmt.Println(resp.Answer)
```
This is the same code `micro chat` runs internally. Use it to add LLM-powered orchestration to any service.
## What's Next
`micro chat` is the interactive version. `micro flow` is the event-driven version — same building blocks, but triggered by broker events instead of human input. See the [flows blog post](/blog/9) for that story.
Both are experiments in what happens when services are composable by agents, not just by code. The framework provides the building blocks. You decide how to use them.
---
*Go Micro is an open source framework for distributed systems development. [Star us on GitHub](https://github.com/micro/go-micro).*
<div class="post-nav">
<div><a href="/blog/9">&larr; From Chat to Flows</a></div>
<div><a href="/blog/">All Posts</a></div>
<div><a href="/blog/11">Build Your Own &rarr;</a></div>
</div>
+191
View File
@@ -0,0 +1,191 @@
---
layout: blog
title: "Build Your Own AI Agent CLI in 150 Lines"
permalink: /blog/11
description: "A complete teardown of micro chat — how to build an LLM agent that discovers and orchestrates your services, with every line explained."
---
# Build Your Own AI Agent CLI in 150 Lines
<img src="/images/generated/developer-experience.jpg" alt="Building an AI agent CLI" style="width: 100%; border-radius: 8px; margin: 1rem 0 1.5rem;" />
*May 30, 2026 • By the Go Micro Team*
We [introduced `micro chat`](/blog/10) — a CLI that lets you talk to your microservices through an LLM. People asked how it works under the hood. The honest answer: it's about 150 lines, and there's no magic. This post walks through every piece so you can build your own — for go-micro, for your own framework, or for whatever services you have.
By the end, you'll understand the four moving parts of any tool-calling agent and have working code you can adapt.
## The Problem
You have services. They do things — create users, send emails, query orders. You want to ask for those things in plain English and have the right service called automatically.
An LLM can do the reasoning ("the user wants to send an email, so call the email service"), but it needs three things from you:
1. **A list of tools** it can call, with descriptions and parameters
2. **A way to execute** a tool when it picks one
3. **Conversation memory** so follow-up questions make sense
That's the whole problem. Let's solve each part.
## Part 1: Discover the Tools
The LLM needs to know what's available. In go-micro, every service registers its endpoints with the registry, including request types and field metadata. We turn that into a tool list:
```go
tools := ai.NewTools(reg, ai.ToolClient(client))
discovered, err := tools.Discover()
```
`discovered` is a `[]ai.Tool` — one per service endpoint. Each has a name (`users_Users_Create`), a description (from the handler's doc comment), and a parameter schema (from the request struct's fields).
If you're not using go-micro, this is the part you'd write yourself: enumerate your functions/endpoints and build a list of `{name, description, parameters}`. The registry just makes it automatic.
## Part 2: Create the Model
```go
m := ai.New("anthropic",
ai.WithAPIKey(apiKey),
ai.WithTools(tools),
)
```
Two things happen here. `ai.New` picks the provider (Anthropic, OpenAI, Gemini, etc. — all the same interface). `ai.WithTools(tools)` wires up the **execution** side: when the model says "call `users_Users_Create` with these args," the handler routes it to the right RPC and returns the result.
That's the second piece — the way to execute. The `Tools` object does double duty: `Discover()` builds the list, and its handler executes the calls.
## Part 3: Track the Conversation
```go
hist := ai.NewHistory(50)
```
`History` is a plain message accumulator with a size limit. It's not magic — it's a `[]Message` with `Add`, `Messages`, and `Reset`. You add the user's prompt and the model's reply after each turn, and pass the accumulated messages back on the next call. That's how follow-up questions work.
## Part 4: The Loop
Now wire it together. The core of `ask` is just this:
```go
func ask(ctx context.Context, m ai.Model, hist *ai.History, tools []ai.Tool, prompt string) error {
hist.Add("user", prompt)
resp, err := m.Generate(ctx, &ai.Request{
Prompt: prompt,
SystemPrompt: systemPrompt,
Tools: tools,
Messages: hist.Messages(),
})
if err != nil {
return err
}
if resp.Reply != "" {
hist.Add("assistant", resp.Reply)
fmt.Println(resp.Reply)
}
for _, tc := range resp.ToolCalls {
args, _ := json.Marshal(tc.Input)
fmt.Printf(" → called %s(%s)\n", tc.Name, args)
}
if resp.Answer != "" {
hist.Add("assistant", resp.Answer)
fmt.Println(resp.Answer)
}
return nil
}
```
Read it top to bottom:
1. **Record the prompt** in history
2. **Call the model** with the prompt, the system instruction, the tool list, and the conversation so far
3. **Print the reply** and record it
4. **Show which tools were called** (the model decides, the handler executes — we just report)
5. **Print the final answer** after tools ran
The model's `Generate` does the heavy lifting: it decides whether to call tools, the handler (from step 2 of setup) executes them, and the model produces a final answer. We never wrote any "if user wants email, call email service" logic. The LLM does that reasoning from the tool descriptions.
## The REPL
Wrap `ask` in a read-loop and you have a chat:
```go
scanner := bufio.NewScanner(os.Stdin)
for {
fmt.Print("> ")
if !scanner.Scan() {
return nil
}
line := strings.TrimSpace(scanner.Text())
switch line {
case "":
continue
case "exit", "quit":
return nil
case "reset":
hist.Reset()
continue
default:
if err := ask(ctx, m, hist, discovered, line); err != nil {
fmt.Printf("error: %v\n", err)
}
}
}
```
That's it. Discover tools, create a model, track history, loop. Four pieces.
## Why It's So Short
The brevity comes from the framework doing the right things:
- **Services are self-describing.** Doc comments become tool descriptions. The `@example` tag gives the LLM a usage hint. You don't hand-write tool schemas.
```go
// CreateUser creates a new user account.
// @example {"name": "Alice", "email": "alice@example.com"}
func (h *Users) CreateUser(ctx context.Context, req *pb.CreateRequest, rsp *pb.CreateResponse) error {
// ...
}
```
- **Providers are uniform.** Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud — all behind one `ai.Model` interface. Switching is one string.
- **Execution is wired automatically.** `ai.WithTools(tools)` connects tool calls to RPC dispatch. No glue.
If you stripped go-micro out and built this against raw HTTP services, you'd add maybe 50 lines: a function to enumerate your endpoints and a function to call one by name. Everything else stays the same.
## Make It Yours
The 150 lines are a starting point. Ideas for extending it:
- **Add a confirmation step** before destructive tool calls ("This will delete 3 records. Continue?")
- **Log every tool call** to an audit trail or your observability stack
- **Filter the tool list** so the agent only sees certain services
- **Swap the REPL for a Slack bot** — same `ask`, different input source
- **Pre-load a system prompt** with domain knowledge about your services
- **Trigger it from events** instead of stdin — that's exactly what [`micro flow`](/blog/9) does
The point of `micro chat` was never to be a finished product. It's a demonstration that turning services into an agent is a small, comprehensible amount of code — not a framework you have to learn, just a pattern you can copy.
## Try It, Then Read It
```bash
go install go-micro.dev/v5/cmd/micro@latest
micro run # start your services
ANTHROPIC_API_KEY=sk-ant-... micro chat --provider anthropic
```
The full source is [`cmd/micro/chat/chat.go`](https://github.com/micro/go-micro/blob/master/cmd/micro/chat/chat.go) — about 220 lines including flags, help text, and provider env-var handling. The agent core is the ~40 lines you saw above.
Build your own. It's more approachable than you think.
---
*Go Micro is an open source framework for distributed systems development. [Star us on GitHub](https://github.com/micro/go-micro).*
<div class="post-nav">
<div><a href="/blog/10">&larr; micro chat</a></div>
<div><a href="/blog/">All Posts</a></div>
<div><a href="/blog/12">Tools as Services &rarr;</a></div>
</div>
+137
View File
@@ -0,0 +1,137 @@
---
layout: blog
title: "Tools as Services: Why Go Micro Was Always Ready for AI"
permalink: /blog/12
description: "The path from API gateway to MCP to LLM tools was shorter than you'd think — because services were always self-describing."
---
# Tools as Services: Why Go Micro Was Always Ready for AI
<img src="/images/generated/architecture.jpg" alt="Services as tools" style="width: 100%; border-radius: 8px; margin: 1rem 0 1.5rem;" />
*May 30, 2026 • By the Go Micro Team*
When people see `micro chat` or the MCP gateway, they assume we built something new. We didn't. We exposed something that was already there.
Go Micro has always treated services as self-describing, addressable units. Every service registers its name, endpoints, and request types with the registry. Every endpoint is callable through a standardised path. The only thing that changed is *who's calling*.
## The Pattern
Go Micro was built on one idea: **a service should be accessible the same way regardless of how you access it**.
When we launched in 2015, that meant:
**HTTP API Gateway** — every service was reachable via `/{service}/{endpoint}`:
```
POST /users/Users.Create {"name": "Alice"}
```
No routing configuration. No URL mapping. You add a handler, it's accessible. The gateway reads the registry and routes.
**Web Dashboard** — every service appeared as a page. Endpoints were browseable, callable from the UI. Same registry, different presentation.
**CLI** — every service became a command:
```bash
micro call users Users.Create '{"name": "Alice"}'
```
Same registry, same endpoint, different interface. The service didn't change. The *access layer* changed.
## The Evolution to AI
MCP is just the next access layer.
**MCP Gateway** — every service is an AI-callable tool:
```json
{
"name": "users_Users_Create",
"description": "Create a new user account",
"parameters": {
"name": {"type": "string"},
"email": {"type": "string"}
}
}
```
Same registry. Same endpoints. Same service code. The gateway reads the registry, translates each endpoint into a tool definition, and exposes it over the Model Context Protocol. Claude, ChatGPT, or any MCP-compatible agent can discover and call your services — without you writing a single line of AI-specific code.
**`micro chat`** — every service is something you can talk to:
```
> create a user named Alice with email alice@example.com
→ users_Users_Create({"name":"Alice","email":"alice@example.com"})
Done. User Alice created.
```
Same registry. Same endpoints. The LLM reads the tool descriptions and decides what to call. The service doesn't know it's being called by an AI.
## Why This Works
The reason the AI integration was straightforward — not months of work, not a rewrite — is that Go Micro services were already:
1. **Named and discoverable.** The registry knows what's running and where.
2. **Self-describing.** Endpoints have typed request/response schemas. Doc comments on handlers become descriptions.
3. **Uniformly callable.** The client makes RPC calls by service name and endpoint name. It doesn't matter if the caller is an HTTP gateway, a CLI, or an LLM.
When we added MCP, we didn't add a new way to call services. We added a new way to *discover* them — one that LLMs understand. The calling mechanism was already there.
When we added `micro chat`, we didn't build an agent framework. We connected the existing tool discovery to an existing model interface and added a for-loop. The whole thing is [~150 lines](/blog/11).
## The Access Layer Pattern
Here's the mental model:
```
Service (Go handler + registry metadata)
↓ accessed via
API Gateway → HTTP clients
Web Dashboard → browsers
CLI → developers
MCP Gateway → AI agents
micro chat → natural language
```
Each layer does the same thing: read the registry, present the services in a format the consumer understands, and route calls back to the service. The service is oblivious. It just handles requests.
This is why we never needed an "agent package" or an "AI framework." The framework already had the right shape. Services were always tools — they just didn't know it yet.
## What Doc Comments Buy You
The one thing that changed: **documentation became functional.**
In the API gateway era, doc comments were nice to have. In the MCP era, they're what the LLM reads to decide which tool to call:
```go
// CreateUser creates a new user account with the given name and email.
// Returns the created user with a generated ID.
// @example {"name": "Alice", "email": "alice@example.com"}
func (h *Users) CreateUser(ctx context.Context, req *pb.CreateRequest, rsp *pb.CreateResponse) error {
```
The comment becomes the tool's description. The `@example` becomes the LLM's hint for what arguments look like. Good comments mean the AI picks the right tool. Bad comments mean it guesses.
This is a genuine incentive to write documentation — not because a human might read it, but because a machine *will* read it and make decisions based on it.
## What's Next
The pattern extends beyond what we've built:
- **`micro registry list`** already shows you what's running. An agent could use the same data to reason about service topology.
- **`micro broker subscribe`** streams events. An agent could monitor events and react — which is what `micro flow` does.
- **`micro store`** persists data. An agent could read and write state as part of a multi-step workflow.
Every framework primitive that has a CLI command could also be a tool. The registry, broker, store, and config interfaces are all accessible from the terminal *and* from code. Making them accessible to AI agents is the same step we've already taken for services.
The thesis hasn't changed since 2015: **build the service once, access it everywhere**. The "everywhere" just expanded to include AI.
---
*Go Micro is an open source framework for distributed systems development. [Star us on GitHub](https://github.com/micro/go-micro).*
<div class="post-nav">
<div><a href="/blog/11">&larr; Build Your Own AI Agent CLI</a></div>
<div><a href="/blog/">All Posts</a></div>
</div>
+180
View File
@@ -0,0 +1,180 @@
---
layout: blog
title: "From Prompt to Production: AI-Generated Microservices That Actually Run"
permalink: /blog/13
description: "micro run --prompt generates real services with business logic, compiles them, starts them, and lets you talk to them. When you need more, the agent builds new services mid-conversation."
---
# From Prompt to Production: AI-Generated Microservices That Actually Run
*June 3, 2026 &bull; By the Go Micro Team*
Every code generator stops at the same point: here's some files, good luck. You get a skeleton, wire it up yourself, hope it compiles. We wanted something different.
```bash
micro run --prompt "a task management system"
```
That's not scaffolding. Two services start, register, and respond to requests. An AI agent can create tasks, organize categories, and orchestrate across services immediately. And when you need a capability that doesn't exist — shipping, payments, notifications — the agent builds it mid-conversation.
## The Full Loop
**1. Describe** — tell it what you need in plain English.
```bash
micro run --prompt "a todo list with tasks and categories"
```
**2. Review** — the LLM designs the architecture. You see every service, field, and endpoint before a line of code is written.
```text
Services:
● category — Manages task categories
Create, Read, Update, Delete, List
● task — Core task management
Create, Read, Update, Delete, List, CompleteTask, GetOverdue
Generate? [Y/n]
```
**3. Generate** — proto files are written deterministically from the spec. Then the LLM writes each handler with real business logic. Not stubs. Not TODOs. Actual validation, edge cases, and persistent storage via go-micro's built-in store:
```go
func (s *Task) CompleteTask(ctx context.Context, req *pb.CompleteTaskRequest,
rsp *pb.CompleteTaskResponse) error {
if req.Id == "" {
return errors.New("id is required")
}
recs, err := s.store.Read("task/" + req.Id)
if err != nil || len(recs) == 0 {
return errors.New("task not found")
}
var task pb.TaskRecord
json.Unmarshal(recs[0].Value, &task)
if task.Completed {
rsp.Success = false
rsp.Message = "task already completed"
return nil
}
task.Completed = true
task.Updated = time.Now().Unix()
data, _ := json.Marshal(&task)
s.store.Write(&store.Record{Key: "task/" + req.Id, Value: data})
rsp.Record = &task
rsp.Success = true
return nil
}
```
Data survives restarts — generated services use go-micro's built-in store (file-backed by default, swappable to Postgres or NATS KV) instead of in-memory maps. Every handler compiles. If it doesn't, the errors are fed back to the LLM for correction. Most services compile on the first attempt.
**4. Run** — services start, register via mDNS, and an HTTP gateway comes up. Every endpoint is accessible via REST, gRPC, or MCP.
```text
Micro
Dashboard http://localhost:8080
API http://localhost:8080/api/{service}/{method}
Services:
● category
● task
micro chat --provider anthropic # talk to your services
```
## Talk To Your Services
```bash
micro chat --provider anthropic
> Create a Work category, then add a task called 'Finish report' to it
```
The agent discovers services from the registry, sees every endpoint as a tool, and orchestrates:
```text
→ category_Category_Create({"name":"Work","user_id":"user1"})
← {"record":{"id":"f633...","name":"Work"},"success":true}
→ task_Task_Create({"title":"Finish report","category_id":"f633..."})
← {"record":{"id":"a1b2...","title":"Finish report","status":"pending"}}
Created Work category and added 'Finish report' task to it.
```
No service-to-service calls. No distributed transactions. No saga patterns. The agent reads the result of one call and uses it as input to the next. Each service stays simple and independent.
This is the answer to the oldest problem in microservices: how do services coordinate? They don't. An intelligent agent does it for them.
## Growing the System
Here's where it gets interesting. You're chatting, the domain grows, and you need something that doesn't exist:
```text
> I need to track shipping for my orders. Create a shipment for order 123 to London.
⚡ generating service: a shipping service...
✓ task (unchanged)
✓ category (unchanged)
✓ shipping
⚡ starting shipping...
✓ 13 tools available
→ shipping_Shipping_Create({"order_id":"123","destination":"London"})
← {"record":{"id":"xyz...","status":"pending"}}
Created shipment for order 123 going to London.
```
The agent recognised that no shipping service exists, generated one, compiled it, started it, discovered its endpoints, and used them — all within the conversation. You didn't leave the chat. You didn't run a separate command. The system grew because you needed it to.
Each service stays small and focused. When you need more, you add more services. The agent orchestrates across whatever exists. If you're running `micro run`, it detects new service directories automatically and starts them — the loop is fully seamless.
## Iteration Without Destruction
Run the same prompt again — services whose proto hasn't changed are skipped entirely:
```text
✓ category (unchanged)
✓ task (unchanged)
```
Edit a handler by hand, and re-running preserves your changes. The system tracks SHA-256 hashes of generated files and only regenerates what's actually different.
The LLM sees existing service protos and extends the system rather than redesigning from scratch.
## What This Isn't
This isn't a no-code platform. The generated code is standard Go — you own it, edit it, version it, deploy it however you want. There's no vendor lock-in, no runtime dependency on an AI provider, no magic abstraction.
The services are real Go code with real interfaces. The same code you'd write by hand, generated in 30 seconds instead of 3 hours.
## Why This Matters
The barrier to microservices has always been ceremony. Proto definitions, handler scaffolding, service registration, build systems — hours of work before you can test a single endpoint.
The deeper problem was coordination. Services need to talk to each other, and every pattern for that (sagas, choreography, service mesh) adds complexity. Teams spend more time on infrastructure than on business logic.
`micro run --prompt` solves both. The AI handles the ceremony. The agent handles the coordination. You handle the domain.
```bash
micro run --prompt "an order system for dropshipping"
micro chat --provider anthropic
> Place an order for 5 units of SKU-123 shipping to London
```
That's a running system. Not a prototype. Not a demo. Services you can deploy, iterate on, and scale.
## Try It
```bash
go install go-micro.dev/v5/cmd/micro@latest
micro run --prompt "a task management system" --provider anthropic
micro chat --provider anthropic
```
The future of microservices isn't fewer services. It's making them so easy to create and compose that the architecture disappears. Services become tools. The agent becomes the interface. You focus on what matters: the domain.
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+105
View File
@@ -0,0 +1,105 @@
---
layout: blog
title: "Going All In on AI"
permalink: /blog/14
description: "Go Micro started as a microservices framework. It's becoming the way you build software that AI agents can use. Here's why we're making that bet."
---
# Going All In on AI
*June 4, 2026 &bull; Asim Aslam*
I started Go Micro in 2015 because building microservices in Go was too hard. Too much boilerplate, too many decisions before you could test a single endpoint. The idea was simple: sane defaults, pluggable architecture, get out of the developer's way.
Eleven years later, the framework works. Service discovery, RPC, pub/sub, config, store — all pluggable, all production-tested. 23,000 stars on GitHub. But let's be honest: the microservices ecosystem moved on. Kubernetes won infrastructure. gRPC became the default transport. Service meshes handle the network. The problems Go Micro originally solved are mostly solved.
So what's left?
## The next step
Every few years, something shifts the entire stack. Containers changed deployment. Kubernetes changed orchestration. LLMs are changing how software gets built and used.
Here's what we noticed: Go Micro services were already self-describing. Every service registers its name, endpoints, and request types with the registry. Every endpoint is callable through a standardised path. The only thing that changed is *who's calling* — instead of another service or an API gateway, it's an AI agent.
We added MCP support. Every Go Micro endpoint became an AI-callable tool automatically. No annotations, no wrapper code, no new framework. Just the same service registration that was always there, exposed through a different protocol.
Then we added `micro chat`. An LLM discovers your services, sees every endpoint, and orchestrates across them. No service-to-service calls. No distributed transactions. No saga patterns. The agent reads the result of one call and decides what to do next.
Then we added `micro run --prompt`. Describe a system in plain English, and the AI designs services, writes handlers with real business logic, compiles them, and starts them. Ask for something that doesn't exist mid-conversation, and the agent builds a new service on the fly.
Each step was small. Together, they changed what Go Micro is.
## What It Is Now
Go Micro is a framework for building microservices that AI agents can use.
That's not a pivot. It's an evolution. The same service discovery, RPC, and store abstractions that powered the framework since 2015 are what make the AI integration work. Services register — agents discover them. Endpoints are typed — agents know how to call them. Doc comments describe what things do — agents read them.
The difference is the entry point. Before:
```bash
micro new helloworld
# write handler code
# define proto
# compile proto
# go mod tidy
micro run
```
Now:
```bash
micro run --prompt "a task management system"
micro chat --provider anthropic
> Create a task called 'Ship order 123'
```
The services that come out are the same Go code. You can edit them, version them, deploy them the same way. The framework underneath hasn't changed. But the developer experience is fundamentally different — you go from idea to running services in under a minute.
## Why Now
Three things came together:
**LLM tool calling works.** A year ago, getting an LLM to reliably call the right function with the right arguments was unreliable. Now Claude, GPT-4, and even open models handle multi-step tool orchestration consistently. The technology caught up to the architecture.
**MCP is a real standard.** The Model Context Protocol gives us a wire format that every AI company is adopting. We're not building on a proprietary API — we're implementing an open protocol. Services exposed via MCP work with Claude, with ChatGPT, with any MCP-compatible agent.
**Sponsorship aligns with direction.** Anthropic and Atlas Cloud are sponsoring Go Micro because they see the same thing we do: developers need frameworks that make their code accessible to AI. This isn't a side project within the framework — it's the direction the sponsors are investing in.
## What We're Not Doing
We're not abandoning the framework. You can still `micro new helloworld`, write a handler by hand, and deploy it. The pluggable architecture isn't going anywhere. gRPC, NATS, Consul, Postgres — all still there, all still swappable.
We're also not building another agent framework. There's no LangChain-style chain abstraction, no workflow DSL, no agent graph. Services are the only abstraction. The LLM calls them as tools. That's it. The simplicity is the point.
What we are doing is making AI the primary way people interact with Go Micro. The README leads with `micro run --prompt`. The website leads with "Microservices That AI Agents Can Use." The CLI leads with generation and chat. If you're evaluating Go Micro today, the AI experience is the reason to choose it.
## What's Next
**Better generation.** Services currently use in-memory or file-backed storage. We're moving to persistent store by default, with the full store interface (Postgres, NATS KV) available from day one.
**Smarter agents.** The chat agent currently generates services when capabilities are missing. Next: it should be able to modify existing services, add endpoints, update business logic — all from the conversation.
**Production hardening.** Auth on generated services, rate limiting on the MCP gateway, observability built in. The generation gets you to 80%. The framework gets you to production.
**More providers, more models.** Seven providers today. The `ai.Model` interface makes adding more trivial. As open models get better at tool calling, the barrier to entry drops further.
## The Opportunity
Most microservices frameworks are fighting over the same developers with the same features. Better gRPC support, better Kubernetes integration, better observability hooks. Important work, but incremental.
The AI shift is not incremental. It changes who builds services (anyone who can describe what they need), how services get composed (agents, not code), and how fast systems evolve (minutes, not sprints).
Go Micro is ten years old. It's survived containers, Kubernetes, service meshes, and serverless. Each time, the framework adapted because the core abstraction — small, self-describing, independently deployable services — turned out to be what the next wave needed.
AI agents need exactly that. Small services with typed interfaces that can be discovered, called, and composed. That's what Go Micro has always been. We're just leaning into it.
```bash
curl -fsSL https://go-micro.dev/install.sh | sh
micro run --prompt "your idea here" --provider anthropic
micro chat --provider anthropic
```
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+147
View File
@@ -0,0 +1,147 @@
---
layout: blog
title: "Agents for Services: A New Model for Microservices"
permalink: /blog/15
description: "What if every service had an agent responsible for it? Not embedded in the service, but created to manage its lifecycle. A design for distributed AI agents on top of microservices."
---
# Agents for Services: A New Model for Microservices
*June 4, 2026 &bull; Asim Aslam*
Microservices solved monolithic code. Split it into small, independent units. But we centralised the intelligence — one agent, one brain, managing everything. That's just a different kind of monolith.
What if every service had its own agent? Not embedded in the service. Created separately, assigned to manage it. The service is the capability. The agent is the intelligence.
## The Problem with One Agent
Right now, `micro chat` is a single agent. It discovers every service in the registry, sees every endpoint, and orchestrates across all of them. You ask it a question and it figures out which service to call.
This works for small systems. Two or three services, a dozen endpoints — one agent can handle it. But it doesn't scale the way microservices are supposed to scale.
A single agent managing ten services is like one person running ten departments. They know a little about everything and a lot about nothing. They can't hold the full context of each domain. They make shallow decisions because they're spread too thin.
This is the same centralisation problem that microservices were designed to solve. We distributed the services but centralised the intelligence.
## An Agent Per Service
What if each service had its own agent? Not inside the service — separate from it, but created to manage it.
The service is the capability. It has endpoints, stores data, handles requests. It doesn't know or care whether an agent exists.
The agent is the intelligence. It knows the service's domain deeply — what the endpoints do, how they relate, what the edge cases are, when to use which operation. It makes decisions about its service the way a domain expert would.
```
You (the creator)
└── Agent: task-agent
│ └── Service: task
└── Agent: project-agent
│ └── Service: project
└── Agent: notification-agent
└── Service: notification
```
When you talk to the system, you don't talk to the services. You don't even talk to a single central agent. Your message reaches the right agent — the one responsible for that domain — and it handles it using its service.
## Multi-Service Agents
Some agents manage more than one service. A "project management agent" might oversee both the task service and the project service because they're part of the same domain. It understands how projects contain tasks, how task completion affects project progress, how to coordinate across both.
```
You
└── Agent: project-management
│ ├── Service: task
│ └── Service: project
└── Agent: communication
├── Service: notification
└── Service: email
```
The agent boundaries don't have to match the service boundaries. A service is a technical unit — one concern, one data store, one set of endpoints. An agent is a domain unit — it might span multiple services because the domain spans them.
This is the separation that matters: **services are about capability, agents are about intelligence.**
## How They Communicate
Agents are services. They communicate via standard RPC — the same way every service in the system communicates. An agent has a proto-defined `Agent.Chat` endpoint that any other agent or client can call.
When the project management agent needs to notify someone, the router dispatches to the communication agent via RPC. Each agent handles its domain.
```
> Reschedule all of Alice's tasks to next week and let her know
[project-management] Rescheduling 3 tasks for Alice...
[project-management] → task.Update(id: "t1", due: "2026-06-11")
[project-management] → task.Update(id: "t2", due: "2026-06-12")
[project-management] → task.Update(id: "t3", due: "2026-06-13")
[project-management] Notifying communication agent...
[communication] Sending schedule change email to Alice...
[communication] → notification.Create(user: "alice", message: "3 tasks rescheduled")
[communication] → email.Send(to: "alice@example.com", subject: "Tasks rescheduled")
```
Each agent handles its domain. The user sees one conversation. Underneath, multiple agents are collaborating, each using their own services.
## What Agents Do
An agent isn't just a router to endpoints. It has responsibilities:
**Functional** — it's the intelligent interface to its services. It knows which endpoint to call, in what order, with what parameters. It handles the "how" so the user only needs to express the "what."
**Evolutionary** — it can grow its services. If the task agent realises it needs a "recurring tasks" capability that doesn't exist, it can generate a new endpoint or a new service. The agent evolves the system based on what users need.
**Operational** — it monitors its services. Health checks, error rates, log patterns. If the task service starts failing, the task agent notices and can act — restart it, alert the operator, degrade gracefully.
These layers build on each other. Functional is where we start. Evolutionary is what makes the system grow. Operational is what makes it production-ready.
## The Service Stays Simple
This is the critical point: **the service doesn't change.** It's still a Go struct with methods. It still registers with the registry. It still stores data in the store. It still communicates via RPC.
```go
func main() {
service := micro.NewService("task")
service.Handle(new(TaskHandler))
service.Run()
}
```
The agent is a separate entity, created by the operator — or by `micro run --prompt` — to manage that service. The service has no dependency on the agent. You can run services without agents. You can swap agents without touching services. You can have one agent managing three services, or three agents managing one service from different angles.
The service is the body. The agent is the mind assigned to it.
## What This Looks Like in Micro
The pieces already exist:
- **Registry** — agents register as services with a proto-defined `Agent.Chat` endpoint. No special metadata hacks — they're real services.
- **RPC** — `micro chat` calls agents via standard RPC. Agents call services via standard RPC. Agent-to-agent communication is just RPC. One protocol for everything.
- **Store** — agents persist memory (conversation history, learned context) across restarts.
- **`micro chat`** — discovers agents from the registry and routes to the right one based on intent.
- **`micro run --prompt`** — generates services and an agent as a pair. Everything starts together.
The framework was built for distributed systems. Agents are just the next thing we distribute.
## The Bigger Picture
Microservices solved the problem of monolithic code — split it into small, independent units that can evolve separately. But they created a new problem: how do the pieces coordinate?
We tried solving coordination with code — service-to-service calls, sagas, choreography, orchestration engines. Each solution added complexity. The coordination layer became harder to build than the services themselves.
Agents solve coordination differently. They don't coordinate through code. They coordinate through understanding. An agent that manages the project domain understands what "reschedule Alice's tasks" means. It doesn't need a workflow definition or a saga pattern. It just knows.
And when you have multiple agents, each understanding their domain, the system coordinates itself. Not through service-to-service calls. Not through a central orchestrator. Through distributed intelligence.
That's the model. Services for capability. Agents for intelligence. The framework connects them.
```bash
micro run --prompt "task management system"
micro chat
> What's overdue this week?
```
The agent for your services answers. Not because you wired it up. Because the system understands what you built.
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+183
View File
@@ -0,0 +1,183 @@
---
layout: blog
title: "Introducing micro.NewAgent()"
permalink: /blog/16
description: "Agent is now a first-class abstraction in Go Micro — alongside Service and Flow. Build intelligent agents that manage your services in Go."
---
# Introducing micro.NewAgent()
*June 5, 2026 &bull; By the Go Micro Team*
Go Micro now has three core abstractions:
```go
service := micro.NewService("task") // capability
agent := micro.NewAgent("task-mgr") // intelligence
flow := micro.NewFlow("onboard-user") // event-driven orchestration
```
Service has been the foundation since 2015. Flow added event-driven LLM orchestration. Now Agent completes the picture — an intelligent layer that manages services, with scoped tools, persistent memory, and multi-agent coordination.
## What an Agent Is
A Service has endpoints and handles requests. An Agent knows *how* to use those endpoints intelligently. The service doesn't know about its agent. The agent knows about its services.
```go
agent := micro.NewAgent("task-mgr",
micro.AgentServices("task", "project"),
micro.AgentPrompt("You manage tasks and projects. You understand deadlines, priorities, and assignments."),
micro.AgentProvider("anthropic"),
)
agent.Run()
```
That's it. The agent:
- Discovers `task` and `project` from the registry
- Only sees their endpoints (scoped tools — no access to unrelated services)
- Maintains conversation memory in the store (survives restarts)
- Registers as a real service with a proto-defined `Agent.Chat` RPC endpoint
- Discoverable by `micro chat`, other agents, or any go-micro client
Under the hood, an agent IS a service. It has a real server, a real address, and a real proto definition:
```protobuf
service Agent {
rpc Chat(ChatRequest) returns (ChatResponse) {}
}
```
This means you can call an agent the same way you call any service:
```bash
micro call task-mgr Agent.Chat '{"message": "What tasks are overdue?"}'
```
## Talking to an Agent
Programmatically:
```go
resp, _ := agent.Ask(ctx, "What tasks are overdue for Alice?")
fmt.Println(resp.Reply)
```
Via the CLI:
```bash
micro agent list
◆ task-mgr manages: task, project
micro chat
> What tasks are overdue for Alice?
[task-mgr] Checking overdue tasks...
→ task_Task_ListOverdue({"user_id":"alice"})
{"records":[...],"total":"3"}
Alice has 3 overdue tasks:
1. Write quarterly report (due June 1)
2. Review PR #42 (due June 2)
3. Update deployment docs (due June 3)
```
`micro chat` discovers the agent from the registry and routes to it automatically. If multiple agents are registered, the router classifies intent and dispatches to the right one.
## Multi-Service Agents
An agent can manage multiple services that form a domain:
```go
agent := micro.NewAgent("project-mgr",
micro.AgentServices("task", "project", "milestone"),
micro.AgentPrompt("You manage the project system. Tasks belong to projects. Milestones track progress."),
micro.AgentProvider("anthropic"),
)
```
The agent understands the relationships between its services because its prompt gives it domain knowledge. It coordinates across them without the services needing to know about each other.
## Multi-Agent Systems
Multiple agents coordinate via RPC — each is a service with an `Agent.Chat` endpoint:
```go
// Task management agent
taskAgent := micro.NewAgent("task-mgr",
micro.AgentServices("task", "project"),
micro.AgentPrompt("You manage tasks and projects."),
micro.AgentProvider("anthropic"),
)
// Communications agent
commsAgent := micro.NewAgent("comms-mgr",
micro.AgentServices("notification", "email"),
micro.AgentPrompt("You handle notifications and emails."),
micro.AgentProvider("anthropic"),
)
```
When you ask `micro chat` to "reschedule Alice's tasks and notify her," the router dispatches to both agents. Each handles its domain. The user sees one conversation.
## Persistent Memory
Agents remember. Conversation history is stored in the go-micro store and persists across restarts:
```text
agent/task-mgr/history — conversation history
```
The store backend determines durability — file-backed by default, Postgres or NATS KV for production. An agent that restarts picks up where it left off.
## The Three Abstractions
| | Service | Agent | Flow |
|---|---------|-------|------|
| **What** | Capability | Intelligence | Event orchestration |
| **Does** | Handles requests | Manages services | Reacts to events |
| **Knows** | Its endpoints | Its services' endpoints | Its trigger topic |
| **State** | Store | Store-backed memory | Checkpointed run history |
| **Create** | `micro.NewService("name")` | `micro.NewAgent("name")` | `micro.NewFlow("name")` |
| **Package** | `service/` | `agent/` | `flow/` |
They compose:
- A **Service** handles requests and stores data
- An **Agent** orchestrates one or more services intelligently
- A **Flow** triggers LLM orchestration when events arrive on the broker
You can use any combination. Services work without agents. Agents work without flows. Each adds a layer.
## Getting Started
```bash
curl -fsSL https://go-micro.dev/install.sh | sh
# Generate services
micro run --prompt "task management system"
# In another terminal — talk to them
micro chat --provider anthropic
> Create a project called Launch and add three tasks to it
```
Or build an agent in Go:
```go
package main
import "go-micro.dev/v6"
func main() {
agent := micro.NewAgent("task-mgr",
micro.AgentServices("task"),
micro.AgentPrompt("You manage tasks."),
micro.AgentProvider("anthropic"),
)
agent.Run()
}
```
The agent implementation lives under `go-micro.dev/v6/agent`; most users create agents through the top-level `go-micro.dev/v6` API. The full interface design is documented in [AGENT_DESIGN.md](https://github.com/micro/go-micro/blob/master/internal/docs/AGENT_DESIGN.md).
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+168
View File
@@ -0,0 +1,168 @@
---
layout: blog
title: "Agents That Plan and Delegate"
permalink: /blog/17
description: "An agent shouldn't just react tool by tool. It should form intent — plan what it's doing — and direct intent — delegate what it shouldn't do itself. Go Micro now gives every agent both, as plain tools."
---
# Agents That Plan and Delegate
*June 7, 2026 &bull; By the Go Micro Team*
When we [introduced `micro.NewAgent()`](/blog/16), an agent was already a service with an LLM inside: scoped tools, persistent memory, and a `micro chat` router that dispatches across agents. And in [Agents for Services](/blog/15) we made the case that intelligence should be distributed — agents coordinate "not through code… through understanding."
This post is the next beat. An agent that only reacts, one tool call at a time, isn't really understanding anything — it's improvising. Two things turn reaction into intent: the agent should **plan** what it's doing before it does it, and **delegate** what it shouldn't be doing itself. Go Micro now gives every agent both.
True to the rest of the framework, they aren't a new layer. There's no harness, no workflow engine, no agent graph. `plan` and `delegate` are two ordinary tools — the LLM calls them exactly like it calls a service endpoint — added automatically to every agent. (If you've followed what everyone from Claude Code to LangChain calls "deep agents," this is the same idea, built the go-micro way: as tools, not as a framework.)
## The smallest version
An agent doesn't need any services to plan. Here's a complete program:
```go
package main
import (
"context"
"fmt"
"os"
"go-micro.dev/v5"
)
func main() {
a := micro.NewAgent("assistant",
micro.AgentProvider("anthropic"),
micro.AgentAPIKey(os.Getenv("ANTHROPIC_API_KEY")),
)
resp, err := a.Ask(context.Background(),
"Plan how to launch a product, then carry out what you can.")
if err != nil {
panic(err)
}
fmt.Println(resp.Reply)
}
```
Save it in a fresh module and run:
```bash
mkdir my-agent && cd my-agent
go mod init my-agent
go get go-micro.dev/v5
# save the code above as main.go
export ANTHROPIC_API_KEY=sk-ant-...
go run main.go
```
The agent records a plan with the `plan` tool, then works through it. That's the whole setup.
## plan: stating intent
`plan` is exactly what it sounds like: before multi-step work, the model writes down an ordered list of steps, and updates it as it goes.
```json
{
"steps": [
{"task": "draft the announcement", "status": "in_progress"},
{"task": "schedule the email", "status": "pending"},
{"task": "publish the blog post", "status": "pending"}
]
}
```
This builds directly on the memory we [already shipped](/blog/16): the plan is saved to the same [store](/docs/store) every service uses — file-backed by default, Postgres or NATS KV in production — under `agent/{name}/plan`, and folded back into the system prompt on the next turn. The agent stays oriented across a long task and picks up where it left off after a restart.
You get it for free. To make an agent reliably plan, just say so in its prompt:
```go
micro.AgentPrompt("For multi-step requests, call the plan tool first to record your steps, then carry them out.")
```
## delegate: directing intent
The harder move is knowing what *not* to do yourself.
A single agent managing ten services is a different kind of monolith — it knows a little about everything and a lot about nothing. We argued in [Agents for Services](/blog/15) that the fix is the same one microservices made for code: distribute it. Give each domain its own agent, and let them hand work to each other over RPC.
That hand-off already existed — an agent is a service, so any agent can call any other agent's `Agent.Chat` endpoint. `delegate` simply lets the agent reach for it *as part of its own reasoning*, instead of you wiring the routing. The model calls `delegate` with a subtask, and Go Micro resolves it **delegate-first**:
1. **If the target names a registered agent** that owns the relevant services, the subtask goes to it over RPC. The domain expert handles its own services.
2. **Otherwise** a focused, short-lived **sub-agent** is created for just that subtask, with a fresh, isolated context, and torn down when it's done.
```json
{
"task": "Notify owner@acme.com that the launch plan is ready",
"to": "comms"
}
```
One design decision worth calling out: we didn't add a "spawn" or a "fork" primitive. **A sub-agent is just an agent** — created with `New`, talked to with `Ask`, the same two calls you already use. There's no new concept to learn, because there's no new concept: it's the existing RPC model, surfaced as a tool. Ephemeral sub-agents load and persist no history and get no tools of their own — so they can't plan or re-delegate, which keeps delegation from recursing.
## Putting it together
Two services (`task`, `notify`) and two agents. The `conductor` owns `task`; `comms` owns `notify`. Ask the conductor to create some tasks and notify someone, and watch intent split across the system:
```go
comms := micro.NewAgent("comms",
micro.AgentServices("notify"),
micro.AgentPrompt("You handle outbound notifications."),
micro.AgentProvider("anthropic"),
micro.AgentAPIKey(key),
)
go comms.Run()
conductor := micro.NewAgent("conductor",
micro.AgentServices("task"),
micro.AgentPrompt(
"For multi-step requests, call the plan tool first. "+
"For notifications, delegate to the \"comms\" agent (to: \"comms\")."),
micro.AgentProvider("anthropic"),
micro.AgentAPIKey(key),
)
resp, _ := conductor.Ask(ctx,
"Create three launch tasks: Design, Build, and Ship. "+
"Then make sure owner@acme.com is notified that the launch plan is ready.")
```
A typical run:
```
→ plan({"steps":[{"task":"create Design task","status":"pending"}, ...]})
→ task_TaskService_Add({"title":"Design"})
→ task_TaskService_Add({"title":"Build"})
→ task_TaskService_Add({"title":"Ship"})
→ delegate({"task":"Notify owner@acme.com that the launch plan is ready","to":"comms"})
📨 notify: to=owner@acme.com message="The launch plan is ready"
```
The conductor never learned how to send a notification. It learned *who does*. `comms` handled it with its own service, in its own context, over RPC — exactly the distributed-intelligence picture from [blog 15](/blog/15), now driven by the agent itself rather than a router.
The full runnable code is in [examples/agent-plan-delegate](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate). Set any provider key (`ANTHROPIC_API_KEY`, `OPENAI_API_KEY`, …) and `go run main.go`.
## Why it's only two tools
It would have been easy to ship a planning engine, a sub-agent scheduler, a delegation graph. We didn't, on purpose. Every one of those is a new abstraction to learn and maintain, and Go Micro's bet has been consistent since we [went all in on AI](/blog/14): services are the only abstraction, the LLM calls them as tools, and an agent's own capabilities are no exception.
`plan` and `delegate` are two small tools added to mechanisms that already existed — the store, and agent-to-agent RPC. That's the entire feature. It's also why there's nothing to configure: if you've written a `micro.NewAgent`, you already have them.
## Getting started
The fastest way to see it end to end is the runnable example in the repo — it has the module set up and both agents wired:
```bash
git clone https://github.com/micro/go-micro
cd go-micro/examples/agent-plan-delegate
export ANTHROPIC_API_KEY=sk-ant-... # or OPENAI_API_KEY, GEMINI_API_KEY, ...
go run main.go
```
To start from scratch in your own project, use the smallest-agent snippet above (`go mod init` + `go get go-micro.dev/v5`).
Read the [Plan & Delegate guide](/docs/guides/plan-delegate) for the full reference, or the [agent patterns guide](/docs/guides/agent-patterns) for where this fits among the other ways to build with agents.
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+93
View File
@@ -0,0 +1,93 @@
---
layout: blog
title: "Not Everything Should Be an Agent"
permalink: /blog/18
description: "We spent two posts on agents that plan and delegate. Here's the other half: when the path is known, you want a workflow — predictable, event-driven, deterministic. In Go Micro they're the same building blocks, two modes."
---
# Not Everything Should Be an Agent
*June 8, 2026 &bull; By the Go Micro Team*
The last two posts were about agents — [the abstraction](/blog/16), and then [agents that plan and delegate](/blog/17), directing their own work over many turns. That's the exciting part. It's also, honestly, the part you should reach for *least often*.
An agent decides its own path at runtime. That's powerful when the task genuinely needs it, and a liability when it doesn't — you trade predictability, latency, and cost for flexibility you may not want. Most real work has a known shape: *when this event happens, do these things.* For that, you don't want a model improvising. You want a **workflow**.
The distinction is the one Anthropic draws in [Building Effective Agents](https://www.anthropic.com/engineering/building-effective-agents): a **workflow** is LLMs and tools orchestrated through *predefined* paths; an **agent** is an LLM *dynamically directing* its own process. Determinism is the dividing line. Go Micro has both — and they're the same building blocks underneath.
## A workflow is a Flow
In Go Micro, the predefined-path side is a `Flow`. It subscribes to an event and runs one defined step: a prompt, with your services available as tools.
```go
f := micro.NewFlow("onboard-user",
micro.FlowTrigger("events.user.created"),
micro.FlowPrompt("New user {{.Data}} — create a workspace and send a welcome email."),
micro.FlowProvider("anthropic"),
)
```
When a `user.created` event lands on the broker, the flow fires. There's no open-ended loop, no self-direction — a known trigger runs a known step. You can read exactly what it will do. That's the point.
## Same building blocks, two modes
Here's what makes this coherent rather than two competing systems: a workflow and an agent are built from the *same* primitive — the augmented LLM. A model, with every service endpoint already available as a tool, and the store as memory. Go Micro gives you that for free; every endpoint is a tool the moment a service registers.
The only difference is **who decides the path**:
- **You decide it** → a workflow (`Flow`). The trigger and the step are fixed.
- **The model decides it** → an agent (`Agent`). It plans, calls tools, evaluates, and chooses the next step.
It's not two frameworks. It's one set of pieces, pointed two ways.
## Flow triggers, Agent reasons
Sometimes a workflow's step genuinely needs judgment — the path isn't fully knowable in advance. You don't have to choose globally. A flow can *hand off* to an agent: the workflow stays the deterministic trigger, and the agent does the open-ended part.
```go
f := micro.NewFlow("onboard-user",
micro.FlowTrigger("events.user.created"),
micro.FlowPrompt("New user {{.Data}} — get them set up."),
micro.FlowAgent("conductor"), // the flow triggers; the conductor agent reasons
)
```
Now the event fires the flow, the flow renders the prompt, and a registered `conductor` agent handles it over RPC — with its full toolkit: [plan, delegate](/blog/17), memory, and guardrails. **Flow triggers, Agent reasons.** The deterministic and dynamic halves compose along one clean seam, because an agent is just a service and the hand-off is just an RPC.
## Predictability, even in agents
Choosing an agent doesn't mean giving up control. Anthropic is emphatic that autonomous agents need stopping conditions and human checkpoints, and Go Micro's agent has both — as plain options, not a framework:
```go
micro.NewAgent("conductor",
micro.AgentServices("task"),
micro.AgentMaxSteps(8), // a stopping condition
micro.AgentApproveTool(approveBilling), // a human-in-the-loop gate
)
```
`MaxSteps` bounds how many actions the agent may take. `ApproveTool` gates each action before it runs — return `false` and it's blocked, with the reason fed back to the model. These are guardrails: a counter and a callback on the path every tool call already takes. No new abstraction.
## Which one do you reach for?
The honest order, smallest first:
1. **A single model call.** Most tasks need nothing more — one augmented LLM, one service call. Start here.
2. **A workflow (`Flow`).** When the path is well-defined and you want it to be predictable and event-driven.
3. **An agent (`Agent`).** When the task genuinely needs flexibility and model-driven decisions — and you accept the cost, and add the guardrails.
The mistake is starting at 3. Agents are the most capable tool and the easiest to over-apply. Reach for the simplest thing that does the job, and move up only when the job demands it.
## One set of pieces
We didn't build a workflow engine and an agent framework. We built services that are tools, and then pointed an LLM at them two ways — a predefined path, or a dynamic one. `Flow` and `Agent` are modes, not frameworks, and they compose because they share everything underneath. That's the same principle we've held since [going all in on AI](/blog/14): services are the only abstraction, the LLM calls them as tools, and everything else is how you arrange them.
Read the [Agents and Workflows guide](/docs/guides/agents-and-workflows) for the full mapping, or [Plan & Delegate](/docs/guides/plan-delegate) for the agent side.
```bash
curl -fsSL https://go-micro.dev/install.sh | sh
```
---
*Go Micro is open source. Star us on [GitHub](https://github.com/micro/go-micro), join the [Discord](https://discord.gg/G8Gk5j3uXr), or read the [docs](https://go-micro.dev/docs).*
+72
View File
@@ -0,0 +1,72 @@
---
layout: blog
title: "The Evolution of Microservices"
permalink: /blog/19
description: "From the scaling pressures that produced microservices, through Kubernetes and the service mesh, to AI agents — fifteen years of evolution, what actually endured, and why the future belongs to agents."
---
# The Evolution of Microservices
*June 8, 2026 &bull; Asim Aslam*
Every era of distributed systems has solved the problem the previous era created. Microservices solved the coordination cost of the monolith and created a distributed-systems problem. Containers and orchestration solved the deployment problem and created an operational one. The service mesh solved the cross-cutting problem and pushed complexity into the platform. Each step was a response to a concrete engineering constraint, not a trend.
Follow that chain to the end, and the interface the industry converged on — a named, typed, discoverable, independently deployable unit — turns out to be almost exactly the interface a language model needs to call a tool. That convergence is the technical reason agents are the next era.
## The monolith and the cost of coordination
A monolith is one deployable, one process, one database, one release cadence. In-process calls, a single transaction boundary, no network in the path — technically efficient. Its limits are organisational, not technical.
As the number of engineers grows, the cost of coordinating changes to a single artifact grows faster than linearly. Everyone shares a build, a test suite, a deploy. A change in one corner can block a release in another. This is Conway's law stated as a constraint: a system's structure ends up mirroring the communication structure of the org that builds it, and a single shared artifact forces a single shared communication channel. Microservices were the response: let teams own and release their part independently. The decomposition was organisational first and technical second.
## Microservices and the distributed-systems tax
The moment you split a process across the network, you inherit the network. Calls that were function invocations become RPCs that can be slow, reordered, duplicated, or simply not return. You now have partial failure — the defining property of a distributed system — and with it: service discovery (where is `payments` right now?), load balancing across instances, retries with backoff, idempotency, timeouts, circuit breaking to stop cascading failure, and distributed tracing because no single stack trace spans the request anymore.
The first wave of answers was libraries. Netflix open-sourced Eureka (discovery), Ribbon (client-side load balancing), and Hystrix (circuit breaking); Twitter built Finagle. The defining trait of this era: the distributed-systems concerns lived *inside your application process*, as code you imported. Which meant every language needed its own copy, and every service carried the weight.
## Containers and orchestration
Two innovations made "independently deployable" actually cheap.
Docker (2013) standardised the unit of deployment as an immutable image — application plus dependencies, built once, run anywhere, isolated with namespaces and cgroups. It killed "works on my machine" and made a service a reproducible artifact rather than a deploy procedure.
Kubernetes (2014) standardised operation. Its core idea is declarative reconciliation: you describe desired state, and a control loop continuously drives the system toward it — scheduling, restarting, scaling, rolling out. Operating hundreds of independently deployable services became tractable, because lifecycle became the platform's job, not yours. The unit the platform scheduled was a container exposing declared ports — a named thing with an interface.
## The service mesh
By 2016 the pattern was clear: discovery, load balancing, retries, circuit breaking, mTLS, and telemetry are *cross-cutting and language-agnostic*. Reimplementing them as a library in every language is waste. So move them out of the process entirely.
The mechanism was the sidecar proxy — Envoy, out of Lyft — deployed next to each service, intercepting all traffic, with a central control plane (Istio) configuring it. The application stopped needing resilience libraries; the mesh did discovery and routing at L4/L7, transparently. Technically this was a clean decoupling of operational concerns from business logic. Practically, it hollowed out the library era: the value that lived in Netflix OSS migrated into infrastructure, and the service got thinner.
## The correction
Around 2020 the trade-offs drew a harder look. Microservices have a real cost: serialization, network latency, eventual consistency, and the cognitive load of debugging across process boundaries. If your org isn't large enough to need independent deployability, you pay the distributed-systems tax and get little of the team-autonomy benefit. Amazon's Prime Video team published a workload they moved from orchestrated distributed components back to a single process and cut cost ~90% — because for a tight, high-throughput loop, the serialization and orchestration overhead dwarfed the work.
The lesson wasn't "microservices were wrong." It was that *micro* was always a distraction. The thing worth paying for was independent deployability and clear ownership; granularity is a workload decision, and the cost is real.
## What endured
Strip away the runtime churn — images, schedulers, sidecars — and the same primitive sits underneath every era: a service is a **named, network-addressable, typed, independently deployable unit**. It announces itself (registration), it's locatable (discovery), it exposes a contract (typed endpoints with schemas), and it can be deployed and scaled on its own.
This shape never changed, and that's not an accident. Each new runtime layer *needed* something with exactly this shape to operate on. Kubernetes schedules units with declared interfaces. The mesh routes to named endpoints. Tracing correlates typed calls. The industry kept rebuilding the runtime and kept requiring the same unit, because the unit was the stable interface every layer agreed on.
## The caller changes
For fifteen years, the consumer of that typed interface was deterministic code: another service, a gateway, a client. The contract was machine-to-machine on a fixed integration written ahead of time.
Then language models learned to call functions reliably. Given a set of capabilities described as name, purpose, and a typed parameter schema, a model can choose which to invoke and produce well-formed arguments, and chain them toward a goal stated in natural language.
What an LLM needs in order to use a capability is specific: a name, a description of what it does, and a typed input/output contract. That is the definition of a service endpoint. A registration is a tool definition. Service discovery is tool discovery. The Model Context Protocol (Anthropic, 2024) is, stripped down, a discovery-and-invocation protocol for model-callable capabilities — service discovery and RPC, with a model on the other end. No one designed the microservices interface for models, but it already provides what they need.
So the shift is not a new architecture. It is a change of caller: from a deterministic program that was integrated in advance, to a probabilistic reasoner that decides at runtime which typed capabilities to compose, and in what order, from intent.
## Why agents are the future
The hard part of distributed systems was never building a capability. It was everything *between* capabilities — the integration and orchestration. Composing services into a workflow meant writing the glue: sagas, choreography, retries-with-meaning, the orchestration code that encodes "do A, then B, and if C fails compensate." That glue is where most distributed-systems effort and most distributed-systems bugs live.
An agent attacks exactly that layer. Given the available tools and a goal, it can compose them dynamically — read the contracts, plan a sequence, call, observe, adapt — without that sequence being written ahead of time. The orchestration shifts from code you author to a decision the model makes against typed capabilities. The interface to software moves from fixed APIs invoked by code to capabilities invoked by intent. It changes where the work is: from writing capabilities and their glue, to exposing capabilities cleanly and letting a reasoner compose them.
The caveats are technical. Agents are non-deterministic, higher-latency, and more expensive per call than a function invocation, and they need guardrails — stopping conditions, approval gates, sandboxing — precisely because they decide at runtime. So agents do not replace deterministic services or workflows; they sit on top of them. When the path is known, you still want fixed code. The substrate is unchanged: you still need well-defined, typed, discoverable, independently deployable capabilities. Agents don't make that substrate obsolete — they make it the most valuable layer in the stack, because a reasoner is only as good as the capabilities it can call.
So agents are the next era, not a replacement for what came before. The architecture the last fifteen years produced was not built for language models; it converged, era by era, on the exact interface they require to act. Each wave changed the runtime and left the unit intact, because the unit was what the next wave needed. The only thing new in this wave is the caller: it reasons about which units to invoke, instead of being wired to them in advance.
+491
View File
@@ -0,0 +1,491 @@
---
layout: blog
title: Making Microservices AI-Native with MCP
permalink: /blog/2
description: Expose go-micro services as AI tools with 3 lines of code using the Model Context Protocol
---
# Making Microservices AI-Native with MCP
<img src="/images/generated/blog-mcp.jpg" alt="Making Microservices AI-Native with MCP" style="width: 100%; border-radius: 8px; margin: 1rem 0 1.5rem;" />
*February 11, 2026 • By the Go Micro Team*
We're excited to announce **MCP (Model Context Protocol) support** in Go Micro v5.15.0 — making your microservices instantly accessible to AI tools like Claude.
## The Vision
Imagine telling Claude: *"Why is user 123's order stuck?"*
Claude responds by:
1. Calling your `users` service to check the account
2. Calling your `orders` service to inspect the order
3. Calling your `payments` service to verify the transaction
4. Giving you a complete diagnosis
**No API wrappers. No manual integrations. Your services just work with AI.**
## What is MCP?
[Model Context Protocol](https://modelcontextprotocol.io) is Anthropic's open standard for connecting AI models to external tools. Think of it like a microservices registry, but for AI.
With MCP, your go-micro services become **tools** that Claude can discover and call directly.
## The Integration
### For Library Users (Just Add Comments!)
```go
package main
import (
"context"
"go-micro.dev/v5"
"go-micro.dev/v5/gateway/mcp"
)
type UserService struct{}
// GetUser retrieves a user by ID. Returns user profile with email and preferences.
//
// @example {"id": "user-123"}
func (s *UserService) GetUser(ctx context.Context, req *GetUserRequest, rsp *GetUserResponse) error {
// implementation
return nil
}
type GetUserRequest struct {
ID string `json:"id" description:"User's unique identifier"`
}
type GetUserResponse struct {
User *User `json:"user" description:"The user object"`
}
func main() {
service := micro.NewService(micro.Name("users"))
service.Init()
// Register handler - docs extracted automatically from comments!
service.Server().Handle(service.Server().NewHandler(new(UserService)))
// Add MCP gateway
go mcp.Serve(mcp.Options{
Registry: service.Options().Registry,
Address: ":3000",
})
service.Run()
}
```
That's it. Your service is now AI-accessible **with automatic documentation**.
### For CLI Users (Just a Flag)
```bash
# Development with MCP
micro run --mcp-address :3000
# Production with MCP
micro server --mcp-address :3000
```
The CLI integration uses the same underlying library, so you get the same functionality either way.
## How It Works
1. **Service Discovery**: MCP gateway queries your registry (mdns/consul/etcd)
2. **Auto-Exposure**: Each service endpoint becomes an MCP tool
3. **Schema Conversion**: Request/response types → JSON Schema for AI
4. **Dynamic Updates**: New services appear as tools automatically
For example, if you have:
```go
type UsersService struct{}
func (u *UsersService) Get(ctx context.Context, req *GetRequest, rsp *GetResponse) error {
// ...
}
func (u *UsersService) Create(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
// ...
}
```
Claude sees:
```
Tools:
- users.UsersService.Get
- users.UsersService.Create
```
And can call them with natural language: *"Get user 123's details"*
## Real-World Use Cases
### 1. AI-Powered Customer Support
```bash
# Claude can help support agents
User: "Why is my order taking so long?"
Claude: Let me check...
→ Calls orders.Orders.Get with user's order ID
→ Calls shipping.Shipping.Track with tracking number
→ Calls inventory.Inventory.Check with product ID
Claude: "Your order is waiting for inventory. The product
is expected to be restocked on Feb 15. Would you like to
switch to an in-stock alternative?"
```
### 2. Debugging Production Issues
```bash
# Tell Claude the symptoms, it investigates
You: "Users can't log in. Check if it's the auth service."
Claude:
→ Calls health.Check on auth service
→ Calls metrics.Get for error rates
→ Calls logs.Recent for auth failures
→ Calls database.ConnectionPool for connection issues
Claude: "The auth service is healthy but the connection
pool is exhausted. Current: 100/100. Recommend increasing
pool size or checking for connection leaks."
```
### 3. Automated Operations
```bash
# Claude as an operations assistant
You: "Scale up the worker service"
Claude:
→ Calls infrastructure.Services.List to find workers
→ Calls infrastructure.Services.Scale with new count
→ Calls metrics.Monitor to watch the scale-up
Claude: "Scaled from 3 to 5 workers. All healthy and
processing jobs normally."
```
### 4. AI Data Analysis
```bash
# Claude can query your services for insights
You: "Show me revenue trends for the last quarter"
Claude:
→ Calls analytics.Revenue.GetTrends with date range
→ Calls analytics.Revenue.Compare with previous quarter
→ Calls analytics.Revenue.TopProducts
Claude: "Revenue is up 23% vs Q4. Top driver is product X
with 45% growth. However, churn increased 5% — recommend
investigating retention."
```
## Deployment Patterns
### Pattern 1: Embedded Gateway
Add MCP directly to your services:
```go
func main() {
service := micro.NewService(...)
go mcp.Serve(mcp.Options{
Registry: service.Options().Registry,
Address: ":3000",
})
service.Run()
}
```
**Best for**: Simple deployments, quick prototypes
### Pattern 2: Standalone Gateway
Deploy a dedicated MCP gateway service:
```go
// cmd/mcp-gateway/main.go
package main
import (
"go-micro.dev/v5/gateway/mcp"
"go-micro.dev/v5/registry/consul"
)
func main() {
mcp.ListenAndServe(":3000", mcp.Options{
Registry: consul.NewRegistry(),
})
}
```
**Best for**: Production, multiple services, centralized auth
### Pattern 3: Docker Compose
```yaml
version: '3.8'
services:
users:
build: ./users
environment:
- MICRO_REGISTRY=mdns
orders:
build: ./orders
environment:
- MICRO_REGISTRY=mdns
mcp-gateway:
build: ./mcp-gateway
ports:
- "3000:3000"
environment:
- MICRO_REGISTRY=mdns
```
**Best for**: Local development, testing
### Pattern 4: Kubernetes
```yaml
apiVersion: apps/v1
kind: Deployment
metadata:
name: mcp-gateway
spec:
replicas: 2
template:
spec:
containers:
- name: mcp-gateway
image: myregistry/mcp-gateway:latest
ports:
- containerPort: 3000
env:
- name: MICRO_REGISTRY
value: "consul"
- name: MICRO_REGISTRY_ADDRESS
value: "consul:8500"
```
**Best for**: Production at scale
## Security Considerations
### Add Authentication
```go
mcp.Serve(mcp.Options{
Registry: registry.DefaultRegistry,
Address: ":3000",
AuthFunc: func(r *http.Request) error {
token := r.Header.Get("Authorization")
if !validateToken(token) {
return errors.New("unauthorized")
}
return nil
},
})
```
### Network Isolation
Deploy MCP gateway in a private network:
```
Internet
┌──────▼────────┐
│ micro server │ :8080 (public)
│ + Auth │
└──────┬────────┘
┌──────▼────────┐
│ MCP Gateway │ :3000 (private)
└──────┬────────┘
┌──────────┼──────────┐
│ │ │
┌───▼───┐ ┌──▼────┐ ┌──▼────┐
│ users │ │ orders│ │payments│
└───────┘ └───────┘ └────────┘
(private) (private) (private)
```
Only the HTTP gateway is public. MCP gateway and services are internal.
## Library vs CLI
Both approaches use the **same underlying library** (`go-micro.dev/v5/gateway/mcp`):
| Approach | Users | Benefits |
|----------|-------|----------|
| **Library** | Import `gateway/mcp` package | Full control, works anywhere (Docker/K8s) |
| **CLI** | Use `--mcp-address` flag | Zero code changes, instant MCP support |
The CLI is just a convenient wrapper around the library.
## Getting Started
### Install
```bash
go get go-micro.dev/v5@v5.16.0
```
### Library Usage
```go
import "go-micro.dev/v5/gateway/mcp"
go mcp.Serve(mcp.Options{
Registry: service.Options().Registry,
Address: ":3000",
})
```
### CLI Usage
```bash
micro run --mcp-address :3000
# or
micro server --mcp-address :3000
```
### Test It
```bash
# List available tools
curl http://localhost:3000/mcp/tools
# Call a tool
curl -X POST http://localhost:3000/mcp/call \
-d '{"tool": "users.Users.Get", "input": {"id": "123"}}'
```
## New in v5.16.0: Stdio Transport & Auto-Documentation
We've added two major features that make MCP even more powerful:
### 1. Stdio Transport for Claude Code
Use go-micro services directly in Claude Code with stdio transport:
```bash
# Start MCP server with stdio (no HTTP needed)
micro mcp serve
```
Add to Claude Code config (`~/.claude/claude_desktop_config.json`):
```json
{
"mcpServers": {
"my-services": {
"command": "micro",
"args": ["mcp", "serve"]
}
}
}
```
Now Claude Code can discover and call your services directly!
### 2. Automatic Documentation Extraction
Services now **automatically extract documentation** from Go comments:
```go
// GetUser retrieves a user by ID from the database.
//
// @example {"id": "user-123"}
func (s *UserService) GetUser(ctx context.Context, req *GetUserRequest, rsp *GetUserResponse) error {
// implementation
}
// Register handler - docs extracted automatically!
handler := service.Server().NewHandler(new(UserService))
```
**No manual configuration needed!** Claude understands your service from your code comments.
### 3. MCP Command Line Tools
The new `micro mcp` command provides utilities for working with MCP:
```bash
# Start MCP server (stdio by default)
micro mcp serve
# Start with HTTP
micro mcp serve --address :3000
# List available tools
micro mcp list
# Test a tool
micro mcp test users.Users.Get
```
## What's Next?
We're continuing to evolve MCP support:
- **Streaming responses** for long-running operations
- **Rate limiting** and usage tracking
- **MCP server discovery** (browse available gateways)
- **Enhanced schema generation** from struct tags
## Philosophy
Go Micro has always been about **composable microservices**. MCP extends that philosophy:
- **Your services, your way**: MCP doesn't change how you build services
- **Library-first**: Works for all users, not just CLI users
- **Zero vendor lock-in**: Open protocol, works with any MCP client
- **Production-ready**: Security, auth, and scaling built-in
AI is becoming infrastructure. Your services should be ready.
## Try It Today
```bash
# Update to v5.16.0
go get go-micro.dev/v5@v5.16.0
# Add MCP to your service
import "go-micro.dev/v5/gateway/mcp"
go mcp.Serve(mcp.Options{
Registry: service.Options().Registry,
Address: ":3000",
})
# Or use the CLI
micro run --mcp-address :3000
```
See the [MCP Gateway documentation](/docs/mcp) for full details.
---
*Go Micro is an open source framework for distributed systems development in Go. [Star us on GitHub](https://github.com/micro/go-micro).*
<div class="post-nav">
<div><a href="/blog/1">← Introducing micro deploy</a></div>
<div><a href="/blog/">All Posts</a></div>
<div><a href="/blog/3">Building the AI-Native Future →</a></div>
</div>
+84
View File
@@ -0,0 +1,84 @@
---
layout: blog
title: "Doubling Down on Agents"
permalink: /blog/20
description: "Go Micro made services easy by being opinionated, batteries-included, and pluggable. We're applying the same model to agents — a model, memory, and tools that compose like a service does."
---
# Doubling Down on Agents
*June 10, 2026 &bull; Asim Aslam*
Go Micro made microservices easy by having an opinion. You called `micro.New`, and it composed the pieces a service needs — service discovery, RPC, pub/sub, config, storage — behind one interface, with defaults that worked out of the box. You could test an endpoint in minutes, and when you needed to, you swapped any piece: mDNS for etcd, HTTP for gRPC, in-memory for Postgres. Opinionated, batteries-included, and pluggable. That combination is why people used it.
Agents need the same thing, and right now most of them don't have it.
## What an agent is made of
Strip an agent down and it composes a small set of pieces, the same way a service does:
- a **model** that reasons,
- **memory** so it carries context across turns,
- **tools** it can call to act,
- and **guardrails** so it stops where you want it to.
Today most agent code wires these together by hand, per project, with whatever libraries happen to be nearby. That's where microservices were before frameworks: every team re-solving discovery, load balancing, and retries in their own way. The work isn't building the model — the model is a given. The work is everything around it.
So we're doing for agents what Go Micro did for services: compose those pieces, with defaults, behind one interface, and keep every piece swappable.
```go
agent := micro.NewAgent("assistant",
micro.AgentProvider("anthropic"), // model
micro.AgentMemory(micro.NewInMemory(50)), // memory — default is store-backed and durable
micro.AgentTool("weather", "Get the weather",
map[string]any{"city": map[string]any{"type": "string"}},
func(ctx context.Context, in map[string]any) (string, error) {
return getWeather(in["city"].(string))
}),
micro.AgentMaxSteps(8), // guardrails
)
```
Nothing here is required. `micro.NewAgent("assistant")` gives you a working agent with a default model, durable store-backed memory, your services as tools, and the built-in `plan` and `delegate` tools. The options are there for when you outgrow the defaults — which is exactly how the service side has always worked.
## The pieces we just made pluggable
Two of these were hardcoded until now, and both are the kind of thing the framework should own:
**Memory.** An agent's conversation is now behind a `Memory` interface. The default is store-backed, so memory is durable across restarts on whatever store you already use — file, Postgres, NATS KV — the same pluggable `store` that backs your services. Supply your own implementation when you want in-process, a database, or a semantic store. Memory is to an agent what the store is to a service: a first-class, swappable dependency, not something you bolt on.
**Tools beyond services.** An agent's tools were its registered services, reached over RPC. That's powerful, and it stays the default — but an agent often needs a plain function, a local computation, an external API. `AgentTool` registers any function as a tool the model can call, alongside the services it discovers. Agents are no longer limited to orchestrating RPC.
These join what's already there: the `plan` and `delegate` built-in tools, and the `MaxSteps` and `ApproveTool` guardrails. The model, memory, tools, and guardrails now compose the way registry, broker, and store compose for a service.
## Microagents
People are going to build large, monolithic agents — one brain with a hundred tools, holding everything in one context. It will work for a while, and then it will hit the same wall the monolith hit: one unit that knows a little about everything and can't hold the full context of anything.
The answer is the same one microservices gave. If there's an agent for everything, those are microagents — each scoped to a domain, each small enough to reason well about its own services, each independently deployable and composable. We already have the mechanics: an agent is a service, agents reach each other over RPC, and `delegate` lets one hand work to another. Distributing the intelligence is the same move as distributing the services, and it's available now.
## Services, agents, workflows
That leaves three primitives, and they compose:
- a **service** is a capability — endpoints, data, business logic;
- an **agent** is intelligence pointed at capabilities — it reasons, remembers, and acts through tools;
- a **workflow** is a deterministic trigger — when an event happens, run a known path, or hand off to an agent.
All three are Go code, all three register, all three communicate over RPC, all three deploy the same way. You can build a system out of any combination of them. That's the point: these aren't three products, they're three primitives on one substrate, and together they're enough to build the foundation of most platforms you'd want to build.
## What's still missing
I'd rather be plain about the gaps than pretend they're filled. An agent framework that matches what the service framework offers still needs:
- **Knowledge / retrieval** — a pluggable way to ground an agent in a corpus, the way memory grounds it in its own history.
- **Streaming** — the model interface has it stubbed; agents want token streaming for real interfaces.
- **An explicit reasoning loop** — the loop is functional today but lives inside the providers; making it a first-class, inspectable piece (without turning it into a graph DSL) is the right next step.
These are the next pieces to make pluggable. The principle holds for each: a working default, behind an interface, swappable.
## The same foundation
I started Go Micro because building microservices in Go was harder than it should have been, and the fix was an opinionated framework that got out of your way. Building agents is harder than it should be for the same reasons, and the fix is the same. Services were the foundation for a generation of distributed systems. Agents — built on those same services, with the same opinionated and pluggable model — are the foundation for the next one.
This is the direction now. Services, agents, workflows, one substrate.
+76
View File
@@ -0,0 +1,76 @@
---
layout: blog
title: "When the Event Is the Prompt"
permalink: /blog/21
description: "Most agents wait for a human to type something. The useful ones don't — they run because something happened in the system. A Flow turns an event into the prompt, and an agent acts on its own."
---
# When the Event Is the Prompt
*June 15, 2026 &bull; Asim Aslam*
Almost every agent demo starts the same way: a human types a prompt, the agent responds. That framing is a habit from chat, and it hides the more useful case. The agents worth running don't wait for you to ask. They run because something happened — a user signed up, a payment failed, a deployment finished, a metric crossed a line. In those systems there is no prompt, because there is no human in the loop. The event is the prompt.
Go Micro has had the pieces for this since we added [workflows](/blog/18): a `Flow` subscribes to a broker topic, and an `Agent` reasons and acts. Putting them together is the point of this post.
## The shape
A workflow is the deterministic half — it knows *when* to run (an event arrives) and turns that event into a prompt. An agent is the dynamic half — it decides *what to do* about it. Connect them and you get an agent that runs on events, unattended.
```go
// The agent: it onboards new users using its services.
onboarder := micro.NewAgent("onboarder",
micro.AgentServices("workspace", "notify"),
micro.AgentPrompt("You onboard new users. Create their workspace and send a welcome."),
micro.AgentProvider("anthropic"),
)
go onboarder.Run()
// The workflow: when a user signs up, hand the event to the agent.
f := micro.NewFlow("onboard",
micro.FlowTrigger("events.user.created"),
micro.FlowPrompt("A new user signed up: {{.Data}}. Get them set up."),
micro.FlowAgent("onboarder"),
)
```
When a `user.created` event lands on the broker, the flow renders it into a prompt and hands it to the onboarder over RPC. The agent looks at its tools, creates the workspace, sends the welcome, and stops. No one typed anything.
There's a runnable version of exactly this in [`internal/harness/agent-flow`](https://github.com/micro/go-micro/tree/master/internal/harness/agent-flow) — real services, registry, RPC, broker, and the agent loop, with only the model mocked so it runs without a key:
```
> event: publishing events.user.created {"email":"alice@acme.com"}
[onboarder] → workspace_WorkspaceService_Create({"owner":"alice@acme.com"})
[workspace] created ws-1 for alice@acme.com
[onboarder] → notify_NotifyService_Send({"to":"alice@acme.com","message":"Welcome — your workspace is ready."})
[notify] 📨 to=alice@acme.com message="Welcome — your workspace is ready."
✓ the agent onboarded the user — triggered by an event, not a prompt
```
## This is where microagents become real
A chat agent is something you visit. An event-driven agent is something that runs. That difference is what makes [an agent for everything](/blog/20) practical: each domain gets a small agent that wakes on its own events and acts. Payments has an agent that responds to failed charges. Ops has one that reacts to alerts. Support has one that triages new tickets. None of them is a monolithic brain holding the whole system in one context; each is scoped, each runs on its events, and they reach each other over RPC when they need to. It's the microservices decomposition, applied to the intelligence.
The mechanics are already there: an agent is a service, agents call each other with `delegate`, and a flow is the trigger. The only thing that changed is the absence of a person at the start of the loop.
## What running unattended demands
Taking the human out of the loop raises the bar, and it's worth being honest about that rather than pretending it's free.
- **Guardrails stop being optional.** When you're sitting in a chat you are the stopping condition — you see a wrong turn and intervene. An event-driven agent has no one watching, so the bounds have to be in the code: `MaxSteps` to cap what it can do per event, and `ApproveTool` to gate the actions that genuinely need a human or a policy check. For an unattended agent these are load-bearing, not nice-to-haves.
- **Observability becomes the interface.** If no one is reading the agent's replies, the trace of what it did is the only way to know it did the right thing. The reply text matters less than a record of the tools it called and the results it got.
- **Execution has to be durable.** An event-driven agent may run longer than a request, and it has to survive a restart without dropping the work or repeating it. Its memory is already store-backed and durable; the loop itself needs to be checkpointed and resumable in the same way.
The first of these is shipped today. The other two are the next things to build, and they're the same gaps the [last post](/blog/20) named — autonomy is what makes them urgent.
## Three primitives, one substrate
This is the whole picture coming together:
- a **service** is a capability;
- an **agent** is intelligence pointed at capabilities;
- a **workflow** is the trigger — and when the trigger is an event, it is also the prompt.
You compose them. A workflow turns an event into a prompt, an agent reasons about it, services do the work, and other agents pick up the parts they own. The human moves from typing every instruction to setting the system up and letting it run. That is the shift worth building for: not better chat, but software that acts on its own — on the same services, agents, and workflows you already have.
+85
View File
@@ -0,0 +1,85 @@
---
layout: blog
title: "Integrating x402: Payments for Agents"
permalink: /blog/22
description: "Agents that act on their own eventually need to pay on their own. Go Micro now speaks x402 — the HTTP 402 payment standard — so a tool can require a stablecoin payment and an agent can settle it, with the chain pluggable behind a facilitator."
---
# Integrating x402: Payments for Agents
*June 15, 2026 &bull; Asim Aslam*
The [last post](/blog/21) was about agents that run on their own — triggered by an event, acting without a human prompt. Follow that one step further and you reach something agents can't do yet in most systems: pay. An autonomous agent that calls an API, rents compute, or uses another agent's service will, sooner or later, need to settle for it — without a person reaching for a credit card. There is now a standard for exactly that, and we're integrating it.
## What x402 is
x402 is an open payment protocol built on the HTTP **402 Payment Required** status code. The flow is simple: a client requests a resource, the server answers `402` with machine-readable payment requirements (amount, asset, network, where to pay), the client pays and retries with an `X-PAYMENT` header, and the server verifies the payment and serves the resource. It's designed for stablecoins and for machine-to-machine use — agents paying for things, per request.
It started at Coinbase, it's multi-chain (Base, Solana, Ethereum, Polygon, and more), and as of April 2026 it's governed by the **x402 Foundation under the Linux Foundation**, with founding members including Google, Visa, Stripe, AWS, Mastercard, Circle, and Shopify. That governance is why we're comfortable integrating it: it's an open standard with the payments industry behind it, not a single vendor's API.
## How Go Micro integrates it
The same way it integrates everything else — interface-first, with a default, and pluggable.
The core is HTTP middleware in `wrapper/x402`. It enforces the 402 challenge and verifies payments, but it carries **no chain or crypto code**. Verification and settlement are delegated to a pluggable **Facilitator**:
```go
type Facilitator interface {
Verify(ctx context.Context, payment string, req Requirements) (Result, error)
}
```
So Go Micro stays chain-agnostic. "Base through Coinbase" and "Solana through Alchemy" are not two integrations — they're the same middleware pointed at two facilitators. The facilitator does the on-chain work; the framework speaks the protocol.
```go
pay := x402.Middleware(x402.Config{
PayTo: "0xYourAddress", // where payments go
Network: "solana", // or "base", ...
Amount: "10000", // smallest units, e.g. 0.01 USDC
})
mux.Handle("/paid", pay(handler))
```
## Opt-in, at the gateway
Because every Go Micro endpoint is already an AI-callable tool through the MCP gateway, that's the natural place to charge: a tool call is the thing worth a payment. So x402 is wired into both the built-in `micro mcp serve` and the standalone `micro-mcp-gateway`, and it is strictly **opt-in** — off unless you set a pay-to address.
```bash
micro mcp serve --address :3000 \
--x402-pay-to 0xYourAddress \
--x402-network solana \
--x402-amount 10000 \
--x402-facilitator https://facilitator.example
```
With payments enabled, the `/mcp/call` endpoint requires a verified payment; listing tools and health checks stay free. Without the flag, nothing changes. The standalone gateway takes the same options via flags or environment variables, so you can put a paid gateway in front of services you didn't write.
Different tools can cost different amounts. Because pricing is an operator concern — the payTo address is the operator's, and prices change without redeploying anyone's service — it's set at the gateway with a config file, the same way per-tool scopes and rate limits already are:
```json
{ "payTo": "0xYourAddress", "network": "solana", "asset": "USDC",
"amount": "0",
"amounts": { "weather.Weather.Forecast": "10000", "search.Search.Query": "5000" } }
```
```bash
micro mcp serve --address :3000 --x402-config x402.json
```
`amount` is the default (here `0` — free), and `amounts` sets per-tool overrides. There's no "pricing" abstraction in the framework; it's just the x402 amount, resolved per tool, in the protocol's own vocabulary.
## Why this matters
Go Micro's premise has been that every service is a tool an agent can call. x402 adds one word: a *paid* tool. That turns "tools as services" into something with an economic side — a service can charge per call, and an agent can pay for it, with no human in the loop on either end. It gives the services people build a native way to be paid for, and it gives Go Micro a place in the supply side of an agent economy: the rails for agents to act *and* transact.
## Honest about the edges
- **It's opt-in and dependency-light.** No pay-to address, no payments. Go Micro pulls in no chain libraries — the facilitator does that work.
- **Amounts start simple.** A default amount, or per-tool amounts via the gateway config today; metered usage and prepaid balances (the place a "credit" concept would actually fit) are the harder design work to come.
- **A paying agent needs a budget.** Blog 21 argued that unattended agents need guardrails; an unattended agent that spends money needs them most. A spend cap belongs next to `MaxSteps` and `ApproveTool`, and it's the next piece to build on the agent side.
Agents that act, and now can pay. Services, agents, workflows, and payments — the substrate for software that operates, and transacts, on its own.
---
Sources: [x402 — Coinbase Developer Docs](https://docs.cdp.coinbase.com/x402/welcome), [What is x402? — Alchemy](https://www.alchemy.com/blog/how-x402-brings-real-time-crypto-payments-to-the-web), [x402 on Solana — solana.com](https://solana.com/x402/what-is-x402).
+66
View File
@@ -0,0 +1,66 @@
---
layout: blog
title: "Agent Guardrails"
permalink: /blog/23
description: "An autonomous agent fails in mundane ways — it loops, it runs away, it takes an action it shouldn't. Go Micro separates orchestration from execution safety, and gives every agent three guardrails at the point where tools actually run."
---
# Agent Guardrails
*June 16, 2026 &bull; Asim Aslam*
The interesting failures of an autonomous agent aren't dramatic. It calls the same tool with the same arguments over and over, making no progress. It takes thirty steps on a task that needed three, quietly running up cost. It performs an action that a human, or a policy, should have stood in front of. When an agent [runs on its own](/blog/21), there's no one watching to catch any of it.
A useful way to think about this — which came up in a community discussion recently — is to separate **orchestration** from **execution safety**. The model decides what to do; that's orchestration. Whether a decided action is actually allowed to run is a separate concern, and it shouldn't be tangled into the model or the services. Go Micro keeps them apart: every tool call an agent makes passes through one choke point, and that's where the guardrails live. They apply uniformly to service calls, custom tools, and `delegate`, and they touch neither the model nor your services.
There are three.
## Stop on count
`MaxSteps` bounds the total tool executions in one request. Past the limit, calls are refused and the model is told to stop and summarize. It's the blunt backstop against runaway cost.
```go
micro.NewAgent("worker", micro.AgentMaxSteps(8))
```
## Stop on repeat
This is the one the discussion was really about, and the one we didn't have. `MaxSteps` bounds *how many* calls, but not *whether they're the same call*. An agent can spend its entire budget calling one tool with identical arguments — each call succeeds, and none of them moves anything forward. A circuit breaker won't catch it either, because a circuit breaker reacts to *failures*, and a pointlessly repeated call isn't failing.
`LoopLimit` catches it: it bounds how many times the agent may call the same tool with the same arguments in one request. When the limit is hit, the call is refused with a message that names the loop, so the model changes approach instead of spinning:
> loop detected: you have already called "search.Search.Query" with the same arguments 3 times and the result will not change. Stop repeating it — try a different approach, or finish with what you have.
That refusal-with-reason is what lets the agent recover on its own. It's **on by default** (a lenient 3), because identical repeated calls are never useful; `AgentLoopLimit(0)` disables it.
```go
micro.NewAgent("worker", micro.AgentLoopLimit(3))
```
## Gate the action
`ApproveTool` is a hook called before each action runs. Return `false` to block it, with a reason the model sees — for human-in-the-loop approval, spend limits, allow/deny lists, or any policy:
```go
micro.NewAgent("worker", micro.AgentApproveTool(
func(tool string, input map[string]any) (bool, string) {
if strings.HasPrefix(tool, "billing_") {
return false, "billing actions require sign-off"
}
return true, ""
}))
```
## The hook is the seam
`ApproveTool` is also where an **external safety layer** plugs in. It sees every tool call before execution and can veto, so you can route decisions to your own rules, a budget service, or a third-party runtime-policy engine — without Go Micro depending on any of them. That's the value of separating the two concerns: orchestration stays in the agent, execution safety stays in the hook, and you can replace the safety layer without touching the agent. Loop detection ships built in because it's near-universal; everything beyond it is a policy you supply.
## At the edge, too
When agents reach tools **through the MCP gateway**, the gateway adds its own per-tool policies, independent of the agent: `RateLimit` (requests per second) and `CircuitBreaker` (a tool that fails repeatedly is temporarily blocked, so a failing dependency doesn't cascade). With the agent-side guardrails that's a complete set — bound the count, stop the spin, gate the action, rate-limit and circuit-break at the edge.
## Why it's the autonomy story
None of this matters much when you're sitting in a chat watching the agent work — you are the guardrail. It matters when no one is. An agent triggered by an event, running unattended, needs to fail safely and recover by itself rather than burning resources in a loop nobody sees. Guardrails aren't a feature bolted onto agents; for an autonomous agent they're part of what makes it safe to run at all.
See the [Agent Guardrails guide](/docs/guides/agent-guardrails) for the full reference.

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