Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 327347d0bc docs(priorities): refresh planner queue
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
2026-07-02 15:00:04 +00:00
66 changed files with 334 additions and 2904 deletions
+7 -3
View File
@@ -21,9 +21,13 @@ changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Add cross-provider agent conformance** ([#3901](https://github.com/micro/go-micro/issues/3901)) — #3895 closed the provider deadline/retry guidance gap and #3899 closed the AP2 foundation, leaving no open `codex` PRs or issues in flight. The highest-value Now-phase gap is therefore battle-testing the same first-agent behavior across providers: tool calling, multi-step runs, plan/delegate, and guardrails should fail loudly by provider instead of drifting behind a single happy-path backend. Keep default CI no-secret friendly with mock coverage and key-gated live providers.
2. **Checkpoint and resume durable agent runs** ([#3902](https://github.com/micro/go-micro/issues/3902)) — adoption now has install, scaffold, run, chat, inspect/history, and 0→hero coverage, but the lived harness story still has an asymmetry: flows checkpoint and resume while long agent runs do not. This is the top Next-phase agentic-depth gap because it makes services → agents → workflows feel like one runtime under interruption and deploy/restart conditions. Keep it additive; do not break the public agent API.
3. **Broaden provider streaming and keep chat/A2A streaming end to end** ([#3903](https://github.com/micro/go-micro/issues/3903)) — after conformance and durability, streaming is the next developer-visible seam in the inner loop. Real chat and long-running A2A tasks need token streaming to stay coherent from provider → `ai.Stream``micro chat` → A2A `message/stream`, with mock/default CI coverage plus key-gated live provider checks and safe fallback for non-streaming providers.
1. **Constrain universe concierge notifications to one buyer side effect** ([#3682](https://github.com/micro/go-micro/issues/3682)) — PR #3684 closed the unfinished plan/delegate run gap, and the latest live provider-conformance failure has shifted to the universe harness: the checkout resume path is durable and reachable, but the concierge can notify both `order-1` and `buyer`. This is the highest-value Now-phase correctness item because the services → agents → workflows story must produce bounded, idempotent side effects under retry/resume before developers can trust the harness.
2. **Make the examples index a walkable first-agent map** ([#3671](https://github.com/micro/go-micro/issues/3671)) — recent README/docs work surfaced the first-agent on-ramp, and blog #33 sharpened the dogfooded loop story; the next adoption gap is example wayfinding. A newcomer should be able to move from first service to first agent to first workflow from the examples surface without stitching together README, website docs, and directories by hand.
3. **Expose `fallback_echo` during A2A streaming fallback conformance** ([#3560](https://github.com/micro/go-micro/issues/3560)) — this remains the next scoped Now-phase interop/conformance gap: it protects the A2A streaming promise developers see in the README and site by ensuring the non-native streaming fallback path receives the tool surface, while staying behind the immediate multi-agent correctness and on-ramp blockers.
4. **Parse multi-event A2A SSE fallback responses in the harness** ([#3662](https://github.com/micro/go-micro/issues/3662)) — once the fallback tool path succeeds, the harness must accept legitimate multi-event `message/stream` responses instead of concatenating valid SSE events into invalid JSON. This is a small CI-verifiable harness fix that keeps cross-provider streaming conformance focused on real gateway failures rather than parser brittleness.
5. **Propagate agent run cancellation and deadlines through model and tool calls** ([#3544](https://github.com/micro/go-micro/issues/3544)) — the highest-value remaining Now-phase resilience gap after the live-provider side-effect fixes is predictable failure semantics across agent runs, model calls, tool calls, plan/delegate, and flow handoffs. Tool retries and live-provider deadline tuning are in place; the lifecycle still needs cancellation/deadline propagation so work fails safely instead of becoming opaque loops.
6. **Emit OpenTelemetry spans for agent run timelines** ([#3525](https://github.com/micro/go-micro/issues/3525)) — recent work made runs inspectable, correlated trace metadata through scheduled dispatch, verified restart resume, added opt-in tool retries, hardened provider conformance, and fixed provider-emitted text tool calls. The next Next-phase step is to turn that RunInfo foundation into standard OTel spans for agent runs, model calls, tool calls, checkpoint/resume, cancellation/deadlines, and failures.
7. **Add an AP2 mandate layer over A2A and x402** ([#3552](https://github.com/micro/go-micro/issues/3552)) — this is a forward interop investment, not a Now-phase blocker: Go Micro already has A2A agents and x402 paid tools, so a small signed-mandate foundation can keep agent payments aligned with the open-protocol story without pulling the queue away from adoption, resilience, or observability. Keep it additive and opt-in while the AP2/FIDO work settles.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
-30
View File
@@ -1,30 +0,0 @@
<!--
The SECURITY prompt — go-micro's security audit. Editable policy; the workflow
prepends the agent @mention and substitutes __ISSUE__ before posting. Keep
__ISSUE__ literal.
Deliberately conservative: it does NOT auto-merge fixes, and it does NOT publish
exploit details in public issues (responsible disclosure).
-->
Act as the security reviewer for go-micro. Audit for real, exploitable vulnerabilities — skip theoretical or lint-style noise.
GO-MICRO ATTACK SURFACE — weight these:
- **MCP gateway** (`gateway/mcp`) and **A2A gateway** (`gateway/a2a`) — untrusted input from agents/tools: auth/scope enforcement, injection into downstream RPC, SSRF via tool/agent URLs, rate-limit/circuit-breaker bypass, info leak in errors.
- **x402 payments** (`wrapper/x402`) — payment verification and settlement: signature/mandate validation, replay, budget-reservation races, facilitator auth (CDP bearer) handling, amount/network confusion.
- **Auth** (`auth/jwt`, `wrapper/auth`) — token validation, algorithm confusion, scope/priority rule bypass, missing checks on endpoints.
- **AI providers** (`ai/*`) — base-URL and endpoint handling: SSRF via config-controlled `BaseURL`, API keys leaking into logs/errors, TLS verification.
- **Agent tool loop** (`agent/`) — prompt injection reaching real tool calls, guardrail (`MaxSteps`/`LoopLimit`/`ApproveTool`) bypass, delegate/plan side effects.
- **Trust boundaries** — `server` RPC handlers, `broker` consumers, `store`/`registry` inputs, `transport` TLS defaults (v6 verifies by default — confirm nothing regressed).
- **The loop itself** — `.github/workflows/loop-*.yml`: the `CODEX_TRIGGER_TOKEN` PAT must never be echoed/leaked; workflow inputs must not enable script injection.
- **Dependencies** — run `govulncheck ./...` (install if needed) and inspect `go.mod` for known CVEs.
DEDUPE against open issues first.
HOW TO REPORT:
- **Known/public dependency CVEs**: file a `security` issue referencing the CVE + module; you MAY open a PR bumping to the patched version. Do NOT enable auto-merge.
- **Novel, exploitable vulnerabilities in this code** (not yet public): do NOT post an exploit or PoC in a public issue. File a CONCISE `security` + `needs-human` issue naming the class, location (file/function), and impact only — and note it should go through GitHub private vulnerability reporting. Do NOT open a public fix PR that reveals it.
- **Low-risk hardening**: a normal `security` issue is fine.
NEVER auto-merge a security change. Never weaken a control to make a test pass. Architectural/breaking fixes → `needs-human` with the tradeoff.
Post a summary as a comment on this issue (#__ISSUE__) — findings by severity, what you filed, what needs a human — then close it (`gh issue close __ISSUE__`). If you open a dependency-bump PR: `git switch -c loop/security-__ISSUE__`, `git push -u origin loop/security-__ISSUE__`, `gh pr create --base master --label codex --label security --title "<title>" --body "<summary, Closes #__ISSUE__>"` — then STOP, do NOT run `gh pr merge --auto`. Do not use the make_pr tool.
+6 -6
View File
@@ -1,14 +1,14 @@
<!--
The TRIAGE prompt — go-micro's CI-failure feedback path. Editable policy; the
workflow prepends the agent @mention and substitutes __ISSUE__ (this tracking
issue) and __RUNURL__ (the failed run) before posting. Keep both literal.
The TRIAGE prompt — go-micro's harness-failure feedback path. Editable policy;
the workflow prepends the agent @mention and substitutes __ISSUE__ (this
tracking issue) and __RUNURL__ (the failed run) before posting. Keep both literal.
-->
Triage the failed CI run at __RUNURL__. It may be the linter (Lint), the unit/integration tests (Run Tests), or the provider-conformance harness (Harness (E2E)).
Triage the failed provider-conformance harness run at __RUNURL__.
Read the logs and root-cause each distinct failure. DEDUPE against open issues — if a failure matches an existing issue, comment "recurred" there instead of filing a duplicate.
For each genuine, self-contained defect, file a scoped issue (`gh issue create --label codex --label enhancement --title "<scoped fix>" --body "<root cause, where, acceptance criteria>"`) so the increment loop builds it and the next CI/harness run verifies it. A lint or test failure on master is a real regression — file it so it is fixed promptly; do NOT ignore it.
For each genuine, self-contained defect, file a scoped issue (`gh issue create --label codex --label enhancement --title "<scoped fix>" --body "<root cause, where, acceptance criteria>"`) so the increment loop builds it and the next harness run verifies it.
IGNORE only genuine transient flakes — live-model latency, provider outages, rate limits, network timeouts with no code cause (mostly relevant to the harness). Anything needing a breaking or architectural change: file it as `needs-human` and describe it, rather than auto-queuing it as a routine fix.
IGNORE transient flakes — live-model latency, provider outages, rate limits, timeouts with no code cause. Anything needing a breaking or architectural change: file it as `needs-human` and describe it, rather than auto-queuing it as a routine fix.
Close this issue (`gh issue close __ISSUE__`) when triage is done. Open any PR yourself from the shell with `gh`; do not use the make_pr tool.
+2 -3
View File
@@ -18,7 +18,7 @@ on:
providers:
description: "Comma-separated providers for live conformance (default: all supported)"
required: false
default: "anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud"
default: "anthropic,openai,gemini,groq,mistral,together,atlascloud"
harnesses:
description: "Comma-separated harnesses for live conformance"
required: false
@@ -64,7 +64,6 @@ jobs:
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 }}
@@ -74,7 +73,7 @@ jobs:
# catalog id differs (Atlas uses org/model ids).
ATLASCLOUD_MODEL: ${{ vars.ATLASCLOUD_MODEL || 'minimaxai/minimax-m3' }}
run: |
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,minimax,mistral,together,atlascloud' }}"
PROVIDERS="${{ github.event.inputs.providers || 'anthropic,openai,gemini,groq,mistral,together,atlascloud' }}"
HARNESSES="${{ github.event.inputs.harnesses || 'agent,universe,agent-flow,plan-delegate,a2a-stream-fallback' }}"
REQUIRE_CONFIGURED="${{ github.event.inputs.require_configured || 'false' }}"
-60
View File
@@ -1,60 +0,0 @@
name: "Loop: Security"
# Generated by `micro loop init`. A dispatch role of the autonomous loop: on a
# cadence it opens a fresh tracking issue and posts the instruction in
# .github/loop/prompts/security.md to the agent (@codex).
#
# The workflow is the MECHANISM; that prompt file is the editable POLICY —
# change what this role does by editing the prompt, not this YAML. A FRESH
# issue per run is deliberate: agents derive the PR branch name from the
# triggering issue, so reusing one tracker collapses every run onto one branch.
#
# Gated on CODEX_TRIGGER_TOKEN: the agent ignores @mentions from the
# github-actions bot, so dispatch posts as a real user (a PAT). No token → no-op.
on:
workflow_dispatch: {}
schedule:
- cron: "0 6 * * 1"
permissions:
issues: write
concurrency:
group: loop-security
cancel-in-progress: false
jobs:
dispatch:
runs-on: ubuntu-latest
steps:
- uses: actions/checkout@v4 # needed to read the prompt file
- name: Dispatch security
env:
GH_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN || github.token }}
HAS_TOKEN: ${{ secrets.CODEX_TRIGGER_TOKEN != '' }}
REPO: ${{ github.repository }}
RUN_NUMBER: ${{ github.run_number }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping (the agent ignores bot @mentions)."
exit 0
fi
PROMPT=".github/loop/prompts/security.md"
if [ ! -f "$PROMPT" ]; then
echo "missing $PROMPT — run 'micro loop init'." >&2
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: security review #$RUN_NUMBER" \
--body "Autonomous security pass. Direction: .github/loop/NORTH_STAR.md; queue: .github/loop/PRIORITIES.md.")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching security."
# The prompt file is the policy; strip its editorial <!-- --> header and
# substitute the tracking issue number (__ISSUE__) at runtime.
{
echo "@codex"
echo
sed -e '/<!--/,/-->/d' -e "s/__ISSUE__/$ISSUE_NUM/g" "$PROMPT"
} > "$RUNNER_TEMP/loop-body.md"
gh issue comment "$ISSUE_NUM" --repo "$REPO" --body-file "$RUNNER_TEMP/loop-body.md"
+5 -6
View File
@@ -1,14 +1,14 @@
name: "Loop: Triage"
# Generated by `micro loop init`. The feedback path of the evaluator: when a CI
# workflow (Harness (E2E), Lint, Run Tests) fails on a non-PR run, dispatch the agent
# Generated by `micro loop init`. The feedback path of the evaluator: when the
# CI workflow ("Harness (E2E)") fails on a non-PR run, dispatch the agent
# (@codex) with the instruction in .github/loop/prompts/triage.md
# to root-cause the failure and file scoped fix issues back into the queue — so
# failures become fixes with no human in the middle. Gated on CODEX_TRIGGER_TOKEN.
on:
workflow_run:
workflows: ["Harness (E2E)", "Lint", "Run Tests"]
workflows: ["Harness (E2E)"]
types: [completed]
permissions:
@@ -33,7 +33,6 @@ jobs:
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "CODEX_TRIGGER_TOKEN is not set — skipping."
@@ -45,8 +44,8 @@ jobs:
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: triage failed run $RUN_ID ($WORKFLOW_NAME)" \
--body "The '$WORKFLOW_NAME' workflow failed on a non-PR run: $RUN_URL")
--title "Loop: triage failed run $RUN_ID" \
--body "The 'Harness (E2E)' workflow failed: $RUN_URL")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching triage."
{
-34
View File
@@ -16,43 +16,9 @@ next version when it ships.
## [Unreleased]
## [6.3.14] - July 2026
### Added
- **MiniMax provider** — run agents against MiniMax's `MiniMax-M3` model via its OpenAI-compatible endpoint, with tool calling and streaming; auto-detected from the base URL. (`ai/minimax/`)
- **`micro loop` security role** — a new opt-in loop role (`--roles …,security`) that periodically audits a repo for vulnerabilities and files `security` issues. It is deliberately conservative: it never auto-merges fixes and never publishes exploit detail in public issues (responsible disclosure), and risky fixes are marked `needs-human`. go-micro now runs it against its own attack surface (MCP/A2A gateways, x402, auth, provider URLs, agent tool loop, deps). (`cmd/micro/loop/`)
- **Agent run tracing** — agent model streaming and run-event kinds now emit richer trace detail for debugging agent execution. (`agent/`)
### Changed
- **Agent memory** — streamed agent replies are persisted in conversation memory so later turns can reference streamed responses. (`agent/`)
### Fixed
- **Plan/delegate completion** — agents now continue unfinished plan steps more reliably, fail checkpointed runs that leave delegated plans unfinished, recover from unknown plan-delegate tool calls, avoid duplicate side effects, and complete timeout paths deterministically. (`agent/`)
- **AtlasCloud tool calls** — streaming and request fallback handling now recovers tool-call results from provider responses that omit the expected structured fields. (`ai/atlascloud/`)
- **Agent preflight diagnostics** — provider setup failures now surface more actionable errors before an agent run starts. (`agent/`)
- **A2A fallback streams** — fallback stream validation is stricter for malformed or incomplete A2A streaming responses. (`gateway/a2a/`)
- **File-store test isolation** — file-store expiry and table tests are less timing-sensitive and isolate their state more reliably. (`store/file/`)
### Documentation
- **First-agent debugging path** — docs now include no-secret transcript checkpoints, durable resume examples, and clearer CLI/website wayfinding for first-agent debugging. (`README.md`, `internal/website/docs/`, `examples/agent-durable/`)
---
## [6.3.13] - July 2026
### Added
- **`micro loop`** — scaffold an autonomous improvement loop into any repository: GitHub Actions workflows dispatched to an @mention-driven coding agent, across up to five roles — `planner` (ranked queue), `builder` (top item as a single-concern PR, auto-merged on green CI), `triage` (CI failures → fix issues), and opt-in `coherence` (docs/CHANGELOG alignment) and `release` (daily patch tag). Each dispatch role's instruction lives in an editable `.github/loop/prompts/<role>.md` file — the workflow is the mechanism, the prompt is the policy — so a repo customizes behavior without forking the CLI. `micro loop init --roles …` writes it all; `micro loop verify` checks the wiring. This is the loop that maintains go-micro itself, generalized. (`cmd/micro/loop/`)
### Changed
- **x402 payments** — settlement now covers CDP facilitator authentication and conformance edge cases. (`wrapper/x402/`)
### Fixed
- **Plan/delegate harnessing** — side effects and notifications are now idempotent and deterministic across duplicate, alias, order-scoped, and reachability scenarios. (`agent/`, `internal/harness/`)
### Documentation
- **First-agent on-ramp** — quickstart docs now connect the no-secret first-agent transcript, example map, and 0→hero path. (`README.md`, `internal/website/docs/`)
- **Ollama provider docs** — the provider surface, capability matrix, and examples now document local and cloud behavior. (`internal/website/docs/`, `examples/agent-ollama/`)
---
## [6.3.12] - July 2026
+4 -8
View File
@@ -87,14 +87,12 @@ make harness
After install and the first `micro new`/`micro run` smoke check, take the
walkable agent path in this order:
1. [No-secret first-agent transcript](internal/website/docs/guides/no-secret-first-agent.md) — run the
maintained support agent with a mock model and see services → agents → workflows succeed without a key.
2. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
1. [Your First Agent](internal/website/docs/guides/your-first-agent.md) — build a
service-backed agent and talk to it with `micro chat`.
3. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
2. [Debugging your agent](internal/website/docs/guides/debugging-agents.md) — use
`micro agent inspect`, run history, memory, and provider checks when the first
conversation does something unexpected.
4. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
3. [0→hero Reference](internal/website/docs/guides/zero-to-hero.md) — complete the
services → agents → workflows loop with scaffold, run, chat, inspect, flow
history, and deploy dry-run commands that match the maintained harness.
@@ -332,7 +330,7 @@ MCP exposes your services as tools; A2A exposes your agents as agents. See the [
| MCP gateway | Every endpoint is an AI tool automatically |
| A2A gateway | Every agent is reachable over the Agent2Agent protocol; cards generated from the registry (`micro a2a`) |
| Payments (x402) | Opt-in per-call payments for tools via the x402 standard; pluggable facilitator (Base, Solana, …) |
| 9 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, MiniMax, Ollama (local + cloud) |
| 8 LLM providers | Anthropic, OpenAI, Gemini, Groq, Mistral, Together, Atlas Cloud, Ollama (local + cloud) |
| Interactive console | `micro run` includes a chat console for talking to services |
| Service generation | `micro run --prompt` — describe a system, get running services |
@@ -429,7 +427,6 @@ Swap providers with a single import — same interface everywhere:
| Mistral | `mistral-large-latest` |
| Together AI | `meta-llama/Llama-3.3-70B-Instruct-Turbo` |
| Atlas Cloud | `deepseek-ai/DeepSeek-V3-0324` |
| MiniMax | `MiniMax-M3` |
| Ollama | `llama3.2` (local) |
```go
@@ -443,7 +440,6 @@ resp, _ := m.Generate(ctx, &ai.Request{Prompt: "hello"})
- [multi-service](examples/multi-service/) — Multiple services in one binary
- [mcp](examples/mcp/) — MCP integration with AI agents
- [agent-plan-delegate](examples/agent-plan-delegate/) — Agent planning and multi-agent delegation
- [agent-durable](examples/agent-durable/) — Checkpoint and resume an agent run without replaying completed tool side effects
- [grpc-interop](examples/grpc-interop/) — Call go-micro from any gRPC client
See [all examples](examples/README.md).
+47 -72
View File
@@ -222,16 +222,12 @@ func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, erro
return nil, fmt.Errorf("discover tools: %w", err)
}
a.mem.Add("user", message)
stream, err := a.model.Stream(ctx, &ai.Request{
return a.model.Stream(ctx, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: a.mem.Messages(),
})
if err != nil {
return nil, err
}
return &memoryRecordingStream{stream: stream, memory: a.mem}, nil
}
// Pending returns checkpointed agent runs that have not completed. It mirrors
@@ -297,78 +293,57 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
}
}
const maxPlanCompletionTurns = 3
var resp *ai.Response
for planCompletionTurn := 0; ; planCompletionTurn++ {
resp, err = ai.GenerateWithRetry(ctx, a.model, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: messages,
}, ai.GeneratePolicy{
Timeout: a.opts.ModelTimeout,
MaxAttempts: a.opts.ModelMaxAttempts,
Backoff: a.opts.ModelRetryBackoff,
})
if err != nil {
run.Status = agentRunFailureStatus(err)
err = agentOperationalError(err)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = run.Status
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
resp, err := ai.GenerateWithRetry(ctx, a.model, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: messages,
}, ai.GeneratePolicy{
Timeout: a.opts.ModelTimeout,
MaxAttempts: a.opts.ModelMaxAttempts,
Backoff: a.opts.ModelRetryBackoff,
})
if err != nil {
run.Status = agentRunFailureStatus(err)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = run.Status
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
return nil, err
}
if a.pause != nil && a.opts.Checkpoint != nil {
run.Status = "paused"
run.State.Stage = agentApprovalStep
run.State.Data = []byte(message)
if a.pause.Tool == toolHumanInput {
run.State.Stage = agentInputStep
_ = run.State.Set(inputPause{OriginalMessage: message, Prompt: a.pause.Message})
}
run.Steps[0].Status = "paused"
run.Steps[0].Error = a.pause.Message
run.Steps[0].Result = a.pause.Tool
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
if a.pause != nil && a.opts.Checkpoint != nil {
run.Status = "paused"
run.State.Stage = agentApprovalStep
run.State.Data = []byte(message)
if a.pause.Tool == toolHumanInput {
run.State.Stage = agentInputStep
_ = run.State.Set(inputPause{OriginalMessage: message, Prompt: a.pause.Message})
}
run.Steps[0].Status = "paused"
run.Steps[0].Error = a.pause.Message
run.Steps[0].Result = a.pause.Tool
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
return nil, fmt.Errorf("agent run %s paused for approval: %s", run.ID, a.pause.Message)
}
return nil, fmt.Errorf("agent run %s paused for approval: %s", run.ID, a.pause.Message)
}
if len(resp.ToolCalls) == 0 {
if calls, answer, ok := a.executeTextToolCalls(ctx, resp.Reply, toolList); ok {
resp.ToolCalls = calls
if resp.Answer == "" {
resp.Answer = answer
}
trimmedReply := strings.TrimSpace(resp.Reply)
if strings.HasPrefix(trimmedReply, "{") || strings.HasPrefix(trimmedReply, "[") || strings.HasPrefix(trimmedReply, "```") {
resp.Reply = ""
}
if len(resp.ToolCalls) == 0 {
if calls, answer, ok := a.executeTextToolCalls(ctx, resp.Reply, toolList); ok {
resp.ToolCalls = calls
if resp.Answer == "" {
resp.Answer = answer
}
trimmedReply := strings.TrimSpace(resp.Reply)
if strings.HasPrefix(trimmedReply, "{") || strings.HasPrefix(trimmedReply, "[") || strings.HasPrefix(trimmedReply, "```") {
resp.Reply = ""
}
}
if a.opts.Checkpoint != nil {
if unfinished := a.unfinishedPlanSteps(); len(unfinished) > 0 && planCompletionTurn < maxPlanCompletionTurns {
if resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
}
if resp.Answer != "" {
a.mem.Add("assistant", resp.Answer)
}
message = "Continue the run. These plan steps are still unfinished and must be completed before a final answer: " + strings.Join(unfinished, ", ")
a.mem.Add("user", message)
messages = a.mem.Messages()
continue
}
}
break
}
if resp.Reply != "" {
-37
View File
@@ -191,43 +191,6 @@ func agentRunFailureStatus(err error) string {
}
}
type operationalError struct {
err error
hint string
}
func (e *operationalError) Error() string {
if e == nil {
return ""
}
return e.err.Error() + "; " + e.hint
}
func (e *operationalError) Unwrap() error {
if e == nil {
return nil
}
return e.err
}
func agentOperationalError(err error) error {
if err == nil {
return nil
}
switch ai.ClassifyError(err) {
case ai.ErrorKindCanceled:
return &operationalError{err: err, hint: "agent run canceled; inspect run history with `micro inspect agent <name> --status canceled` or see docs/guides/debugging-agents.md"}
case ai.ErrorKindTimeout:
return &operationalError{err: err, hint: "agent provider call timed out; inspect run history with `micro inspect agent <name> --status timeout`, then adjust AgentModelCallTimeout/AgentModelRetry or see docs/guides/debugging-agents.md"}
case ai.ErrorKindRateLimited:
return &operationalError{err: err, hint: "agent provider was rate limited; inspect run history with `micro inspect agent <name> --status rate_limited`, check provider keys with `micro agent preflight`, or see docs/guides/debugging-agents.md"}
case ai.ErrorKindUnavailable:
return &operationalError{err: err, hint: "agent provider appears temporarily unavailable; retry with bounded AgentModelRetry and verify provider setup with `micro agent preflight` or docs/guides/debugging-agents.md"}
default:
return err
}
}
func (a *agentImpl) checkpointToolWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.Checkpoint == nil || a.currentRun == nil {
+22 -63
View File
@@ -7,10 +7,7 @@ import (
"testing"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/client"
codecBytes "go-micro.dev/v6/codec/bytes"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/registry"
"go-micro.dev/v6/store"
)
@@ -144,78 +141,40 @@ func TestCheckpointSkipsDuplicateToolWithinAsk(t *testing.T) {
}
}
func TestCheckpointContinuesRunWithUnfinishedPlanStep(t *testing.T) {
func TestCheckpointFailsRunWithUnfinishedPlanStep(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewMemoryStore(), "unfinished-plan-agent")
reg := registry.NewMemoryRegistry()
if err := reg.Register(&registry.Service{
Name: "comms",
Metadata: map[string]string{"type": "agent"},
Nodes: []*registry.Node{{Id: "comms-1", Address: "127.0.0.1:0"}},
}); err != nil {
t.Fatalf("register comms agent: %v", err)
}
delegateCalls := 0
fc := &fakeClient{Client: client.DefaultClient}
fc.callFn = func(ctx context.Context, req client.Request, rsp interface{}) error {
delegateCalls++
if req.Service() != "comms" || req.Endpoint() != "Agent.Chat" {
t.Fatalf("delegate RPC = %s %s, want comms Agent.Chat", req.Service(), req.Endpoint())
}
frame := rsp.(*codecBytes.Frame)
frame.Data = []byte(`{"reply":"owner notified","agent":"comms"}`)
return nil
}
modelCalls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
modelCalls++
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
switch modelCalls {
case 1:
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "create launch tasks", "status": "done"},
map[string]any{"task": "delegate readiness notification to comms", "status": "in_progress"},
},
}})
return &ai.Response{Reply: "tasks are ready"}, nil
case 2:
if !strings.Contains(req.Prompt, "delegate readiness notification to comms") {
t.Fatalf("continuation prompt = %q, want unfinished step", req.Prompt)
}
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "delegate-1", Name: toolDelegate, Input: map[string]any{"task": "Notify owner@acme.com that the launch plan is ready", "to": "comms"}})
if !strings.Contains(res.Content, "owner notified") {
t.Fatalf("delegate result = %q, want owner notified", res.Content)
}
return &ai.Response{Reply: "all done"}, nil
default:
t.Fatalf("unexpected model call %d", modelCalls)
return nil, nil
}
opts.ToolHandler(ctx, ai.ToolCall{ID: "plan-1", Name: toolPlan, Input: map[string]any{
"steps": []any{
map[string]any{"task": "create launch tasks", "status": "done"},
map[string]any{"task": "notify owner via comms", "status": "in_progress"},
},
}})
return &ai.Response{Reply: "all done"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("unfinished-plan-agent"), WithCheckpoint(cp), WithRegistry(reg), WithClient(fc))
resp, err := a.Ask(ctx, "create tasks and notify owner")
a := newTestAgent(Name("unfinished-plan-agent"), WithCheckpoint(cp))
_, err := a.Ask(ctx, "create tasks and notify owner")
if err == nil {
t.Fatal("Ask succeeded with unfinished plan step")
}
if !strings.Contains(err.Error(), "notify owner via comms") {
t.Fatalf("Ask error = %v, want unfinished delegate step named", err)
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Ask: %v", err)
t.Fatalf("Pending: %v", err)
}
if resp.Reply != "all done" {
t.Fatalf("reply = %q, want final continuation reply", resp.Reply)
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want failed run remains actionable", len(runs))
}
if modelCalls != 2 {
t.Fatalf("model calls = %d, want initial plus continuation", modelCalls)
}
if delegateCalls != 1 {
t.Fatalf("delegate calls = %d, want exactly one", delegateCalls)
}
if unfinished := a.unfinishedPlanSteps(); len(unfinished) != 0 {
t.Fatalf("unfinished plan steps = %v, want none", unfinished)
if runs[0].Status != "failed" {
t.Fatalf("run status = %q, want failed", runs[0].Status)
}
}
+1 -26
View File
@@ -2,7 +2,6 @@ package agent
import (
"context"
"io"
"strings"
"testing"
@@ -17,7 +16,6 @@ import (
// it with a deferred cleanup. Tests in this package are not parallel,
// so a package-level hook is safe.
var fakeGen func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error)
var fakeStream func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error)
type fakeModel struct{ opts ai.Options }
@@ -35,33 +33,10 @@ func (m *fakeModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.Gener
return &ai.Response{Reply: "ok"}, nil
}
func (m *fakeModel) Stream(ctx context.Context, req *ai.Request, _ ...ai.GenerateOption) (ai.Stream, error) {
if fakeStream != nil {
return fakeStream(ctx, m.opts, req)
}
return &sliceStream{chunks: []string{"ok"}}, nil
return nil, nil
}
func (m *fakeModel) String() string { return "fake" }
type sliceStream struct {
chunks []string
idx int
closed bool
}
func (s *sliceStream) Recv() (*ai.Response, error) {
if s.idx >= len(s.chunks) {
return nil, io.EOF
}
chunk := s.chunks[s.idx]
s.idx++
return &ai.Response{Reply: chunk}, nil
}
func (s *sliceStream) Close() error {
s.closed = true
return nil
}
func init() {
ai.Register("fake", func(opts ...ai.Option) ai.Model {
m := &fakeModel{}
+3 -125
View File
@@ -3,9 +3,7 @@ package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"sort"
"strings"
"time"
@@ -20,10 +18,9 @@ import (
const agentInstrumentationName = "go-micro.dev/v6/agent"
const (
spanNameRun = "agent.run"
spanNameModelCall = "agent.model.call"
spanNameModelStream = "agent.model.stream"
spanNameToolCall = "agent.tool.call"
spanNameRun = "agent.run"
spanNameModelCall = "agent.model.call"
spanNameToolCall = "agent.tool.call"
AttrRunID = "agent.run.id"
AttrParentRunID = "agent.run.parent_id"
@@ -48,7 +45,6 @@ const (
AttrFlowStep = "agent.flow.step"
AttrDispatch = "agent.dispatch"
AttrTrigger = "agent.trigger"
AttrRunEventKind = "agent.event.kind"
)
type RunEvent struct {
@@ -223,123 +219,6 @@ func (m *tracedModel) Generate(ctx context.Context, req *ai.Request, opts ...ai.
return resp, err
}
func (m *tracedModel) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
info, _ := ai.RunInfoFrom(ctx)
provider := m.String()
model := m.Options().Model
start := time.Now()
if m.a.opts.TraceProvider == nil {
stream, err := m.Model.Stream(ctx, req, opts...)
if err != nil {
m.a.recordRunEvent(RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "stream", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: time.Since(start).Milliseconds(), Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))})
return nil, err
}
return &tracedStream{Stream: stream, a: m.a, info: info, provider: provider, model: model, start: start}, nil
}
attrs := appendRunInfoAttributes([]attribute.KeyValue{
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrProvider, provider),
attribute.String(AttrModel, model),
}, info)
ctx, span := m.a.tracer().Start(ctx, spanNameModelStream, trace.WithAttributes(attrs...))
stream, err := m.Model.Stream(ctx, req, opts...)
if err != nil {
dur := time.Since(start).Milliseconds()
span.SetAttributes(attribute.Int64(AttrLatencyMS, dur), attribute.String(AttrErrorKind, string(ai.ClassifyError(err))))
span.RecordError(err)
span.SetStatus(codes.Error, err.Error())
e := RunEvent{Time: time.Now(), RunID: info.RunID, ParentID: info.ParentID, Agent: info.Agent, Kind: "stream", Provider: provider, Model: model, Attempt: info.Attempt, MaxAttempts: info.MaxAttempts, LatencyMS: dur, Error: err.Error(), ErrorKind: string(ai.ClassifyError(err))}
m.a.recordSpanEvent(span, e)
span.End()
return nil, err
}
return &tracedStream{Stream: stream, a: m.a, info: info, provider: provider, model: model, start: start, span: span}, nil
}
type tracedStream struct {
ai.Stream
a *agentImpl
info ai.RunInfo
provider string
model string
start time.Time
span trace.Span
usage ai.Usage
closed bool
}
func (s *tracedStream) Recv() (*ai.Response, error) {
resp, err := s.Stream.Recv()
if resp != nil {
s.usage = mergeUsage(s.usage, resp.Usage)
}
if err != nil {
if errors.Is(err, io.EOF) {
s.finish(nil)
} else {
s.finish(err)
}
}
return resp, err
}
func (s *tracedStream) Close() error {
err := s.Stream.Close()
s.finish(err)
return err
}
func (s *tracedStream) finish(err error) {
if s.closed {
return
}
s.closed = true
dur := time.Since(s.start).Milliseconds()
e := RunEvent{Time: time.Now(), RunID: s.info.RunID, ParentID: s.info.ParentID, Agent: s.info.Agent, Kind: "stream", Provider: s.provider, Model: s.model, Attempt: s.info.Attempt, MaxAttempts: s.info.MaxAttempts, LatencyMS: dur, Tokens: s.usage}
if err != nil {
e.Error = err.Error()
e.ErrorKind = string(ai.ClassifyError(err))
}
if s.span == nil {
s.a.recordRunEvent(e)
return
}
attrs := appendUsage([]attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}, s.usage)
if s.info.Attempt > 0 {
attrs = append(attrs, attribute.Int(AttrAttempt, s.info.Attempt))
}
if s.info.MaxAttempts > 0 {
attrs = append(attrs, attribute.Int(AttrMaxAttempts, s.info.MaxAttempts))
}
if err != nil {
attrs = append(attrs, attribute.String(AttrErrorKind, e.ErrorKind))
s.span.RecordError(err)
s.span.SetStatus(codes.Error, err.Error())
} else {
s.span.SetStatus(codes.Ok, "")
}
s.span.SetAttributes(attrs...)
s.a.recordSpanEvent(s.span, e)
s.span.End()
}
func mergeUsage(current, next ai.Usage) ai.Usage {
if next.InputTokens > current.InputTokens {
current.InputTokens = next.InputTokens
}
if next.OutputTokens > current.OutputTokens {
current.OutputTokens = next.OutputTokens
}
if next.TotalTokens > current.TotalTokens {
current.TotalTokens = next.TotalTokens
}
return current
}
func appendUsage(attrs []attribute.KeyValue, u ai.Usage) []attribute.KeyValue {
if u.InputTokens > 0 {
attrs = append(attrs, attribute.Int(AttrInputTokens, u.InputTokens))
@@ -444,7 +323,6 @@ func runEventAttributes(e RunEvent) []attribute.KeyValue {
attrs := []attribute.KeyValue{
attribute.String(AttrRunID, e.RunID),
attribute.String(AttrAgentName, e.Agent),
attribute.String(AttrRunEventKind, e.Kind),
}
if e.ParentID != "" {
attrs = append(attrs, attribute.String(AttrParentRunID, e.ParentID))
+1 -92
View File
@@ -5,7 +5,6 @@ import (
"encoding/json"
"errors"
"fmt"
"io"
"strings"
"testing"
"time"
@@ -280,8 +279,7 @@ func spanEventHasRunInfo(events []trace.Event, name, runID, agentName string) bo
continue
}
attrs := spanAttributes(event.Attributes)
wantKind := strings.TrimPrefix(name, "agent.")
if attrs[AttrRunID] == runID && attrs[AttrAgentName] == agentName && attrs[AttrRunEventKind] == wantKind {
if attrs[AttrRunID] == runID && attrs[AttrAgentName] == agentName {
return true
}
}
@@ -579,92 +577,3 @@ func TestListRunSummariesWithOptionsFiltersAndLimits(t *testing.T) {
t.Fatalf("filtered summaries = %#v", got)
}
}
type otelStreamModel struct{ opts ai.Options }
func (m *otelStreamModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
}
return nil
}
func (m *otelStreamModel) Options() ai.Options { return m.opts }
func (m *otelStreamModel) String() string { return "otelstream" }
func (m *otelStreamModel) Generate(context.Context, *ai.Request, ...ai.GenerateOption) (*ai.Response, error) {
return &ai.Response{Reply: "unused"}, nil
}
func (m *otelStreamModel) Stream(context.Context, *ai.Request, ...ai.GenerateOption) (ai.Stream, error) {
return &otelTestStream{chunks: []*ai.Response{{Reply: "one", Usage: ai.Usage{InputTokens: 1, OutputTokens: 2, TotalTokens: 3}}, {Reply: "two", Usage: ai.Usage{InputTokens: 1, OutputTokens: 4, TotalTokens: 5}}}}, nil
}
type otelTestStream struct {
chunks []*ai.Response
idx int
}
func (s *otelTestStream) Recv() (*ai.Response, error) {
if s.idx >= len(s.chunks) {
return nil, io.EOF
}
resp := s.chunks[s.idx]
s.idx++
return resp, nil
}
func (s *otelTestStream) Close() error { return nil }
func TestAgentOpenTelemetrySpansModelStream(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("stream-runner"), Provider("oteltest"), Model("stream-model"), WithStore(st), TraceProvider(tp))
m := a.(*agentImpl).tracedModel(&otelStreamModel{opts: ai.Options{Model: "stream-model"}})
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{RunID: "stream-run-1", ParentID: "parent-run", Agent: "stream-runner", Attempt: 2, MaxAttempts: 3, Flow: "deploy", Step: "plan"})
stream, err := m.Stream(ctx, &ai.Request{Prompt: "stream"})
if err != nil {
t.Fatal(err)
}
for {
_, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatal(err)
}
}
if err := stream.Close(); err != nil {
t.Fatal(err)
}
spans := exp.GetSpans().Snapshots()
var sawStream bool
for _, s := range spans {
if s.Name() != spanNameModelStream {
continue
}
attrs := spanAttributes(s.Attributes())
if attrs[AttrRunID] != "stream-run-1" || attrs[AttrParentRunID] != "parent-run" || attrs[AttrAgentName] != "stream-runner" {
t.Fatalf("stream span missing run lineage: %#v", attrs)
}
if attrs[AttrFlowName] != "deploy" || attrs[AttrFlowStep] != "plan" {
t.Fatalf("stream span missing workflow attributes: %#v", attrs)
}
if attrs[AttrAttempt] != "2" || attrs[AttrMaxAttempts] != "3" || attrs[AttrTotalTokens] != "5" {
t.Fatalf("stream span missing attempt/usage attributes: %#v", attrs)
}
sawStream = true
}
if !sawStream {
t.Fatalf("stream span not emitted; got %d spans", len(spans))
}
events, err := LoadRunEvents(st, "stream-runner", "stream-run-1")
if err != nil {
t.Fatal(err)
}
if len(events) != 1 || events[0].Kind != "stream" || events[0].TraceID == "" || events[0].SpanID == "" || events[0].Tokens.TotalTokens != 5 {
t.Fatalf("unexpected stream run event: %#v", events)
}
}
-52
View File
@@ -77,30 +77,6 @@ func TestAskRetriesTransientErrorsThenSurfacesStructuredError(t *testing.T) {
if attempts != 2 {
t.Fatalf("model attempts = %d, want 2", attempts)
}
if !strings.Contains(err.Error(), "micro inspect agent <name> --status timeout") ||
!strings.Contains(err.Error(), "docs/guides/debugging-agents.md") {
t.Fatalf("Ask error = %q, want actionable timeout/debugging guidance", err.Error())
}
}
func TestAskRateLimitFailureSuggestsPreflightAndInspect(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return nil, testStatusError{code: 429}
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("rate-limit-guidance"), ModelRetry(1, time.Millisecond))
_, err := a.Ask(context.Background(), "hello")
if err == nil {
t.Fatal("Ask succeeded, want rate-limit failure")
}
if !strings.Contains(err.Error(), "micro inspect agent <name> --status rate_limited") ||
!strings.Contains(err.Error(), "micro agent preflight") {
t.Fatalf("Ask error = %q, want inspect and preflight guidance", err.Error())
}
if ai.ClassifyError(err) != ai.ErrorKindRateLimited {
t.Fatalf("ClassifyError(wrapped error) = %q, want rate_limited", ai.ClassifyError(err))
}
}
func TestCanceledAskContextSkipsToolExecution(t *testing.T) {
@@ -156,34 +132,6 @@ func TestToolCallTimeoutPropagatesDeadlineToCustomTool(t *testing.T) {
}
}
func TestAskCancellationDuringToolCallFailsRun(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "cancel-self"})
if !strings.Contains(res.Content, context.Canceled.Error()) {
t.Fatalf("tool result = %q, want cancellation error", res.Content)
}
return &ai.Response{Reply: "should not succeed"}, nil
}
defer func() { fakeGen = nil }()
ctx, cancel := context.WithCancel(context.Background())
a := newTestAgent(
Name("cancel-during-tool"),
WithTool("cancel-self", "cancel the run context", nil, func(context.Context, map[string]any) (string, error) {
cancel()
return "", context.Canceled
}),
)
_, err := a.Ask(ctx, "cancel during tool")
if !errors.Is(err, context.Canceled) {
t.Fatalf("Ask error = %v, want context canceled", err)
}
}
func TestAskCheckpointRecordsTerminalOperationalFailureStatus(t *testing.T) {
tests := []struct {
name string
-39
View File
@@ -185,45 +185,6 @@ type agentStreamAdapter struct {
stream AgentStream
}
type memoryRecordingStream struct {
stream ai.Stream
memory Memory
mu sync.Mutex
chunks []string
closed bool
}
func (s *memoryRecordingStream) Recv() (*ai.Response, error) {
resp, err := s.stream.Recv()
if resp != nil && resp.Reply != "" {
s.mu.Lock()
s.chunks = append(s.chunks, resp.Reply)
s.mu.Unlock()
}
if errors.Is(err, io.EOF) {
s.recordAssistant()
}
return resp, err
}
func (s *memoryRecordingStream) Close() error {
s.recordAssistant()
return s.stream.Close()
}
func (s *memoryRecordingStream) recordAssistant() {
s.mu.Lock()
defer s.mu.Unlock()
if s.closed {
return
}
s.closed = true
if reply := strings.Join(s.chunks, ""); reply != "" {
s.memory.Add("assistant", reply)
}
}
func (s *agentStreamAdapter) Recv() (*ai.Response, error) {
for {
event, err := s.stream.Recv()
-46
View File
@@ -82,52 +82,6 @@ func TestStreamAskHelperRejectsUnsupportedAgent(t *testing.T) {
}
}
func TestAgentStreamUsesProviderStreamingAndRecordsAssistantMemory(t *testing.T) {
var sawRequest bool
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
sawRequest = true
if req.Prompt != "stream the answer" {
t.Fatalf("Prompt = %q, want stream the answer", req.Prompt)
}
if len(req.Messages) != 1 || req.Messages[0].Role != "user" || req.Messages[0].Content != "stream the answer" {
t.Fatalf("Messages = %#v, want current user turn in memory", req.Messages)
}
return &sliceStream{chunks: []string{"hel", "lo"}}, nil
}
defer func() { fakeStream = nil }()
a := newTestAgent(Name("provider-stream"))
stream, err := a.Stream(context.Background(), "stream the answer")
if err != nil {
t.Fatalf("Stream: %v", err)
}
var reply string
for {
chunk, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Recv: %v", err)
}
reply += chunk.Reply
}
if err := stream.Close(); err != nil {
t.Fatalf("Close: %v", err)
}
if !sawRequest {
t.Fatal("provider Stream was not called")
}
if reply != "hello" {
t.Fatalf("reply = %q, want hello", reply)
}
got := a.mem.Messages()
if len(got) != 2 || got[0].Role != "user" || got[0].Content != "stream the answer" || got[1].Role != "assistant" || got[1].Content != "hello" {
t.Fatalf("memory = %#v, want user turn and streamed assistant reply", got)
}
}
func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "stream-resume-agent")
-14
View File
@@ -300,20 +300,6 @@ Default base URL: `https://api.atlascloud.ai`
Atlas Cloud is an enterprise AI infrastructure platform offering high-performance LLM APIs. It exposes an OpenAI-compatible chat completions endpoint with tool calling support.
### MiniMax
```go
m := ai.New("minimax",
ai.WithAPIKey("your-key"),
ai.WithModel("MiniMax-M3"), // default
)
```
Default model: `MiniMax-M3`
Default base URL: `https://api.minimax.io`
MiniMax offers its flagship MiniMax-M3 model via an OpenAI-compatible chat completions endpoint.
## Auto-Detection
Use `AutoDetectProvider()` to detect the provider from a base URL:
+14 -86
View File
@@ -52,15 +52,6 @@ type Provider struct {
opts ai.Options
}
type atlasToolCall struct {
ID string `json:"id"`
Type string `json:"type"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
}
// NewProvider creates a new Atlas Cloud provider.
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
@@ -130,7 +121,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
apiReq["tools"] = tools
}
resp, rawMessage, err := p.callAPI(ctx, "chat", apiReq)
resp, rawMessage, err := p.callAPI(ctx, apiReq)
if err != nil {
return nil, err
}
@@ -140,7 +131,6 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
}
if p.opts.ToolHandler != nil {
var toolResults []string
followUpMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMessage["content"],
@@ -149,9 +139,6 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
for _, tc := range resp.ToolCalls {
content := p.opts.ToolHandler(ctx, tc).Content
if content != "" {
toolResults = append(toolResults, content)
}
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
@@ -164,14 +151,9 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
"messages": followUpMessages,
}
followUpResp, _, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
if err != nil {
return nil, err
}
if followUpResp.Reply != "" {
followUpResp, _, err := p.callAPI(ctx, followUpReq)
if err == nil && followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
} else if len(toolResults) > 0 {
resp.Answer = strings.Join(toolResults, "\n")
}
}
@@ -181,10 +163,6 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
// Stream generates a streaming response from Atlas Cloud's OpenAI-compatible
// chat completions endpoint, emitting content deltas as they arrive.
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
if len(req.Tools) > 0 {
return nil, fmt.Errorf("%w: atlascloud streaming does not expose tools", ai.ErrStreamingUnsupported)
}
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
}
@@ -289,7 +267,7 @@ func (s *atlasStream) Close() error {
return s.body.Close()
}
func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any) (*ai.Response, map[string]any, error) {
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
@@ -312,14 +290,20 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s) during atlascloud %s request (%s): %s", httpResp.Status, phase, atlascloudRequestSummary(req), string(respBody))
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []atlasToolCall `json:"tool_calls"`
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
@@ -351,68 +335,12 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
rawMessage := map[string]any{
"content": choice.Message.Content,
"tool_calls": normalizeAtlasCloudToolCalls(choice.Message.ToolCalls),
"tool_calls": choice.Message.ToolCalls,
}
return response, rawMessage, nil
}
func normalizeAtlasCloudToolCalls(toolCalls []atlasToolCall) []map[string]any {
out := make([]map[string]any, 0, len(toolCalls))
for _, tc := range toolCalls {
toolType := tc.Type
if toolType == "" {
toolType = "function"
}
out = append(out, map[string]any{
"id": tc.ID,
"type": toolType,
"function": map[string]any{
"name": tc.Function.Name,
"arguments": tc.Function.Arguments,
},
})
}
return out
}
func atlascloudRequestSummary(req map[string]any) string {
parts := []string{}
if model, ok := req["model"].(string); ok && model != "" {
parts = append(parts, "model="+model)
}
if messages, ok := req["messages"].([]map[string]any); ok {
parts = append(parts, fmt.Sprintf("messages=%d", len(messages)))
if len(messages) > 0 {
last := messages[len(messages)-1]
if role, ok := last["role"].(string); ok && role != "" {
parts = append(parts, "last_role="+role)
}
if _, ok := last["tool_call_id"].(string); ok {
parts = append(parts, "last_has_tool_call_id=true")
}
}
}
if tools, ok := req["tools"].([]map[string]any); ok {
names := make([]string, 0, len(tools))
for _, tool := range tools {
fn, _ := tool["function"].(map[string]any)
name, _ := fn["name"].(string)
if name != "" {
names = append(names, name)
}
}
parts = append(parts, fmt.Sprintf("tools=%d", len(tools)))
if len(names) > 0 {
parts = append(parts, "tool_names="+strings.Join(names, ","))
}
}
if len(parts) == 0 {
return "request_context=unavailable"
}
return strings.Join(parts, " ")
}
const defaultImageModel = "openai/gpt-image-2/text-to-image"
// GenerateImage creates an image using Atlas Cloud's async image API.
-182
View File
@@ -7,7 +7,6 @@ import (
"io"
"net/http"
"net/http/httptest"
"strings"
"testing"
"go-micro.dev/v6/ai"
@@ -141,187 +140,6 @@ func TestProvider_Stream(t *testing.T) {
}
}
func TestProvider_StreamWithToolsFallsBack(t *testing.T) {
p := NewProvider(ai.WithAPIKey("test-key"))
_, err := p.Stream(context.Background(), &ai.Request{
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "fallback_echo",
Description: "echo fallback marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream with tools error = %v, want ErrStreamingUnsupported", err)
}
}
func TestProvider_GenerateToolCallEmptyFollowUpUsesToolResult(t *testing.T) {
var calls int
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Errorf("path = %s, want /v1/chat/completions", r.URL.Path)
}
calls++
w.Header().Set("Content-Type", "application/json")
switch calls {
case 1:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":""}}]}`))
default:
t.Fatalf("unexpected API call %d", calls)
}
}))
defer ts.Close()
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if call.Name != "conformance_echo" {
t.Fatalf("tool name = %q, want conformance_echo", call.Name)
}
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "conformance_echo",
Description: "echo conformance marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if calls != 2 {
t.Fatalf("API calls = %d, want 2", calls)
}
if resp.Answer != `{"marker":"agent-conformance-ok"}` {
t.Fatalf("Answer = %q, want tool result fallback", resp.Answer)
}
}
func TestProvider_GenerateMinimaxToolRequests(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
bodies = append(bodies, body)
w.Header().Set("Content-Type", "application/json")
switch len(bodies) {
case 1:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-1","function":{"name":"conformance_echo","arguments":"{\"value\":\"agent-conformance\"}"}}]}}]}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("minimaxai/minimax-m3"),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
return ai.ToolResult{ID: call.ID, Content: `{"marker":"agent-conformance-ok"}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
SystemPrompt: "You are helpful.",
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "conformance_echo",
Description: "echo conformance marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if resp.Answer != "done" {
t.Fatalf("Answer = %q, want done", resp.Answer)
}
if len(bodies) != 2 {
t.Fatalf("captured requests = %d, want 2", len(bodies))
}
if got := bodies[0]["model"]; got != "minimaxai/minimax-m3" {
t.Fatalf("initial model = %v", got)
}
tools, ok := bodies[0]["tools"].([]any)
if !ok || len(tools) != 1 {
t.Fatalf("initial tools = %#v, want one tool", bodies[0]["tools"])
}
tool := tools[0].(map[string]any)
if tool["type"] != "function" {
t.Fatalf("tool type = %v, want function", tool["type"])
}
fn := tool["function"].(map[string]any)
if fn["name"] != "conformance_echo" {
t.Fatalf("tool function name = %v", fn["name"])
}
params := fn["parameters"].(map[string]any)
if params["type"] != "object" {
t.Fatalf("parameters type = %v, want object", params["type"])
}
followUpMessages := bodies[1]["messages"].([]any)
if len(followUpMessages) != 4 {
t.Fatalf("follow-up messages = %d, want 4", len(followUpMessages))
}
assistant := followUpMessages[2].(map[string]any)
if assistant["role"] != "assistant" {
t.Fatalf("assistant role = %v", assistant["role"])
}
assistantCalls := assistant["tool_calls"].([]any)
assistantCall := assistantCalls[0].(map[string]any)
if assistantCall["type"] != "function" {
t.Fatalf("assistant tool call type = %v, want function", assistantCall["type"])
}
toolResult := followUpMessages[3].(map[string]any)
if toolResult["role"] != "tool" || toolResult["tool_call_id"] != "call-1" {
t.Fatalf("tool result message = %#v", toolResult)
}
}
func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
}))
defer ts.Close()
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("minimaxai/minimax-m3"),
)
_, err := p.Generate(context.Background(), &ai.Request{
Prompt: "call a tool",
Tools: []ai.Tool{{
Name: "conformance_echo",
Description: "echo conformance marker",
Properties: map[string]any{"value": map[string]any{"type": "string"}},
}},
})
if err == nil {
t.Fatal("Generate error = nil, want 400")
}
msg := err.Error()
for _, want := range []string{"400 Bad Request", "atlascloud chat request", "model=minimaxai/minimax-m3", "tools=1", "tool_names=conformance_echo"} {
if !strings.Contains(msg, want) {
t.Fatalf("error %q missing %q", msg, want)
}
}
if strings.Contains(msg, "test-key") {
t.Fatalf("error leaked API key: %s", msg)
}
}
func TestProvider_Registration(t *testing.T) {
m := ai.New("atlascloud", ai.WithAPIKey("test"))
if m == nil {
+4 -6
View File
@@ -9,7 +9,6 @@ import (
_ "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"
@@ -17,7 +16,7 @@ import (
func TestRegisteredProviders(t *testing.T) {
got := ai.RegisteredProviders("")
want := []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"}
want := []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders() = %#v, want %#v", got, want)
}
@@ -35,7 +34,7 @@ func TestRegisteredProviders(t *testing.T) {
}
got = ai.RegisteredProviders("stream")
want = []string{"atlascloud", "groq", "minimax", "mistral", "openai", "together"}
want = []string{"atlascloud", "groq", "mistral", "openai", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
@@ -48,7 +47,6 @@ func TestCapabilityRows(t *testing.T) {
{Provider: "atlascloud", Capabilities: ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}},
{Provider: "gemini", Capabilities: ai.Capabilities{Model: true}},
{Provider: "groq", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "minimax", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true}},
@@ -61,7 +59,7 @@ func TestCapabilityRows(t *testing.T) {
func TestCapabilityMatrix(t *testing.T) {
matrix := ai.CapabilityMatrix()
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "minimax", "mistral", "openai", "together"} {
for _, provider := range []string{"anthropic", "atlascloud", "gemini", "groq", "mistral", "openai", "together"} {
caps, ok := matrix[provider]
if !ok {
t.Fatalf("CapabilityMatrix missing %q", provider)
@@ -90,7 +88,7 @@ func TestRegisterStream(t *testing.T) {
}
got := ai.RegisteredProviders("stream")
want := []string{"atlascloud", "groq", "minimax", "mistral", "openai", "test-stream", "together"}
want := []string{"atlascloud", "groq", "mistral", "openai", "test-stream", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
-196
View File
@@ -1,196 +0,0 @@
// Package minimax implements the MiniMax model provider.
//
// MiniMax offers its flagship MiniMax-M3 model via an OpenAI-compatible
// chat completions endpoint.
//
// Usage:
//
// import _ "go-micro.dev/v6/ai/minimax"
//
// m := ai.New("minimax",
// ai.WithAPIKey("your-api-key"),
// )
package minimax
import (
"bytes"
"context"
"encoding/json"
"fmt"
"io"
"net/http"
"strings"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/ai/internal/openaiapi"
)
func init() {
ai.Register("minimax", func(opts ...ai.Option) ai.Model {
return NewProvider(opts...)
})
ai.RegisterStream("minimax")
}
type Provider struct {
opts ai.Options
}
func NewProvider(opts ...ai.Option) *Provider {
options := ai.NewOptions(opts...)
if options.Model == "" {
options.Model = "MiniMax-M3"
}
if options.BaseURL == "" {
options.BaseURL = "https://api.minimax.io"
}
return &Provider{opts: options}
}
func (p *Provider) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&p.opts)
}
return nil
}
func (p *Provider) Options() ai.Options { return p.opts }
func (p *Provider) String() string { return "minimax" }
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
var tools []map[string]any
for _, t := range req.Tools {
tools = append(tools, map[string]any{
"type": "function",
"function": map[string]any{
"name": t.Name,
"description": t.Description,
"parameters": map[string]any{
"type": "object",
"properties": t.Properties,
},
},
})
}
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
{"role": "user", "content": req.Prompt},
}
apiReq := map[string]any{
"model": p.opts.Model,
"messages": messages,
}
if len(tools) > 0 {
apiReq["tools"] = tools
}
resp, rawMessage, err := p.callAPI(ctx, apiReq)
if err != nil {
return nil, err
}
if len(resp.ToolCalls) == 0 {
return resp, nil
}
if p.opts.ToolHandler != nil {
followUpMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMessage["content"],
"tool_calls": rawMessage["tool_calls"],
})
for _, tc := range resp.ToolCalls {
content := p.opts.ToolHandler(ctx, tc).Content
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
"content": content,
})
}
followUpResp, _, err := p.callAPI(ctx, map[string]any{
"model": p.opts.Model,
"messages": followUpMessages,
})
if err == nil && followUpResp.Reply != "" {
resp.Answer = followUpResp.Reply
}
}
return resp, nil
}
func (p *Provider) Stream(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (ai.Stream, error) {
return openaiapi.Stream(ctx, p.opts, req, "/v1/chat/completions")
}
func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Response, map[string]any, error) {
reqBody, err := json.Marshal(req)
if err != nil {
return nil, nil, fmt.Errorf("failed to marshal request: %w", err)
}
apiURL := strings.TrimRight(p.opts.BaseURL, "/") + "/v1/chat/completions"
httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, apiURL, bytes.NewReader(reqBody))
if err != nil {
return nil, nil, fmt.Errorf("failed to create request: %w", err)
}
httpReq.Header.Set("Content-Type", "application/json")
httpReq.Header.Set("Authorization", "Bearer "+p.opts.APIKey)
httpResp, err := http.DefaultClient.Do(httpReq)
if err != nil {
return nil, nil, fmt.Errorf("API request failed: %w", err)
}
defer httpResp.Body.Close()
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
Choices []struct {
Message struct {
Content string `json:"content"`
ToolCalls []struct {
ID string `json:"id"`
Function struct {
Name string `json:"name"`
Arguments string `json:"arguments"`
} `json:"function"`
} `json:"tool_calls"`
} `json:"message"`
} `json:"choices"`
}
if err := json.Unmarshal(respBody, &chatResp); err != nil {
return nil, nil, fmt.Errorf("failed to parse response: %w", err)
}
if len(chatResp.Choices) == 0 {
return nil, nil, fmt.Errorf("no response from API")
}
choice := chatResp.Choices[0]
response := &ai.Response{Reply: choice.Message.Content}
for _, tc := range choice.Message.ToolCalls {
var input map[string]any
if err := json.Unmarshal([]byte(tc.Function.Arguments), &input); err != nil {
input = map[string]any{}
}
response.ToolCalls = append(response.ToolCalls, ai.ToolCall{
ID: tc.ID,
Name: tc.Function.Name,
Input: input,
})
}
rawMessage := map[string]any{
"content": choice.Message.Content,
"tool_calls": choice.Message.ToolCalls,
}
return response, rawMessage, nil
}
-96
View File
@@ -1,96 +0,0 @@
package minimax
import (
"context"
"encoding/json"
"errors"
"io"
"net/http"
"net/http/httptest"
"testing"
"go-micro.dev/v6/ai"
)
func TestProvider_String(t *testing.T) {
if NewProvider().String() != "minimax" {
t.Errorf("got %q", NewProvider().String())
}
}
func TestProvider_Defaults(t *testing.T) {
opts := NewProvider().Options()
if opts.Model != "MiniMax-M3" {
t.Errorf("default model = %q", opts.Model)
}
if opts.BaseURL != "https://api.minimax.io" {
t.Errorf("default base URL = %q", opts.BaseURL)
}
}
func TestProvider_Init(t *testing.T) {
p := NewProvider()
if err := p.Init(ai.WithModel("m"), ai.WithAPIKey("k")); err != nil {
t.Fatal(err)
}
if p.Options().Model != "m" || p.Options().APIKey != "k" {
t.Error("Init did not apply options")
}
}
func TestProvider_Generate_NoAPIKey(t *testing.T) {
if _, err := NewProvider().Generate(context.Background(), &ai.Request{Prompt: "hi"}); err == nil {
t.Error("expected error without API key")
}
}
func TestProvider_Stream(t *testing.T) {
var sawStream bool
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if r.URL.Path != "/v1/chat/completions" {
t.Fatalf("path = %s, want /v1/chat/completions", r.URL.Path)
}
var body map[string]any
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
t.Fatalf("decode request: %v", err)
}
sawStream, _ = body["stream"].(bool)
w.Header().Set("Content-Type", "text/event-stream")
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"hel\"}}]}\n\n"))
_, _ = w.Write([]byte("data: {\"choices\":[{\"delta\":{\"content\":\"lo\"}}]}\n\n"))
_, _ = w.Write([]byte("data: [DONE]\n\n"))
}))
defer ts.Close()
p := NewProvider(ai.WithAPIKey("test-key"), ai.WithBaseURL(ts.URL))
stream, err := p.Stream(context.Background(), &ai.Request{Prompt: "Hello"})
if err != nil {
t.Fatalf("Stream returned error: %v", err)
}
defer stream.Close()
if !sawStream {
t.Fatal("stream request did not set stream=true")
}
first, err := stream.Recv()
if err != nil || first.Reply != "hel" {
t.Fatalf("first chunk = %#v, %v; want hel", first, err)
}
second, err := stream.Recv()
if err != nil || second.Reply != "lo" {
t.Fatalf("second chunk = %#v, %v; want lo", second, err)
}
if _, err := stream.Recv(); !errors.Is(err, io.EOF) {
t.Fatalf("final error = %v, want EOF", err)
}
}
func TestProvider_Registration(t *testing.T) {
m := ai.New("minimax", ai.WithAPIKey("test"))
if m == nil {
t.Fatal("provider not registered")
}
if m.String() != "minimax" {
t.Errorf("got %q", m.String())
}
}
-2
View File
@@ -212,8 +212,6 @@ func AutoDetectProvider(baseURL string) string {
return "gemini"
case strings.Contains(baseURL, "groq"):
return "groq"
case strings.Contains(baseURL, "minimax"):
return "minimax"
case strings.Contains(baseURL, "mistral"):
return "mistral"
case strings.Contains(baseURL, "together"):
+4 -8
View File
@@ -99,19 +99,15 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
}
resp, err := m.Generate(callCtx, req, opts...)
cancel()
// Caller cancellation/deadline always wins and is not retried, even if
// a provider or tool loop swallowed the canceled tool result and returned
// a final response. This keeps agent runs from appearing successful after
// their controlling context was abandoned.
if ctxErr := ctx.Err(); ctxErr != nil {
return nil, ctxErr
}
if err == nil {
return resp, nil
}
last = err
// Caller cancellation/deadline always wins and is not retried.
if ctx.Err() != nil {
return nil, ctx.Err()
}
transient := IsTransientError(err)
if attempt == policy.MaxAttempts || !transient {
if attempt > 1 || transient {
+1 -3
View File
@@ -18,7 +18,6 @@ import (
_ "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"
@@ -279,7 +278,6 @@ func conformingStreamProviders(t *testing.T) []string {
allowed := map[string]struct{}{
"atlascloud": {},
"groq": {},
"minimax": {},
"mistral": {},
"openai": {},
"together": {},
@@ -290,7 +288,7 @@ func conformingStreamProviders(t *testing.T) []string {
out = append(out, provider)
}
}
want := []string{"atlascloud", "groq", "minimax", "mistral", "openai", "together"}
want := []string{"atlascloud", "groq", "mistral", "openai", "together"}
if !reflect.DeepEqual(out, want) {
t.Fatalf("conforming stream providers = %#v, want %#v (registered stream providers: %#v)", out, want, providers)
}
-1
View File
@@ -646,7 +646,6 @@ micro loop verify # check a repo is wired correctly
| Builder | `loop-builder.yml` | Builds the top open item as a single-concern PR, auto-merged on green CI |
| Triage | `loop-triage.yml` | Turns CI failures into scoped fix issues, back into the queue |
| Coherence | `loop-coherence.yml` | Keeps README/docs/CHANGELOG aligned with the North Star *(opt-in)* |
| Security | `loop-security.yml` | Audits for vulnerabilities and files them; never auto-merges fixes, never publishes exploit detail *(opt-in)* |
| Release | `loop-release.yml` | Cuts the next patch tag when the branch has new commits *(opt-in)* |
The workflows are the **mechanism**; each dispatch role's instruction is an editable file in `.github/loop/prompts/` — the **policy**. Edit those prompts (and `.github/loop/NORTH_STAR.md`) to steer the loop without touching the CLI. That split is what lets go-micro itself use `micro loop` while keeping its own richer prompts.
+6 -29
View File
@@ -16,7 +16,6 @@ type preflightCheck struct {
OK bool
Detail string
Fix string
Next string
}
type preflightDeps struct {
@@ -53,9 +52,6 @@ func runAgentPreflight(w io.Writer, deps preflightDeps) error {
if !check.OK && check.Fix != "" {
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
}
if !check.OK && check.Next != "" {
fmt.Fprintf(w, " Next: %s\n", check.Next)
}
}
if failures > 0 {
return fmt.Errorf("first-agent preflight failed: %d check(s) need attention", failures)
@@ -91,23 +87,19 @@ func agentPreflightChecks(deps preflightDeps) []preflightCheck {
func checkGoToolchain(deps preflightDeps) preflightCheck {
path, err := deps.lookPath("go")
if err != nil {
return preflightCheck{Name: "Go toolchain", Detail: "go was not found on PATH", Fix: "Install Go 1.24 or newer from https://go.dev/doc/install and ensure go is on PATH.", Next: "After installing Go, rerun micro agent preflight, then continue with docs/guides/your-first-agent.html."}
return preflightCheck{Name: "Go toolchain", Fix: "Install Go 1.24 or newer and ensure go is on PATH."}
}
out, err := deps.commandOutput("go", "version")
if err != nil {
return preflightCheck{Name: "Go toolchain", Detail: strings.TrimSpace(string(out)), Fix: "Ensure the go command runs successfully (try `go version`) before starting the agent walkthrough.", Next: "Use docs/guides/debugging-agents.html after the toolchain check passes if an agent run still fails."}
return preflightCheck{Name: "Go toolchain", Detail: strings.TrimSpace(string(out)), Fix: "Ensure the go command runs successfully."}
}
version := firstLine(out)
if !goVersionAtLeast(version, 1, 24) {
return preflightCheck{Name: "Go toolchain", Detail: fmt.Sprintf("%s (%s)", version, path), Fix: "Upgrade to Go 1.24 or newer before running generated services.", Next: "Rerun micro agent preflight, then continue with docs/guides/your-first-agent.html."}
}
return preflightCheck{Name: "Go toolchain", OK: true, Detail: fmt.Sprintf("%s (%s)", version, path)}
return preflightCheck{Name: "Go toolchain", OK: true, Detail: fmt.Sprintf("%s (%s)", firstLine(out), path)}
}
func checkMicroBinary(deps preflightDeps) preflightCheck {
exe, err := deps.executable()
if err != nil || exe == "" {
return preflightCheck{Name: "micro binary", Detail: "micro executable path is unavailable", Fix: "Install the micro CLI or run this check through `go run ./cmd/micro agent preflight` from the repository.", Next: "Then follow docs/getting-started.html for the scaffold -> run path."}
return preflightCheck{Name: "micro binary", Fix: "Install the micro CLI or run this check through go run ./cmd/micro agent preflight."}
}
version := deps.version()
if version == "" {
@@ -125,7 +117,7 @@ func checkProviderKey(deps preflightDeps) preflightCheck {
}
}
if len(found) == 0 {
return preflightCheck{Name: "provider API key", Detail: "no supported provider key found", Fix: "Export MICRO_AI_API_KEY or a provider key such as ANTHROPIC_API_KEY before running provider-backed agents.", Next: "For a no-secret path, run the mock-model walkthrough in docs/guides/no-secret-first-agent.html; for real providers, see docs/guides/debugging-agents.html#provider-failures."}
return preflightCheck{Name: "provider API key", Detail: "no supported provider key found", Fix: "Export MICRO_AI_API_KEY or a provider key such as ANTHROPIC_API_KEY before running provider-backed agents."}
}
return preflightCheck{Name: "provider API key", OK: true, Detail: "found " + strings.Join(found, ", ")}
}
@@ -133,7 +125,7 @@ func checkProviderKey(deps preflightDeps) preflightCheck {
func checkPortAvailable(deps preflightDeps, addr, use string) preflightCheck {
ln, err := deps.listen("tcp", addr)
if err != nil {
return preflightCheck{Name: "local port " + addr, Detail: "busy or unavailable for " + use, Fix: "Stop the process using " + addr + " (for example, `lsof -i :8080`) or run `micro run --address` with a free port.", Next: "Once the gateway starts, open http://localhost:8080/agent or continue with docs/guides/your-first-agent.html#chat-with-your-agent."}
return preflightCheck{Name: "local port " + addr, Detail: "busy or unavailable for " + use, Fix: "Stop the process using " + addr + " or run micro run --address with a free port."}
}
_ = ln.Close()
return preflightCheck{Name: "local port " + addr, OK: true, Detail: "available for " + use}
@@ -146,18 +138,3 @@ func firstLine(b []byte) string {
}
return s
}
func goVersionAtLeast(line string, wantMajor, wantMinor int) bool {
idx := strings.Index(line, "go1.")
if idx < 0 {
return false
}
var major, minor int
if _, err := fmt.Sscanf(line[idx:], "go%d.%d", &major, &minor); err != nil {
return false
}
if major != wantMajor {
return major > wantMajor
}
return minor >= wantMinor
}
+1 -47
View File
@@ -61,59 +61,13 @@ func TestRunAgentPreflightReportsActionableFailures(t *testing.T) {
t.Fatal("runAgentPreflight() error = nil")
}
got := out.String()
for _, want := range []string{"✗ Go toolchain", "go was not found on PATH", "https://go.dev/doc/install", "docs/guides/your-first-agent.html", "✗ micro binary", "go run ./cmd/micro agent preflight", "✗ provider API key", "docs/guides/no-secret-first-agent.html", "docs/guides/debugging-agents.html#provider-failures", "✗ local port :8080", "lsof -i :8080", "micro run --address"} {
for _, want := range []string{"✗ Go toolchain", "Install Go 1.24", "✗ micro binary", "✗ provider API key", "ANTHROPIC_API_KEY", "✗ local port :8080", "micro run --address"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestRunAgentPreflightReportsOldGoVersion(t *testing.T) {
deps := preflightDeps{
lookPath: func(name string) (string, error) { return "/usr/bin/" + name, nil },
commandOutput: func(name string, args ...string) ([]byte, error) {
return []byte("go version go1.23.9 linux/amd64\n"), nil
},
executable: func() (string, error) { return "/usr/local/bin/micro", nil },
getenv: func(key string) string {
if key == "ANTHROPIC_API_KEY" {
return "set"
}
return ""
},
listen: func(network, address string) (net.Listener, error) { return stubListener{}, nil },
}
var out bytes.Buffer
err := runAgentPreflight(&out, deps)
if err == nil {
t.Fatal("runAgentPreflight() error = nil")
}
got := out.String()
for _, want := range []string{"✗ Go toolchain", "go1.23.9", "Upgrade to Go 1.24 or newer", "Rerun micro agent preflight"} {
if !strings.Contains(got, want) {
t.Fatalf("output missing %q:\n%s", want, got)
}
}
}
func TestGoVersionAtLeast(t *testing.T) {
tests := []struct {
line string
want bool
}{
{line: "go version go1.24.0 linux/amd64", want: true},
{line: "go version go1.25.1 linux/amd64", want: true},
{line: "go version go1.23.9 linux/amd64", want: false},
{line: "unexpected", want: false},
}
for _, tt := range tests {
if got := goVersionAtLeast(tt.line, 1, 24); got != tt.want {
t.Fatalf("goVersionAtLeast(%q) = %v, want %v", tt.line, got, tt.want)
}
}
}
func TestFirstLine(t *testing.T) {
if got := firstLine([]byte("one\ntwo")); got != "one" {
t.Fatalf("firstLine() = %q", got)
-35
View File
@@ -24,30 +24,6 @@ import (
_ "go-micro.dev/v6/cmd/micro/cli/remote"
)
const docsWayfinding = `First-agent and 0→hero docs:
1. No-secret first-agent transcript
https://go-micro.dev/docs/guides/no-secret-first-agent.html
Run the maintained support agent without a provider key:
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
2. Your First Agent
https://go-micro.dev/docs/guides/your-first-agent.html
Build a service-backed agent, then use:
micro agent preflight
micro run
micro chat
3. Debugging your agent
https://go-micro.dev/docs/guides/debugging-agents.html
Inspect agent runs and memory with:
micro inspect agent
micro runs <agent>
4. 0→hero Reference
https://go-micro.dev/docs/guides/zero-to-hero.html
Walk the scaffold → run → chat → inspect → deploy dry-run lifecycle.`
func genProtoHandler(c *cli.Context) error {
cmd := exec.Command("find", ".", "-name", "*.proto", "-exec", "protoc", "--proto_path=.", "--micro_out=.", "--go_out=.", `{}`, `;`)
cmd.Stdout = os.Stdout
@@ -120,17 +96,6 @@ func init() {
return nil
},
},
{
Name: "docs",
Usage: "Show the first-agent and 0→hero documentation path",
Description: `Print the maintained adoption on-ramp for new Go Micro developers:
the no-secret first-agent transcript, Your First Agent, debugging guide, and
0→hero lifecycle reference.`,
Action: func(ctx *cli.Context) error {
fmt.Fprintln(ctx.App.Writer, docsWayfinding)
return nil
},
},
{
Name: "call",
Usage: "Call a service",
+1 -27
View File
@@ -1,7 +1,6 @@
package main
import (
"bytes"
"strings"
"testing"
@@ -22,7 +21,7 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
}
}
for _, want := range []string{"new", "run", "chat", "inspect", "agent", "docs"} {
for _, want := range []string{"new", "run", "chat", "inspect", "agent"} {
if !commands[want] {
t.Fatalf("first-agent walkthrough missing %q command", want)
}
@@ -38,31 +37,6 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") {
t.Fatalf("micro chat should describe the service-to-agent walkthrough boundary; description was %q", chat.Description)
}
docs := commandByName(t, "docs")
if !strings.Contains(docs.Usage, "first-agent") || !strings.Contains(docs.Usage, "0→hero") {
t.Fatalf("micro docs should advertise the first-agent and 0→hero docs path; usage was %q", docs.Usage)
}
var out bytes.Buffer
app := cli.NewApp()
app.Writer = &out
if err := docs.Action(cli.NewContext(app, nil, nil)); err != nil {
t.Fatalf("micro docs failed: %v", err)
}
for _, want := range []string{
"no-secret-first-agent.html",
"your-first-agent.html",
"debugging-agents.html",
"zero-to-hero.html",
"micro agent preflight",
"micro run",
"micro chat",
"micro inspect agent",
} {
if !strings.Contains(out.String(), want) {
t.Fatalf("micro docs output missing %q:\n%s", want, out.String())
}
}
}
func commandByName(t *testing.T, name string) *cli.Command {
+12 -43
View File
@@ -40,11 +40,10 @@ var templatesFS embed.FS
// given repo tunes.
type config struct {
// Shared.
DefaultBranch string // base branch for the loop's PRs (e.g. main)
AgentMention string // how the workflows summon the agent (e.g. @codex)
TokenSecret string // repo secret holding the user PAT that drives dispatch
CIWorkflow string // human-readable CI workflow name(s) triage watches
CIWorkflowsYAML string // the same as a YAML array literal, e.g. ["Lint", "Run Tests"]
DefaultBranch string // base branch for the loop's PRs (e.g. main)
AgentMention string // how the workflows summon the agent (e.g. @codex)
TokenSecret string // repo secret holding the user PAT that drives dispatch
CIWorkflow string // name: of the CI workflow triage watches for failures
// Per-dispatch-role (set while rendering each one).
Role string
@@ -71,11 +70,10 @@ var dispatchRoles = map[string]dispatchRole{
"planner": {"Loop: Planner", "Loop: planning review", "loop-planner", "planner-cron", "0 * * * *"},
"builder": {"Loop: Builder", "Loop: build increment", "loop-builder", "builder-cron", "30 * * * *"},
"coherence": {"Loop: Coherence", "Loop: coherence review", "loop-coherence", "coherence-cron", "0 7 * * *"},
"security": {"Loop: Security", "Loop: security review", "loop-security", "security-cron", "0 6 * * 1"},
}
// allRoles is the full set, in a stable order, for --roles=all and help text.
var allRoles = []string{"planner", "builder", "triage", "coherence", "security", "release"}
var allRoles = []string{"planner", "builder", "triage", "coherence", "release"}
const (
promptDir = ".github/loop/prompts"
@@ -96,7 +94,6 @@ Roles (choose with --roles, default: planner,builder,triage):
builder builds the top open item as a single-concern PR (auto-merged on green CI)
triage turns CI failures into scoped fix issues back into the queue
coherence keeps README/docs/CHANGELOG aligned with the North Star
security audits for vulnerabilities and files them (fixes stay human-reviewed)
release cuts the next patch tag when the branch has new commits
Each dispatch role's instruction is an editable file in .github/loop/prompts/ —
@@ -125,11 +122,10 @@ Examples:
&cli.StringFlag{Name: "branch", Usage: "Base branch for the loop's PRs (auto-detected if empty)"},
&cli.StringFlag{Name: "agent", Usage: "How the workflows summon the agent (an @mention)", Value: "@codex"},
&cli.StringFlag{Name: "token-secret", Usage: "Repo secret holding the user PAT that drives dispatch", Value: "LOOP_TOKEN"},
&cli.StringFlag{Name: "ci-workflow", Usage: "CI workflow name(s) triage watches for failures (comma-separated)", Value: "CI"},
&cli.StringFlag{Name: "ci-workflow", Usage: "name: of the CI workflow triage watches for failures", Value: "CI"},
&cli.StringFlag{Name: "planner-cron", Usage: "Cron schedule for the planner", Value: "0 * * * *"},
&cli.StringFlag{Name: "builder-cron", Usage: "Cron schedule for the builder", Value: "30 * * * *"},
&cli.StringFlag{Name: "coherence-cron", Usage: "Cron schedule for the coherence role", Value: "0 7 * * *"},
&cli.StringFlag{Name: "security-cron", Usage: "Cron schedule for the security role", Value: "0 6 * * 1"},
&cli.StringFlag{Name: "release-cron", Usage: "Cron schedule for the release role", Value: "0 23 * * *"},
&cli.StringFlag{Name: "tag-prefix", Usage: "Tag prefix the release role matches and bumps", Value: "v"},
&cli.BoolFlag{Name: "force", Usage: "Overwrite existing loop files"},
@@ -153,15 +149,13 @@ func runInit(c *cli.Context) error {
return err
}
ciNames := splitCSV(c.String("ci-workflow"))
cfg := config{
DefaultBranch: c.String("branch"),
AgentMention: strings.TrimSpace(c.String("agent")),
TokenSecret: strings.TrimSpace(c.String("token-secret")),
CIWorkflow: strings.Join(ciNames, ", "),
CIWorkflowsYAML: yamlStringArray(ciNames),
TagPrefix: c.String("tag-prefix"),
ReleaseCron: c.String("release-cron"),
DefaultBranch: c.String("branch"),
AgentMention: strings.TrimSpace(c.String("agent")),
TokenSecret: strings.TrimSpace(c.String("token-secret")),
CIWorkflow: c.String("ci-workflow"),
TagPrefix: c.String("tag-prefix"),
ReleaseCron: c.String("release-cron"),
}
if cfg.DefaultBranch == "" {
cfg.DefaultBranch = detectDefaultBranch(dir)
@@ -174,7 +168,6 @@ func runInit(c *cli.Context) error {
"planner": c.String("planner-cron"),
"builder": c.String("builder-cron"),
"coherence": c.String("coherence-cron"),
"security": c.String("security-cron"),
}
if err := scaffold(dir, cfg, roles, crons, c.Bool("force")); err != nil {
@@ -212,30 +205,6 @@ func parseRoles(spec string) ([]string, error) {
return out, nil
}
// splitCSV splits a comma-separated flag into trimmed, non-empty values.
func splitCSV(s string) []string {
var out []string
for _, v := range strings.Split(s, ",") {
if v = strings.TrimSpace(v); v != "" {
out = append(out, v)
}
}
if len(out) == 0 {
out = []string{"CI"}
}
return out
}
// yamlStringArray renders names as a YAML/JSON flow array, e.g. ["Lint", "Run Tests"].
// Names are known workflow display names (no embedded quotes), so a simple quote is safe.
func yamlStringArray(names []string) string {
quoted := make([]string, len(names))
for i, n := range names {
quoted[i] = fmt.Sprintf("%q", n)
}
return "[" + strings.Join(quoted, ", ") + "]"
}
func isRole(r string) bool {
for _, x := range allRoles {
if x == r {
+11 -32
View File
@@ -8,13 +8,12 @@ import (
)
var testCfg = config{
DefaultBranch: "main",
AgentMention: "@codex",
TokenSecret: "LOOP_TOKEN",
CIWorkflow: "CI",
CIWorkflowsYAML: `["CI"]`,
TagPrefix: "v",
ReleaseCron: "0 23 * * *",
DefaultBranch: "main",
AgentMention: "@codex",
TokenSecret: "LOOP_TOKEN",
CIWorkflow: "CI",
TagPrefix: "v",
ReleaseCron: "0 23 * * *",
}
var testCrons = map[string]string{"planner": "0 * * * *", "builder": "30 * * * *", "coherence": "0 7 * * *"}
@@ -29,7 +28,6 @@ func renderCases() map[string]config {
"templates/prompts/planner.md.tmpl": testCfg,
"templates/prompts/builder.md.tmpl": testCfg,
"templates/prompts/coherence.md.tmpl": testCfg,
"templates/prompts/security.md.tmpl": testCfg,
}
for role, d := range dispatchRoles {
rc := testCfg
@@ -60,7 +58,7 @@ func TestRenderIsPlaceholderFreeAndKeepsGHAExpressions(t *testing.T) {
func TestBaseBranchSubstitutedIntoPrompts(t *testing.T) {
// The base branch appears in the PR-opening instructions of these prompts.
for _, p := range []string{"planner", "builder", "coherence", "security"} {
for _, p := range []string{"planner", "builder", "coherence"} {
s := mustRender(t, "templates/prompts/"+p+".md.tmpl", testCfg)
if !strings.Contains(s, "--base main") {
t.Errorf("%s prompt missing substituted base branch", p)
@@ -118,7 +116,7 @@ func TestDispatchWorkflowsStripPromptComments(t *testing.T) {
func TestPromptsLeaveRuntimeTokensLiteral(t *testing.T) {
// __ISSUE__ must survive render (the workflow substitutes it at runtime).
for _, p := range []string{"planner", "builder", "coherence", "triage", "security"} {
for _, p := range []string{"planner", "builder", "coherence", "triage"} {
s := mustRender(t, "templates/prompts/"+p+".md.tmpl", testCfg)
if !strings.Contains(s, "__ISSUE__") {
t.Errorf("%s prompt lost its __ISSUE__ runtime token", p)
@@ -134,19 +132,19 @@ func TestScaffoldAllRolesWritesEverything(t *testing.T) {
dir := t.TempDir()
mustWrite(t, filepath.Join(dir, wfDir, "ci.yml"), "name: CI\n")
roles := []string{"planner", "builder", "triage", "coherence", "security", "release"}
roles := []string{"planner", "builder", "triage", "coherence", "release"}
if err := scaffold(dir, testCfg, roles, testCrons, false); err != nil {
t.Fatalf("scaffold: %v", err)
}
wantWorkflows := []string{"loop-planner.yml", "loop-builder.yml", "loop-triage.yml", "loop-coherence.yml", "loop-security.yml", "loop-release.yml"}
wantWorkflows := []string{"loop-planner.yml", "loop-builder.yml", "loop-triage.yml", "loop-coherence.yml", "loop-release.yml"}
for _, w := range wantWorkflows {
if !fileExists(filepath.Join(dir, wfDir, w)) {
t.Errorf("expected %s", w)
}
}
// Dispatch + triage roles have prompts; release does not.
for _, p := range []string{"planner.md", "builder.md", "triage.md", "coherence.md", "security.md"} {
for _, p := range []string{"planner.md", "builder.md", "triage.md", "coherence.md"} {
if !fileExists(filepath.Join(dir, promptDir, p)) {
t.Errorf("expected prompt %s", p)
}
@@ -203,25 +201,6 @@ func TestReinitForceKeepsPromptsRefreshesWorkflows(t *testing.T) {
}
}
func TestCIWorkflowListRendersAsYAMLArray(t *testing.T) {
if got := yamlStringArray([]string{"Harness (E2E)", "Lint", "Run Tests"}); got != `["Harness (E2E)", "Lint", "Run Tests"]` {
t.Errorf("yamlStringArray = %q", got)
}
if got := splitCSV("Harness (E2E), Lint ,Run Tests"); strings.Join(got, "|") != "Harness (E2E)|Lint|Run Tests" {
t.Errorf("splitCSV = %v", got)
}
if got := splitCSV(" "); strings.Join(got, "|") != "CI" {
t.Errorf("splitCSV empty should default to CI, got %v", got)
}
// The triage workflow must embed the array so workflow_run watches all of them.
cfg := testCfg
cfg.CIWorkflowsYAML = `["Harness (E2E)", "Lint", "Run Tests"]`
s := mustRender(t, "templates/loop-triage.yml.tmpl", cfg)
if !strings.Contains(s, `workflows: ["Harness (E2E)", "Lint", "Run Tests"]`) {
t.Errorf("triage workflow does not watch the CI workflow list:\n%s", s)
}
}
func TestParseRoles(t *testing.T) {
if got, err := parseRoles("all"); err != nil || len(got) != len(allRoles) {
t.Errorf("all => %v, %v", got, err)
@@ -1,14 +1,14 @@
name: "Loop: Triage"
# Generated by `micro loop init`. The feedback path of the evaluator: when a CI
# workflow (<< .CIWorkflow >>) fails on a non-PR run, dispatch the agent
# Generated by `micro loop init`. The feedback path of the evaluator: when the
# CI workflow ("<< .CIWorkflow >>") fails on a non-PR run, dispatch the agent
# (<< .AgentMention >>) with the instruction in .github/loop/prompts/triage.md
# to root-cause the failure and file scoped fix issues back into the queue — so
# failures become fixes with no human in the middle. Gated on << .TokenSecret >>.
on:
workflow_run:
workflows: << .CIWorkflowsYAML >>
workflows: ["<< .CIWorkflow >>"]
types: [completed]
permissions:
@@ -33,7 +33,6 @@ jobs:
REPO: ${{ github.repository }}
RUN_ID: ${{ github.event.workflow_run.id }}
RUN_URL: ${{ github.event.workflow_run.html_url }}
WORKFLOW_NAME: ${{ github.event.workflow_run.name }}
run: |
if [ "$HAS_TOKEN" != "true" ]; then
echo "<< .TokenSecret >> is not set — skipping."
@@ -45,8 +44,8 @@ jobs:
exit 1
fi
ISSUE_URL=$(gh issue create --repo "$REPO" \
--title "Loop: triage failed run $RUN_ID ($WORKFLOW_NAME)" \
--body "The '$WORKFLOW_NAME' workflow failed on a non-PR run: $RUN_URL")
--title "Loop: triage failed run $RUN_ID" \
--body "The '<< .CIWorkflow >>' workflow failed: $RUN_URL")
ISSUE_NUM="${ISSUE_URL##*/}"
echo "Opened issue #$ISSUE_NUM — dispatching triage."
{
@@ -1,22 +0,0 @@
<!--
The SECURITY prompt — the editable policy for the security role. The workflow
prepends the agent @mention and substitutes __ISSUE__ before posting. Keep
__ISSUE__ literal.
Security is deliberately more conservative than the other roles: it does NOT
auto-merge fixes, and it does NOT publish exploit details in public issues.
-->
Act as the security reviewer for this repository. Audit for real, exploitable vulnerabilities — do not pad the report with theoretical or low-value lint-style noise.
WHAT TO LOOK FOR: injection (SQL/command/template), authentication and authorization bypass, credential/secret/token exposure (in code, logs, or error messages), SSRF and unsafe outbound requests (especially user- or config-controlled URLs), path traversal, unsafe deserialization, missing or incorrect input validation on trust boundaries (HTTP handlers, RPC endpoints, message consumers), insecure defaults (TLS, auth, permissions), unsafe use of `crypto`/randomness, and known-vulnerable dependencies (run `govulncheck ./...` if available, or inspect `go.mod`).
DEDUPE against open issues before filing anything.
HOW TO REPORT — this matters:
- **Known/public dependency CVEs** (already disclosed): file an issue labeled `security` referencing the CVE and the affected module, and you MAY open a PR that bumps the dependency to the patched version. Do **NOT** enable auto-merge — leave it for human review.
- **Novel, exploitable vulnerabilities in this codebase** (not yet public): do **NOT** post a working exploit, proof-of-concept, or step-by-step reproduction in a public issue — that is irresponsible disclosure. File a CONCISE issue labeled `security` and `needs-human` that names the vulnerability *class*, the *location* (file/function), and the *impact*, with only enough detail for a maintainer to find it — and note it should be handled via the repository's private vulnerability reporting if the repo is public. Do NOT open a public fix PR that reveals the vulnerability; leave the fix to a human.
- **Low-risk hardening** (defense-in-depth, missing validation with no proven exploit): a normal `security` issue is fine.
NEVER auto-merge a security change. Never weaken a control to make a test pass. Anything requiring an architectural or breaking change: label it `needs-human` and describe the tradeoff.
Post a summary as a comment on this issue (#__ISSUE__) — how many findings by severity, what you filed, and what needs a human — then close it (`gh issue close __ISSUE__`). If you open a dependency-bump PR, do it yourself from the shell: `git switch -c loop/security-__ISSUE__`, `git push -u origin loop/security-__ISSUE__`, `gh pr create --base << .DefaultBranch >> --title "<title>" --body "<summary, Closes #__ISSUE__>"` — then STOP; do NOT run `gh pr merge --auto`. Do not use a make_pr tool.
+31 -82
View File
@@ -1,34 +1,14 @@
# Go Micro Examples
This directory contains runnable examples that take you through the Go Micro
lifecycle: start with a service, expose it as agent-usable capability, then
coordinate work with workflows.
This directory contains runnable examples demonstrating various go-micro features and patterns.
## Quick Start
Each example can be run with `go run .` from its directory unless its README says
otherwise. If you are new to the repo, follow the first-agent path below instead
of reading the directories alphabetically.
Each example can be run with `go run .` from its directory.
## Recommended first-agent path
## Examples
| Step | Start here | What you learn | Next step |
|------|------------|----------------|-----------|
| 1. First service | [`hello-world`](./hello-world/) | Create and register a basic RPC service, add a handler, call it with a client, and expose health checks. | Move to [`agent-demo`](./agent-demo/) to see services used by an agent. |
| 2. First agent | [`agent-demo`](./agent-demo/) | Run a small project-management app with Projects, Tasks, and Team services plus an agent playground. | Compare with the maintained 0-to-hero path in [`support`](./support/). |
| 3. First workflow | [`support`](./support/) | Follow typed services into an agent chat loop, an event-driven `intake` flow, and an approval gate in one runnable reference. | Deepen the workflow model with [`flow-durable`](./flow-durable/). |
For the shortest AI-tooling bridge, the MCP path is
[`mcp/hello`](./mcp/hello/) → [`mcp/crud`](./mcp/crud/) →
[`mcp/workflow`](./mcp/workflow/). For debugging and production hardening, keep
[`agent-wrap-tool`](./agent-wrap-tool/), [`agent-durable`](./agent-durable/), and
[`deployment`](./deployment/) nearby.
## Lifecycle map
### 1. Services — learn the runtime foundation
#### [hello-world](./hello-world/)
### [hello-world](./hello-world/)
Basic RPC service demonstrating core concepts:
- Service creation and registration
- Handler implementation
@@ -41,7 +21,7 @@ cd hello-world
go run .
```
#### [web-service](./web-service/)
### [web-service](./web-service/)
HTTP web service with service discovery:
- HTTP handlers
- Service registration
@@ -54,7 +34,7 @@ cd web-service
go run .
```
#### [multi-service](./multi-service/)
### [multi-service](./multi-service/)
Multiple services in a single binary — the modular monolith pattern:
- Isolated server, client, store, and cache per service
- Shared registry and broker for inter-service communication
@@ -67,76 +47,44 @@ cd multi-service
go run .
```
#### [deployment](./deployment/)
### [deployment](./deployment/)
Docker Compose deployment with MCP gateway, Consul registry, and Jaeger tracing:
- Production-like architecture in one `docker-compose up`
- Standalone MCP gateway connected to service registry
- Distributed tracing with OpenTelemetry + Jaeger
### 2. Agents — turn services into tool-using teammates
#### [agent-demo](./agent-demo/)
Recommended first agent: a multi-service project management app with Projects,
Tasks, and Team services, seed data, and agent playground integration.
#### [agent-plan-delegate](./agent-plan-delegate/)
The two built-in agent capabilities in a small multi-agent system:
- **plan** — an agent records an ordered plan in its store-backed memory before doing multi-step work
- **delegate** — an agent hands a subtask to another agent (over RPC if it's registered, else to an ephemeral sub-agent)
#### [agent-wrap-tool](./agent-wrap-tool/)
Middleware around an agent's tool execution with `AgentWrapTool`, the tool-side analogue of client/server wrappers:
- **observe** — time every tool call and record per-tool metrics, correlated by call ID
- **retry** — re-run a call whose result is an error, recovering from a transient failure before the model sees it
#### [agent-durable](./agent-durable/)
Durable agent runs that can be checkpointed and resumed, useful once your first
agent needs predictable recovery behavior.
#### [agent-human-input](./agent-human-input/)
Human-in-the-loop agent interaction for decisions that need an explicit person
before the run can continue.
#### [agent-ollama](./agent-ollama/)
Local-model agent wiring for developers experimenting with Ollama-backed model
calls.
### 3. Workflows — coordinate longer-running work
#### [support](./support/)
A maintained 0-to-hero reference path in one runnable file:
- **scaffold** typed `customers`, `tickets`, and `notify` services
- **run/chat** with a support agent that uses those services as tools
- **inspect** the event-driven `intake` flow and approval gate
- **CI** keeps the deterministic mock-model journey runnable with `go test ./examples/support`
#### [flow-durable](./flow-durable/)
A workflow as ordered, checkpointed steps that survives a crash and resumes where it stopped:
- **steps** — a flow is a task with stages (`reserve → charge → confirm`), not just one LLM turn
- **Checkpoint** — each step is persisted; on `Resume`, completed steps are not re-run (no duplicate side effects)
#### [flow-loop](./flow-loop/)
A looping flow example for repeated workflow steps.
### 4. MCP and agent integration examples
### MCP Examples
See the [mcp/](./mcp/) directory for AI agent integration examples:
- **[hello](./mcp/hello/)** - Minimal MCP service (start here)
- **[crud](./mcp/crud/)** - CRUD contact book with full agent documentation
- **[workflow](./mcp/workflow/)** - Cross-service orchestration via AI agents
- **[documented](./mcp/documented/)** - All MCP features with auth scopes
- **[platform](./mcp/platform/)** - Platform-oriented MCP service example
## Other examples
### [agent-demo](./agent-demo/)
Multi-service project management app (Projects, Tasks, Team) with seed data and agent playground integration.
### [auth](./auth/)
Authentication and authorization example.
### [agent-plan-delegate](./agent-plan-delegate/)
The two built-in agent capabilities in a small multi-agent system:
- **plan** — an agent records an ordered plan in its store-backed memory before doing multi-step work
- **delegate** — an agent hands a subtask to another agent (over RPC if it's registered, else to an ephemeral sub-agent)
### [graceful-stop](./graceful-stop/)
Graceful shutdown behavior for long-running services.
### [agent-wrap-tool](./agent-wrap-tool/)
Middleware around an agent's tool execution with `AgentWrapTool`, the tool-side analogue of client/server wrappers:
- **observe** — time every tool call and record per-tool metrics, correlated by call ID
- **retry** — re-run a call whose result is an error, recovering from a transient failure before the model sees it
### [grpc-interop](./grpc-interop/)
gRPC interoperability example.
### [flow-durable](./flow-durable/)
A workflow as ordered, checkpointed steps that survives a crash and resumes where it stopped:
- **steps** — a flow is a task with stages (`reserve → charge → confirm`), not just one LLM turn
- **Checkpoint** — each step is persisted; on `Resume`, completed steps are not re-run (no duplicate side effects)
### [support](./support/)
A maintained 0-to-hero reference path in one runnable file:
- **scaffold** typed `customers`, `tickets`, and `notify` services
- **run/chat** with a support agent that uses those services as tools
- **inspect** the event-driven `intake` flow and approval gate
- **CI** keeps the deterministic mock-model journey runnable with `go test ./examples/support`
## Coming Soon
@@ -158,5 +106,6 @@ To add a new example:
1. Create a new directory
2. Add a descriptive README.md
3. Include working code with comments
4. Add to this index under the lifecycle stage it supports
4. Add to this index
5. Ensure it runs with `go run .`
+18 -22
View File
@@ -201,13 +201,12 @@ type Part struct {
// Message is a turn in an A2A conversation.
type Message struct {
Role string `json:"role"` // "user" | "agent"
Parts []Part `json:"parts"`
MessageID string `json:"messageId,omitempty"`
TaskID string `json:"taskId,omitempty"`
ContextID string `json:"contextId,omitempty"`
Kind string `json:"kind"` // "message"
AP2Mandates []AP2SignedMandate `json:"ap2Mandates,omitempty"`
Role string `json:"role"` // "user" | "agent"
Parts []Part `json:"parts"`
MessageID string `json:"messageId,omitempty"`
TaskID string `json:"taskId,omitempty"`
ContextID string `json:"contextId,omitempty"`
Kind string `json:"kind"` // "message"
}
// TaskStatus is a task's lifecycle state.
@@ -224,14 +223,12 @@ type Artifact struct {
// Task is the unit of work returned by message/send and tasks/get.
type Task struct {
ID string `json:"id"`
ContextID string `json:"contextId"`
Status TaskStatus `json:"status"`
Artifacts []Artifact `json:"artifacts,omitempty"`
History []Message `json:"history,omitempty"`
Kind string `json:"kind"` // "task"
AP2Mandates []AP2SignedMandate `json:"ap2Mandates,omitempty"`
AP2Verifications []AP2Verification `json:"ap2Verifications,omitempty"`
ID string `json:"id"`
ContextID string `json:"contextId"`
Status TaskStatus `json:"status"`
Artifacts []Artifact `json:"artifacts,omitempty"`
History []Message `json:"history,omitempty"`
Kind string `json:"kind"` // "task"
}
// PushNotificationConfig tells the gateway where to POST task updates for a
@@ -851,13 +848,12 @@ func taskFromReplyWithIDsAndHistory(input Message, reply, state, taskID, context
input.Kind = "message"
}
task := &Task{
ID: taskID,
ContextID: contextID,
Kind: "task",
History: append(append([]Message{}, history...), input),
Status: TaskStatus{State: state, Timestamp: time.Now().UTC().Format(time.RFC3339)},
Artifacts: []Artifact{textArtifact(reply)},
AP2Mandates: append([]AP2SignedMandate{}, input.AP2Mandates...),
ID: taskID,
ContextID: contextID,
Kind: "task",
History: append(append([]Message{}, history...), input),
Status: TaskStatus{State: state, Timestamp: time.Now().UTC().Format(time.RFC3339)},
Artifacts: []Artifact{textArtifact(reply)},
}
task.History = append(task.History, Message{
Role: "agent",
-150
View File
@@ -1,150 +0,0 @@
package a2a
import (
"crypto/ed25519"
"crypto/rand"
"crypto/sha256"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"time"
)
// AP2MandateKind identifies the AP2 mandate stage represented by a credential.
type AP2MandateKind string
const (
AP2CheckoutMandate AP2MandateKind = "checkout"
AP2PaymentMandate AP2MandateKind = "payment"
)
// AP2RailRef names the settlement rail authorized by an AP2 payment mandate.
type AP2RailRef struct {
Type string `json:"type"`
Reference string `json:"reference"`
}
// AP2Mandate is a small verifiable AP2 credential. It is deliberately rail-neutral:
// x402 is represented as one possible rail reference under a payment mandate.
type AP2Mandate struct {
ID string `json:"id"`
Kind AP2MandateKind `json:"kind"`
Subject string `json:"subject,omitempty"`
Merchant string `json:"merchant,omitempty"`
Amount string `json:"amount,omitempty"`
Currency string `json:"currency,omitempty"`
Description string `json:"description,omitempty"`
TaskID string `json:"taskId,omitempty"`
ContextID string `json:"contextId,omitempty"`
Rail *AP2RailRef `json:"rail,omitempty"`
IssuedAt time.Time `json:"issuedAt"`
}
// AP2SignedMandate is an AP2 mandate plus an Ed25519 signature over its canonical JSON.
type AP2SignedMandate struct {
Mandate AP2Mandate `json:"mandate"`
KeyID string `json:"keyId,omitempty"`
Signature string `json:"signature"`
}
// AP2Verification records mandate verification on an A2A task without mixing in
// payment-settlement state.
type AP2Verification struct {
MandateID string `json:"mandateId"`
Kind string `json:"kind"`
Verified bool `json:"verified"`
Error string `json:"error,omitempty"`
}
// NewAP2Keypair returns an Ed25519 keypair suitable for tests or local demos.
func NewAP2Keypair() (ed25519.PublicKey, ed25519.PrivateKey, error) {
return ed25519.GenerateKey(rand.Reader)
}
// SignAP2Mandate signs a mandate as a verifiable AP2 credential.
func SignAP2Mandate(m AP2Mandate, keyID string, private ed25519.PrivateKey) (AP2SignedMandate, error) {
if m.ID == "" {
return AP2SignedMandate{}, errors.New("ap2: mandate id is required")
}
if m.Kind == "" {
return AP2SignedMandate{}, errors.New("ap2: mandate kind is required")
}
if m.IssuedAt.IsZero() {
m.IssuedAt = time.Now().UTC()
}
payload, err := ap2Payload(m)
if err != nil {
return AP2SignedMandate{}, err
}
return AP2SignedMandate{Mandate: m, KeyID: keyID, Signature: base64.RawURLEncoding.EncodeToString(ed25519.Sign(private, payload))}, nil
}
// VerifyAP2Mandate verifies a signed mandate credential.
func VerifyAP2Mandate(s AP2SignedMandate, public ed25519.PublicKey) error {
sig, err := base64.RawURLEncoding.DecodeString(s.Signature)
if err != nil {
return fmt.Errorf("ap2: invalid signature encoding: %w", err)
}
payload, err := ap2Payload(s.Mandate)
if err != nil {
return err
}
if !ed25519.Verify(public, payload, sig) {
return errors.New("ap2: mandate signature verification failed")
}
return nil
}
// AP2BindMandateToMessage returns a copy of m bound to the A2A message's task/context.
func AP2BindMandateToMessage(m AP2Mandate, msg Message) AP2Mandate {
m.TaskID = msg.TaskID
m.ContextID = msg.ContextID
return m
}
// AP2AttachMandate returns a copy of msg carrying the signed AP2 mandate.
func AP2AttachMandate(msg Message, mandate AP2SignedMandate) Message {
msg.AP2Mandates = append(append([]AP2SignedMandate{}, msg.AP2Mandates...), mandate)
return msg
}
// VerifyAP2ForTask verifies signature, task binding, and optional settlement rail reference.
func VerifyAP2ForTask(s AP2SignedMandate, public ed25519.PublicKey, task Task, rail *AP2RailRef) AP2Verification {
out := AP2Verification{MandateID: s.Mandate.ID, Kind: string(s.Mandate.Kind), Verified: true}
if err := VerifyAP2Mandate(s, public); err != nil {
out.Verified = false
out.Error = err.Error()
return out
}
if s.Mandate.TaskID != "" && s.Mandate.TaskID != task.ID {
out.Verified = false
out.Error = "ap2: mandate task binding mismatch"
return out
}
if s.Mandate.ContextID != "" && s.Mandate.ContextID != task.ContextID {
out.Verified = false
out.Error = "ap2: mandate context binding mismatch"
return out
}
if s.Mandate.Kind == AP2PaymentMandate && rail != nil {
if s.Mandate.Rail == nil || *s.Mandate.Rail != *rail {
out.Verified = false
out.Error = "ap2: settlement rail reference mismatch"
return out
}
}
return out
}
// X402AP2Rail builds the x402 settlement rail reference carried under a payment mandate.
func X402AP2Rail(reference string) AP2RailRef { return AP2RailRef{Type: "x402", Reference: reference} }
func ap2Payload(m AP2Mandate) ([]byte, error) {
b, err := json.Marshal(m)
if err != nil {
return nil, fmt.Errorf("ap2: marshal mandate: %w", err)
}
sum := sha256.Sum256(b)
return sum[:], nil
}
-78
View File
@@ -1,78 +0,0 @@
package a2a
import (
"crypto/ed25519"
"strings"
"testing"
"time"
)
func testAP2Key(t *testing.T) (ed25519.PublicKey, ed25519.PrivateKey) {
t.Helper()
pub, priv, err := NewAP2Keypair()
if err != nil {
t.Fatal(err)
}
return pub, priv
}
func TestAP2CheckoutMandateSignAttachAndVerify(t *testing.T) {
pub, priv := testAP2Key(t)
msg := Message{Role: "user", Kind: "message", TaskID: "task-1", ContextID: "ctx-1", Parts: []Part{{Kind: "text", Text: "buy"}}}
mandate := AP2BindMandateToMessage(AP2Mandate{ID: "checkout-1", Kind: AP2CheckoutMandate, Subject: "alice", Merchant: "store", Amount: "10.00", Currency: "USD", Description: "demo", IssuedAt: time.Unix(1, 0).UTC()}, msg)
signed, err := SignAP2Mandate(mandate, "test-key", priv)
if err != nil {
t.Fatal(err)
}
msg = AP2AttachMandate(msg, signed)
task := taskFromReplyWithIDs(msg, "ok", stateCompleted, msg.TaskID, msg.ContextID)
if len(task.AP2Mandates) != 1 {
t.Fatalf("expected mandate carried on task, got %d", len(task.AP2Mandates))
}
got := VerifyAP2ForTask(task.AP2Mandates[0], pub, *task, nil)
if !got.Verified || got.Error != "" {
t.Fatalf("expected verified mandate, got %+v", got)
}
}
func TestAP2PaymentMandateX402RailReference(t *testing.T) {
pub, priv := testAP2Key(t)
rail := X402AP2Rail("payreq_123")
task := Task{ID: "task-2", ContextID: "ctx-2"}
signed, err := SignAP2Mandate(AP2Mandate{ID: "payment-1", Kind: AP2PaymentMandate, TaskID: task.ID, ContextID: task.ContextID, Rail: &rail, IssuedAt: time.Unix(1, 0).UTC()}, "test-key", priv)
if err != nil {
t.Fatal(err)
}
got := VerifyAP2ForTask(signed, pub, task, &rail)
if !got.Verified {
t.Fatalf("expected x402 rail to verify, got %+v", got)
}
}
func TestAP2TamperCasesFailDistinctly(t *testing.T) {
pub, priv := testAP2Key(t)
rail := X402AP2Rail("payreq_123")
task := Task{ID: "task-3", ContextID: "ctx-3"}
signed, err := SignAP2Mandate(AP2Mandate{ID: "payment-2", Kind: AP2PaymentMandate, TaskID: task.ID, ContextID: task.ContextID, Rail: &rail, IssuedAt: time.Unix(1, 0).UTC()}, "test-key", priv)
if err != nil {
t.Fatal(err)
}
tampered := signed
tampered.Mandate.Amount = "999.00"
if got := VerifyAP2ForTask(tampered, pub, task, &rail); got.Verified || !strings.Contains(got.Error, "signature") {
t.Fatalf("expected signature failure, got %+v", got)
}
wrongTask := task
wrongTask.ID = "other-task"
if got := VerifyAP2ForTask(signed, pub, wrongTask, &rail); got.Verified || !strings.Contains(got.Error, "task binding") {
t.Fatalf("expected task binding failure, got %+v", got)
}
otherRail := X402AP2Rail("payreq_other")
if got := VerifyAP2ForTask(signed, pub, task, &otherRail); got.Verified || !strings.Contains(got.Error, "rail reference") {
t.Fatalf("expected rail reference failure, got %+v", got)
}
}
+6 -7
View File
@@ -107,13 +107,12 @@ func (c *Client) SendMessage(ctx context.Context, message Message) (*Task, error
return nil, err
}
return &Task{
ID: m.TaskID,
ContextID: m.ContextID,
Kind: "task",
Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)},
Artifacts: []Artifact{textArtifact(textOf(m.Parts))},
History: []Message{m},
AP2Mandates: append([]AP2SignedMandate{}, m.AP2Mandates...),
ID: m.TaskID,
ContextID: m.ContextID,
Kind: "task",
Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)},
Artifacts: []Artifact{textArtifact(textOf(m.Parts))},
History: []Message{m},
}, nil
}
+9 -12
View File
@@ -22,9 +22,8 @@ Actions instead of subagents. Each role is a workflow:
| **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`) | Grades every change: the mock harness + unit/lint on each push/PR, and real-model conformance hourly. A *separate* grader — never the generator judging itself. |
| **Evaluator → feedback** | `loop-triage.yml`*Loop: Triage (Evaluator feedback)* | When a gate workflow (Lint, Run Tests, 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. |
| **Evaluator → feedback** | `loop-triage.yml`*Loop: Triage (Evaluator feedback)* | On harness failure, root-causes, dedupes, and files scoped fix issues back into the planner's queue. The hill-climbing feedback path. |
| **Coherence** | `loop-coherence.yml`*Loop: Coherence* | Keeps README/website/docs/blog aligned with the North Star, keeps `CHANGELOG.md` living (reconciling `[Unreleased]` against merged PRs and rolling it into version headings as tags cut), and drafts the changelog blog post. |
| **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
@@ -195,16 +194,14 @@ to redirect. Codex is serial, so these passes queue behind any in-flight increme
## 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
fires when the live provider-conformance harness finishes with `conclusion:
failure` (scheduled/manual runs only), and dispatches Codex to **triage** the
failing run: read the logs, root-cause each distinct failure, **dedupe** against
open issues (comment "recurred" rather than filing a duplicate), and file a scoped
`codex`/`enhancement` issue for each genuine, self-contained defect — which the
increment loop then builds and the next harness run verifies. Transient flakes
(live-model latency, provider outages) are ignored; anything needing a breaking or
architectural change is escalated as `needs-human` instead of auto-built. This is
the hill-climbing layer: CI/harness failures become fixes with no human in the
middle, short of a decision that's genuinely the human's.
+14 -71
View File
@@ -148,17 +148,13 @@ func main() {
fmt.Fprintf(os.Stderr, "content-type = %q, want text/event-stream\n", ct)
os.Exit(1)
}
summary, err := readSSESummary(res.Body)
payload, err := readSSEData(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)
if !strings.Contains(payload, "a2a-fallback-ok") {
fmt.Fprintf(os.Stderr, "stream payload missing marker: %s\n", payload)
os.Exit(1)
}
if !sawTool || !sawRunInfo {
@@ -168,77 +164,24 @@ func main() {
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) {
func readSSEData(r io.Reader) (string, 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
}
var payload strings.Builder
for scanner.Scan() {
line := scanner.Text()
if strings.TrimSpace(line) == "" {
if err := flush(); err != nil {
return streamSummary{}, err
}
continue
if data, ok := strings.CutPrefix(line, "data: "); ok {
payload.WriteString(data)
payload.WriteByte('\n')
}
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
return "", err
}
if err := flush(); err != nil {
return streamSummary{}, err
if payload.Len() == 0 {
return "", errors.New("no SSE data received")
}
if !seen {
return streamSummary{}, errors.New("no SSE data received")
if !json.Valid([]byte(strings.TrimSpace(payload.String()))) {
return "", fmt.Errorf("SSE data is not JSON: %s", payload.String())
}
return summary, nil
return payload.String(), nil
}
@@ -1,30 +0,0 @@
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")
}
}
+14 -137
View File
@@ -127,10 +127,6 @@ func (s *TaskService) count() int {
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."
type SendRequest struct {
To string `json:"to" description:"Recipient address"`
Message string `json:"message" description:"Message body"`
@@ -139,11 +135,9 @@ type SendResponse struct {
Sent bool `json:"sent"`
}
type NotifyService struct {
mu sync.Mutex
sent int
attempts int
duplicates int
bySend map[string]bool
mu sync.Mutex
sent int
bySend map[string]bool
}
// Send delivers a notification message to a recipient. Duplicate delivery
@@ -156,13 +150,11 @@ func (s *NotifyService) Send(ctx context.Context, req *SendRequest, rsp *SendRes
s.bySend = map[string]bool{}
}
key := strings.ToLower(strings.TrimSpace(req.To)) + "\x00" + strings.ToLower(strings.TrimSpace(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()
@@ -176,28 +168,13 @@ func (s *NotifyService) count() int {
return s.sent
}
func (s *NotifyService) duplicateAttempts() int {
s.mu.Lock()
defer s.mu.Unlock()
return s.duplicates
}
// ---------------------------------------------------------------------------
// 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
}
type mockModel struct{ opts ai.Options }
func newMock(opts ...ai.Option) ai.Model {
m := &mockModel{}
@@ -205,12 +182,6 @@ func newMock(opts ...ai.Option) ai.Model {
return m
}
func newMockUnknownDelegate(opts ...ai.Option) ai.Model {
m := &mockModel{unknownDelegateOnce: true}
_ = m.Init(opts...)
return m
}
func (m *mockModel) Init(opts ...ai.Option) error {
for _, o := range opts {
o(&m.opts)
@@ -276,18 +247,10 @@ func (m *mockModel) Generate(ctx context.Context, req *ai.Request, _ ...ai.Gener
}
}
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 {
m.call("conductor", del, map[string]any{
"task": delegatedNotifyTask,
"to": "comms",
})
}
m.call("conductor", del, map[string]any{
"task": "Notify owner@acme.com that the launch plan is ready",
"to": "comms",
})
}
return &ai.Response{Answer: "Created Design, Build and Ship, and had comms notify the owner."}, nil
@@ -315,12 +278,9 @@ func providerKey(provider string) string {
func runPlanDelegate(provider string) error {
apiKey := ""
switch provider {
case "mock":
if provider == "mock" {
ai.Register("mock", newMock)
case "mock-unknown-delegate":
ai.Register("mock-unknown-delegate", newMockUnknownDelegate)
default:
} 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)
@@ -358,7 +318,7 @@ func runPlanDelegate(provider string) error {
agent.Name("comms"),
agent.Address("127.0.0.1:0"),
agent.Services("notify"),
agent.Prompt(commsPrompt),
agent.Prompt("You handle outbound notifications. Use the notify service."),
agent.Provider(provider), agent.APIKey(apiKey),
agent.WithRegistry(reg), agent.WithClient(cl), agent.WithStore(mem),
agent.WithCheckpoint(commsCheckpoint),
@@ -410,18 +370,8 @@ func runPlanDelegate(provider string) error {
}
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 := conductor.Ask(ctx, "The Design, Build, and Ship tasks already exist, but the owner notification is still missing. Delegate exactly one notification to the \"comms\" agent now with this exact subtask: "+delegatedNotifyTask+" Do not create more tasks and do not answer until comms has handled the notification.")
return err
}); err != nil {
return err
if err := f.Execute(context.Background(), "launch readiness"); err != nil {
return fmt.Errorf("flow execute: %w", err)
}
if rs := f.Results(); len(rs) > 0 {
@@ -442,81 +392,8 @@ func runPlanDelegate(provider string) error {
return 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 isClientTimeout(err) {
if tasks == 3 && notify == 1 {
fmt.Printf("\n\033[33mwarning:\033[0m flow execute returned after completed side effects: %v\n", err)
return nil
}
return classifiedPlanDelegateTimeout(tasks, notify, err)
}
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: %w", tasks, notify, err)
}
if notify != 1 {
if recoverMissingNotify == nil || tasks != 3 || notify != 0 {
return fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
}
settled, err := waitForNotifySideEffect(notifySvc, 2*time.Second)
if err != nil {
return err
}
if !settled {
fmt.Print("\n\033[33mwarning:\033[0m flow completed before delegated notify; retrying the missing comms handoff once.\n")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
retryErr := recoverMissingNotify(ctx)
cancel()
if retryErr != nil {
return fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, retryErr)
}
}
if notify = notifySvc.count(); notify != 1 {
return fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
}
}
return nil
case <-ticker.C:
if dup := notifySvc.duplicateAttempts(); dup > 0 {
return fmt.Errorf("duplicate notify attempts: got %d duplicate replay(s), want 0", dup)
}
}
}
}
func waitForNotifySideEffect(notifySvc *NotifyService, timeout time.Duration) (bool, error) {
deadline := time.Now().Add(timeout)
for {
if notifySvc.count() == 1 {
return true, nil
}
if dup := notifySvc.duplicateAttempts(); dup > 0 {
return false, fmt.Errorf("duplicate notify attempts: got %d duplicate replay(s), want 0", dup)
}
if !time.Now().Before(deadline) {
return false, nil
}
time.Sleep(50 * time.Millisecond)
}
}
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, anthropic, openai, gemini, groq, mistral, together, atlascloud")
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, gemini, groq, mistral, together, atlascloud")
flag.Parse()
if err := runPlanDelegate(*provider); err != nil {
+2 -185
View File
@@ -2,8 +2,6 @@ package main
import (
"context"
"errors"
"strings"
"testing"
"time"
@@ -67,7 +65,7 @@ func TestPlanDelegateEndToEnd(t *testing.T) {
agent.Name("comms"),
agent.Address("127.0.0.1:0"),
agent.Services("notify"),
agent.Prompt(commsPrompt),
agent.Prompt("You handle outbound notifications."),
agent.Provider("mock"),
agent.WithRegistry(reg),
agent.WithClient(cl),
@@ -149,7 +147,7 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
agent.Name("comms"),
agent.Address("127.0.0.1:0"),
agent.Services("notify"),
agent.Prompt(commsPrompt),
agent.Prompt("You handle outbound notifications."),
agent.Provider("mock"),
agent.WithRegistry(reg),
agent.WithClient(cl),
@@ -222,15 +220,6 @@ func TestZeroToHeroContract(t *testing.T) {
}
}
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 TestTaskServiceAddIsIdempotentForLaunchTitles(t *testing.T) {
svc := new(TaskService)
for _, title := range []string{"Design", "design task", "Build", "Build launch task", "Ship", "ship readiness"} {
@@ -247,178 +236,6 @@ func TestTaskServiceAddIsIdempotentForLaunchTitles(t *testing.T) {
}
}
func TestPlanDelegateExecutionReportsDuplicateNotifyBeforeTimeout(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)
errCh := make(chan error, 1)
go func() { errCh <- waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil) }()
select {
case err := <-errCh:
if err == nil {
t.Fatal("waitForPlanDelegateExecution returned nil, want duplicate notify error")
}
if got := err.Error(); !strings.Contains(got, "duplicate notify attempts") {
t.Fatalf("error = %q, want duplicate notify attempts", got)
}
case <-time.After(time.Second):
t.Fatal("waitForPlanDelegateExecution did not report duplicate notify before timeout")
}
}
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)
}
}
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)
done := make(chan error, 1)
done <- nil
recovered := false
err := waitForPlanDelegateExecution(done, 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)
})
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)
done := make(chan error, 1)
done <- nil
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 := waitForPlanDelegateExecution(done, 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)
})
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 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 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)
for i := 0; i < 3; i++ {
@@ -51,7 +51,6 @@ Provider keys are read from `MICRO_AI_API_KEY` or the provider-specific variable
| 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` |
@@ -29,7 +29,6 @@ import (
_ "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"
@@ -50,7 +49,6 @@ var providerEnv = map[string]string{
"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",
@@ -38,7 +38,7 @@ func TestValidateSelectionRejectsUnsafeHarnessName(t *testing.T) {
func TestDefaultProvidersTracksLiveProviderSet(t *testing.T) {
got := defaultProviders()
for _, want := range []string{"anthropic", "openai", "gemini", "groq", "minimax", "mistral", "together", "atlascloud"} {
for _, want := range []string{"anthropic", "openai", "gemini", "groq", "mistral", "together", "atlascloud"} {
if !strings.Contains(got, want) {
t.Fatalf("defaultProviders() = %q, want %q", got, want)
}
@@ -54,7 +54,7 @@ func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
t.Fatal("CapabilityRows returned no providers")
}
var foundOpenAI, foundMiniMax bool
var foundOpenAI bool
for _, row := range rows {
if row.Provider == "openai" {
foundOpenAI = true
@@ -62,19 +62,10 @@ func TestCapabilityMatrixHasRegisteredProviders(t *testing.T) {
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 TestWriteCapabilityMarkdown(t *testing.T) {
+14 -114
View File
@@ -27,7 +27,6 @@ package main
import (
"context"
"encoding/json"
"flag"
"fmt"
"net/http/httptest"
@@ -40,8 +39,6 @@ import (
"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"
@@ -112,28 +109,14 @@ type SendResponse struct {
}
type Notify struct {
mu sync.Mutex
sent int64
seen map[string]struct{}
lastRejected *SendRequest
mu sync.Mutex
sent int64
seen map[string]struct{}
}
// Send delivers a notification.
// @example {"to": "buyer@acme.com", "message": "Your order is confirmed"}
func (s *Notify) Send(_ context.Context, req *SendRequest, rsp *SendResponse) error {
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 {
@@ -158,63 +141,8 @@ func (s *Notify) Send(_ context.Context, req *SendRequest, rsp *SendResponse) er
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}
keys := []string{req.To + "\x00" + req.Message}
message := strings.ToLower(req.Message)
if strings.Contains(message, "confirm") {
// Live models occasionally emit equivalent confirmation copy more than
@@ -223,45 +151,23 @@ func notificationDedupeKeys(req *SendRequest) []string {
// 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")
keys = append(keys, req.To+"\x00confirmation")
}
return keys
}
func dispatchNotifyStep(agentName string, cl client.Client, ntf *Notify) flow.StepFunc {
func dispatchNotifyStep(agentName string, ntf *Notify) flow.StepFunc {
dispatch := flow.Dispatch(agentName)
return func(ctx context.Context, in flow.State) (flow.State, error) {
before := atomic.LoadInt64(&ntf.sent)
out, err := dispatchBuyerNotification(ctx, agentName, cl, in)
if err != nil {
out = in
out, err := dispatch(ctx, in)
if err == nil {
return out, nil
}
return completeNotifyOnObservedSideEffect(ctx, out, ntf, before, 2*time.Second, err)
return completeNotifyOnObservedSideEffect(ctx, in, 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) {
@@ -271,17 +177,11 @@ func completeNotifyOnObservedSideEffect(ctx context.Context, in flow.State, ntf
}
select {
case <-ctx.Done():
if dispatchErr != nil {
return in, dispatchErr
}
return in, ctx.Err()
return in, dispatchErr
case <-time.After(25 * time.Millisecond):
}
}
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())
return in, dispatchErr
}
// ---------------------------------------------------------------------------
@@ -454,7 +354,7 @@ func runUniverse(provider string) int {
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)},
flow.Step{Name: "notify", Run: dispatchNotifyStep("concierge", ntf)},
),
)
if err := checkout.Register(reg, br, cl); err != nil {
-144
View File
@@ -69,27 +69,6 @@ func TestNotifyStepCompletesAfterObservedSideEffectTimeout(t *testing.T) {
}
}
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()
@@ -111,126 +90,3 @@ func TestNotifySuppressesEquivalentConfirmationMessages(t *testing.T) {
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)
}
}
@@ -45,7 +45,6 @@ func TestGuidesNavigationLeadsWithDoing(t *testing.T) {
nav := readFile(t, filepath.Join(root, "internal", "website", "_data", "navigation.yml"))
orderedGuides := []string{
"/docs/guides/no-secret-first-agent.html",
"/docs/guides/your-first-agent.html",
"/docs/guides/zero-to-hero.html",
"/docs/guides/plan-delegate.html",
@@ -74,114 +73,6 @@ func TestGuidesNavigationLeadsWithDoing(t *testing.T) {
}
}
func TestFirstAgentWayfindingDocs(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
checks := []struct {
name string
file string
heading string
links []string
}{
{
name: "README first-agent on-ramp",
file: filepath.Join(root, "README.md"),
heading: "### First agent on-ramp",
links: []string{
"internal/website/docs/guides/no-secret-first-agent.md",
"internal/website/docs/guides/your-first-agent.md",
"internal/website/docs/guides/debugging-agents.md",
"internal/website/docs/guides/zero-to-hero.md",
},
},
{
name: "website getting-started on-ramp",
file: filepath.Join(root, "internal", "website", "docs", "getting-started.md"),
heading: "### First-agent on-ramp",
links: []string{
"guides/no-secret-first-agent.html",
"guides/your-first-agent.html",
"guides/debugging-agents.html",
"guides/zero-to-hero.html",
},
},
}
for _, check := range checks {
t.Run(check.name, func(t *testing.T) {
doc := firstMarkdownSection(t, readFile(t, check.file), check.heading)
last := -1
for _, link := range check.links {
idx := strings.Index(doc, link)
if idx == -1 {
t.Fatalf("%s missing first-agent wayfinding link %q; keep the no-secret → first-agent → debugging → 0→hero path discoverable", check.name, link)
}
if idx < last {
t.Fatalf("%s link %q appeared out of order; expected no-secret → first-agent → debugging → 0→hero", check.name, link)
}
last = idx
}
})
}
}
func TestNoSecretFirstAgentTranscript(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
guide := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "no-secret-first-agent.md"))
for _, want := range []string{
"go run ./examples/support",
"go test ./examples/support -run TestRunSupportMockSmoke -count=1",
"make harness",
"micro agent preflight",
"micro run",
"micro chat assistant",
"micro inspect agent assistant",
"go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1",
"No-secret first-agent transcript",
} {
if !strings.Contains(guide, want) {
t.Fatalf("no-secret first-agent transcript missing %q", want)
}
}
debugCheckpoint := firstMarkdownSection(t, guide, "## Debug transcript checkpoint")
for _, want := range []string{
`micro chat assistant --prompt "Triage ticket-1 for Alice"`,
"micro inspect agent assistant --limit 1",
"micro agent history assistant",
"status, event count, last event",
"Debugging your agent",
"debugging-agents.html",
} {
if !strings.Contains(debugCheckpoint, want) {
t.Fatalf("no-secret debug transcript checkpoint missing %q", want)
}
}
readme := readFile(t, filepath.Join(root, "README.md"))
if !strings.Contains(readme, "internal/website/docs/guides/no-secret-first-agent.md") {
t.Fatal("README does not point to the no-secret first-agent transcript")
}
firstAgent := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "your-first-agent.md"))
if !strings.Contains(firstAgent, "no-secret-first-agent.html") {
t.Fatal("Your First Agent guide does not point to the no-secret transcript")
}
}
func firstMarkdownSection(t *testing.T, doc, heading string) string {
t.Helper()
start := strings.Index(doc, heading)
if start == -1 {
t.Fatalf("missing %q section", heading)
}
section := doc[start+len(heading):]
if next := strings.Index(section, "\n##"); next != -1 {
section = section[:next]
}
return section
}
func readFile(t *testing.T, name string) string {
t.Helper()
data, err := os.ReadFile(name)
-1
View File
@@ -8,7 +8,6 @@ cd "$ROOT"
# without secrets or long-running daemons.
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroCLIBoundaries' -count=1
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
go test ./internal/harness/zero-to-hero-ci -run 'TestNoSecretFirstAgentTranscript|TestZeroToHeroReferenceDocs' -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
-2
View File
@@ -5,8 +5,6 @@ core:
url: /docs/getting-started.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
+1 -6
View File
@@ -16,7 +16,6 @@ agents → workflows lifecycle.
| First service-backed agent | [`examples/agent-demo`](https://github.com/micro/go-micro/tree/master/examples/agent-demo) | Multi-service project/task/team app with agent playground integration. |
| 0→hero lifecycle | [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) | No-secret support-desk story: typed services, an agent, an event-driven flow, and a guardrail. |
| Planning and delegation | [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate) | Two agents collaborate through `plan` and `delegate` over normal Go Micro RPC. |
| Durable agent runs | [`examples/agent-durable`](https://github.com/micro/go-micro/tree/master/examples/agent-durable) | Checkpoint and resume a model-directed run without replaying completed tool side effects. |
| Durable workflows | [`examples/flow-durable`](https://github.com/micro/go-micro/tree/master/examples/flow-durable) | Ordered, checkpointed flow steps resume without duplicating completed side effects. |
| AI-callable services | [`examples/mcp`](https://github.com/micro/go-micro/tree/master/examples/mcp) | MCP examples that expose service endpoints as model tools. |
@@ -36,11 +35,7 @@ agents → workflows lifecycle.
[`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate).
- [Agents and Workflows](../guides/agents-and-workflows.html) → run
[`examples/flow-durable`](https://github.com/micro/go-micro/tree/master/examples/flow-durable)
for deterministic checkpointed steps,
[`examples/agent-durable`](https://github.com/micro/go-micro/tree/master/examples/agent-durable)
for model-directed checkpointed runs, and
[`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support)
for the full services → agents → workflows lifecycle.
and [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support).
## Repository examples
+3 -8
View File
@@ -25,7 +25,7 @@ Before your first provider-backed agent run, check the local path with:
micro agent preflight
```
The preflight is read-only: it verifies Go 1.24+, the `micro` binary, provider-key setup, and whether the default `micro run` gateway port is free, without calling an LLM provider. When a check fails it prints the exact fix plus the next guide to open, so the scaffold → run → chat path stays walkable.
The preflight is read-only: it verifies Go, the `micro` binary, provider-key setup, and whether the default `micro run` gateway port is free, without calling an LLM provider.
## Install
@@ -86,14 +86,9 @@ Created project Launch and added task 'Write docs' to it.
The console discovers services from the registry and orchestrates across them via the agent. Use `micro run -d` for detached mode without the console, or `micro chat` as a standalone command.
### First-agent on-ramp
If the agent surprises you while iterating, use the [Debugging your agent](guides/debugging-agents.html) guide to inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs.
After this quick start, follow the agent path in order:
1. [No-secret first-agent transcript](guides/no-secret-first-agent.html) — run a useful support agent with a mock model before setting up a provider key.
2. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
3. [Debugging your agent](guides/debugging-agents.html) — inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent surprises you.
4. [0→hero reference path](guides/zero-to-hero.html) — prove the full scaffold → run → chat → inspect → deploy dry-run lifecycle with commands exercised by `make harness`.
When you are ready to prove the whole path end to end, follow the [0→hero reference path](guides/zero-to-hero.html). It is the canonical handoff from this quick start: scaffold a service, run it locally, chat with an agent, inspect durable agent/flow history, and finish with `micro deploy --dry-run` using the same commands exercised by `make harness`.
## Quick Start: Write a Service
@@ -186,16 +186,6 @@ This is the JSON-RPC binding for task execution:
Both directions work: the gateway exposes your agents, and `a2a.Client` (via `flow.A2A` or `delegate` to a URL) calls external ones. The task binding is what makes a Go Micro agent both reachable from, and able to reach, the A2A ecosystem today.
## AP2 mandate layer (opt-in)
AP2 sits above A2A as a verifiable-intent and audit layer. Go Micro keeps the
A2A envelope separate from payment settlement: an A2A message can carry signed
AP2 checkout or payment mandates, and the resulting task can retain the stable
mandate reference plus verification result. Payment settlement state remains in
the payment rail. For x402, use an AP2 payment mandate with an `x402` rail
reference to name the payment requirement; the existing x402 facilitator still
performs verification and settlement.
## See also
- [MCP & AI Agents](../mcp.html) — exposing services as tools
@@ -42,7 +42,6 @@ The built-in providers currently register these capability interfaces:
| `atlascloud` | Yes | Yes | Yes | Yes |
| `gemini` | Yes | No | No | No |
| `groq` | Yes | No | No | Yes |
| `minimax` | Yes | No | No | Yes |
| `mistral` | Yes | No | No | Yes |
| `ollama` | Yes | No | No | Yes |
| `openai` | Yes | Yes | No | Yes |
@@ -17,9 +17,7 @@ micro inspect ... # read the recorded run or workflow history
Debug the lifecycle in the same order Go Micro runs it: first prove the service is
registered and callable, then inspect the agent run that chose tools, then inspect
any workflow that handed off to the agent. If the first local run fails before a
chat turn, run `micro agent preflight`; failed checks include `Fix:` and `Next:`
lines for Go, CLI installation, provider-key setup, and the local gateway port.
any workflow that handed off to the agent.
## 1. Reproduce one small turn
@@ -1,114 +0,0 @@
---
layout: default
---
# No-secret first-agent transcript
This is the fastest first-agent success path when you do not have a provider key
handy. It starts from the maintained `examples/support` app and uses the
repository harness that CI already runs: real Go Micro services, registry,
broker, client, store, agent loop, flow handoff, and guardrail code with only the
LLM provider mocked.
Use it before the live-provider [Your First Agent](your-first-agent.html)
walkthrough when you want to see the services → agents → workflows lifecycle run
end to end with no secrets.
## What this proves
- **Services** expose typed `customers`, `tickets`, and `notify` endpoints.
- **The `support` agent** discovers those endpoints as tools and uses them to
triage a ticket.
- **The `intake` flow** turns a `ticket.created` event into an agent run.
- **The approval gate** intercepts the customer email action before the tool
executes.
## Transcript
From a fresh clone of the repository:
```sh
git clone https://github.com/micro/go-micro.git
cd go-micro
go run ./examples/support
```
The default provider is `mock`, so the command does not need `ANTHROPIC_API_KEY`,
`OPENAI_API_KEY`, or any other secret. A healthy run prints the event, service
calls, guardrail decision, and final support-agent reply in one terminal:
```text
> event: events.ticket.created {"id":"ticket-1","customer":"alice@acme.com",...}
[customers] looked up Alice (pro plan)
[tickets] ticket-1 → priority=high status=in_progress
▣ approval gate notify_NotifyService_Send(alice@acme.com) — approved
[notify] 📨 to=alice@acme.com: "Hi Alice — thanks for reaching out..."
support agent: Hi Alice — thanks for reaching out...
✓ ticket triaged and the customer was replied to — triggered by an event
```
That single run is the no-secret version of the first-agent loop: a service
capability exists, an agent calls it as a tool, and workflow infrastructure can
trigger and inspect the work.
## CI-backed check
Run the same deterministic path as a focused test:
```sh
go test ./examples/support -run TestRunSupportMockSmoke -count=1
```
For the broader no-secret contract that also checks scaffold, chat/inspect CLI
boundaries, flow history, deploy dry-run, and mock provider conformance, run:
```sh
make harness
```
## Equivalent scaffold → run → chat → inspect path
When you are ready to build the smaller live-agent version yourself, follow
[Your First Agent](your-first-agent.html). The command shape is the same, but a
live `micro chat` turn needs a provider key because the model is no longer
mocked:
```sh
micro agent preflight
micro run
micro chat assistant
micro inspect agent assistant
```
CI keeps those CLI boundaries present with:
```sh
go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1
```
## Debug transcript checkpoint
A successful first chat turn should always leave an inspectable trail. After the
chat command finishes, continue the same terminal transcript with the inspection
and history commands before changing prompts or provider settings:
```sh
micro chat assistant --prompt "Triage ticket-1 for Alice"
micro inspect agent assistant --limit 1
micro agent history assistant
```
The inspection output is the checkpoint that the runnable loop did not stop at
chat: it should show a recent agent run with a status, event count, last event,
and trace breadcrumb when tracing is configured. `micro agent history assistant`
then confirms the conversation memory that future turns will reuse. If either
command is empty after a successful chat turn, keep the failing transcript and
use [Debugging your agent](debugging-agents.html) to check provider failures, run
history, memory, and tool-call inspection before changing application code.
If `micro agent preflight` reports a missing provider key, you can still use this no-secret path because it runs against the mock model; the command now prints this guide as the next step for that failure. If chat behaves unexpectedly, continue to
[Debugging your agent](debugging-agents.html) for provider checks, run history,
memory, and tool-call inspection.
@@ -13,7 +13,6 @@ import (
_ "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/ollama"
_ "go-micro.dev/v6/ai/openai"
@@ -128,11 +128,3 @@ Leave those variables unset in normal CI; the live test skips unless the facilit
- [Building Effective Agents — Agents and Workflows](agents-and-workflows.html)
- [MCP & AI Agents](../mcp.html)
- [x402 — Coinbase Developer Docs](https://docs.cdp.coinbase.com/x402/welcome) · [x402 on Solana](https://solana.com/x402/what-is-x402)
## AP2 payment mandates
AP2 can authorize an x402 payment without making A2A carry settlement state. A
payment mandate records the buyer intent and names an `x402` rail reference; the
existing x402 facilitator remains responsible for payment verification and
settlement. This keeps AP2 as the signed mandate/audit layer while x402 stays the
pluggable payment rail.
@@ -13,13 +13,13 @@ run on events or schedules.
## Runnable reference first
If you want to run the lifecycle before copying code, start with the [no-secret first-agent transcript](no-secret-first-agent.html) or run the maintained support-desk example from the repository root:
If you want to run the lifecycle before copying code, start with the maintained support-desk example from the repository root:
```sh
go run ./examples/support
```
It uses a deterministic mock model by default, so it needs no provider key, and it exercises the same shape this guide teaches: services become tools, an agent uses them, and a flow can trigger the work. Use the transcript for expected output, then use this guide when you are ready to build the smaller 0→1 version yourself.
It uses a deterministic mock model by default, so it needs no provider key, and it exercises the same shape this guide teaches: services become tools, an agent uses them, and a flow can trigger the work. Use this guide when you are ready to build the smaller 0→1 version yourself.
## What you'll build
@@ -53,7 +53,7 @@ Run the read-only first-agent preflight before starting the walkthrough. The sam
micro agent preflight
```
It checks Go 1.24+, the `micro` binary, provider-key setup, and the default local gateway port without contacting a provider. Failed checks include a `Fix:` line and a `Next:` line that points back to this guide, the no-secret walkthrough, or the debugging guide.
It checks Go, the `micro` binary, provider-key setup, and the default local gateway port without contacting a provider.
## 1. Create a workspace
@@ -219,7 +219,7 @@ agent for judgment, tool use, and handoffs when the path is not known up front.
- Read the [0→hero reference path](zero-to-hero.html) for the CI-verified
lifecycle contract.
- Run the [no-secret first-agent transcript](no-secret-first-agent.html) or [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) for the no-secret support-desk lifecycle.
- Run [`examples/support`](https://github.com/micro/go-micro/tree/master/examples/support) for the no-secret 0→hero support-desk lifecycle.
- Run [`examples/agent-plan-delegate`](https://github.com/micro/go-micro/tree/master/examples/agent-plan-delegate)
to see planning and delegation across agents.
- Read [Debugging your agent](debugging-agents.html) when a chat turn does not call the tool you expected, loops, refuses a call, loses memory, or fails after a flow handoff.
@@ -18,7 +18,6 @@ cloud credentials?"
| Boundary | Contract | CI check |
| --- | --- | --- |
| Scaffold | `micro new` generates a runnable service with and without MCP support. | `go test ./cmd/micro/cli/new -run TestZeroToOne -count=1` |
| First-agent wayfinding | README and the website getting-started docs keep the no-secret → first-agent → debugging → 0→hero links present and in order. | `go test ./internal/harness/zero-to-hero-ci -run TestFirstAgentWayfindingDocs -count=1` |
| First agent | `micro new`, `micro agent preflight`, `micro run`, `micro chat`, and `micro inspect agent` stay available for the documented first-agent walkthrough. | `go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1` |
| Run | `micro run` remains the local development entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
| Chat | `micro chat` remains the interactive agent entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
+1 -3
View File
@@ -16,7 +16,7 @@ It's built on a pluggable architecture of Go interfaces: service discovery, clie
## Learn More
Start with [Getting Started](getting-started.html) for install and the first local service. Then follow the first-agent on-ramp: [No-secret first-agent transcript](guides/no-secret-first-agent.html) to run a mock-model support agent, [Your First Agent](guides/your-first-agent.html) to build and chat with a service-backed agent, [Debugging your agent](guides/debugging-agents.html) to inspect runs and memory, and the [0→hero reference path](guides/zero-to-hero.html) to walk the full scaffold → run → chat → inspect → deploy dry-run lifecycle covered by CI.
Start with [Getting Started](getting-started.html) for install and the first local service. Then follow the first-agent on-ramp: [Your First Agent](guides/your-first-agent.html) to build and chat with a service-backed agent, [Debugging your agent](guides/debugging-agents.html) to inspect runs and memory, and the [0→hero reference path](guides/zero-to-hero.html) to walk the full scaffold → run → chat → inspect → deploy dry-run lifecycle covered by CI.
Otherwise continue to read the docs for more information about the framework.
@@ -24,7 +24,6 @@ Otherwise continue to read the docs for more information about the framework.
- [Getting Started](getting-started.html)
- [0→hero Reference](guides/zero-to-hero.html) - Walk scaffold → run → chat → inspect → deploy dry-run with CI-backed commands
- [No-secret first-agent transcript](guides/no-secret-first-agent.html) - Run the first useful agent path without a provider key
- [Your First Agent](guides/your-first-agent.html) - Build a service-backed agent end to end
- [MCP & AI Agents](mcp.html) - Turn services into AI-callable tools with the Model Context Protocol
- [CLI & Gateway Guide](guides/cli-gateway.html) - Development vs Production modes
@@ -48,7 +47,6 @@ Otherwise continue to read the docs for more information about the framework.
## AI & Agents
- [0→hero Reference](guides/zero-to-hero.html) - Walk scaffold → run → chat → inspect → deploy dry-run with CI-backed commands
- [No-secret first-agent transcript](guides/no-secret-first-agent.html) - Run the first useful agent path without a provider key
- [Your First Agent](guides/your-first-agent.html) - Build a service-backed agent end to end
- [Building AI-Native Services](guides/ai-native-services.html) - End-to-end tutorial for MCP-enabled services
- [MCP Security Guide](guides/mcp-security.html) - Auth, scopes, rate limiting, and audit logging
+3 -11
View File
@@ -56,11 +56,7 @@ After that first-agent path, branch out to:
```go
package main
import (
"context"
"go-micro.dev/v6"
)
import "go-micro.dev/v6"
type Greeter struct{}
@@ -78,11 +74,7 @@ func main() {
### Pub/Sub Event Handler
```go
import (
"context"
"go-micro.dev/v6"
)
import "go-micro.dev/v6"
func main() {
service := micro.NewService("subscriber")
@@ -90,7 +82,7 @@ func main() {
// Subscribe to events
micro.RegisterSubscriber("user.created", service.Server(),
func(ctx context.Context, event *UserCreatedEvent) error {
// Handle the event here.
log.Infof("User created: %s", event.Email)
return nil
},
)
+54 -39
View File
@@ -3,6 +3,7 @@ package store
import (
"fmt"
"os"
"path/filepath"
"strings"
"testing"
"time"
@@ -11,20 +12,15 @@ import (
"github.com/kr/pretty"
)
func newTestFileStore(t *testing.T, opts ...Option) Store {
t.Helper()
opts = append(opts, DirOption(t.TempDir()))
s := NewStore(opts...)
t.Cleanup(func() {
if err := s.Close(); err != nil {
t.Errorf("failed to close file store: %v", err)
}
})
return s
func cleanup(db string, s Store) {
s.Close()
dir := filepath.Join(DefaultDir, db+"/")
os.RemoveAll(dir)
}
func TestFileStoreReInit(t *testing.T) {
s := newTestFileStore(t, Table("aaa"))
s := NewStore(Table("aaa"))
defer cleanup(DefaultDatabase, s)
s.Init(Table("bbb"))
if s.Options().Table != "bbb" {
t.Error("Init didn't reinitialise the store")
@@ -32,22 +28,26 @@ func TestFileStoreReInit(t *testing.T) {
}
func TestFileStoreBasic(t *testing.T) {
s := newTestFileStore(t)
s := NewStore()
defer cleanup(DefaultDatabase, s)
fileTest(s, t)
}
func TestFileStoreTable(t *testing.T) {
s := newTestFileStore(t, Table("testTable"))
s := NewStore(Table("testTable"))
defer cleanup(DefaultDatabase, s)
fileTest(s, t)
}
func TestFileStoreDatabase(t *testing.T) {
s := newTestFileStore(t, Database("testdb"))
s := NewStore(Database("testdb"))
defer cleanup("testdb", s)
fileTest(s, t)
}
func TestFileStoreDatabaseTable(t *testing.T) {
s := newTestFileStore(t, Table("testTable"), Database("testdb"))
s := NewStore(Table("testTable"), Database("testdb"))
defer cleanup("testdb", s)
fileTest(s, t)
}
@@ -94,7 +94,7 @@ func fileTest(s Store, t *testing.T) {
{
Key: "foobar",
Value: []byte("foobarfoobar"),
Expiry: time.Second, // wide window: CI I/O under -race can exceed a 100ms expiry before the read below
Expiry: time.Millisecond * 100,
},
}
@@ -113,8 +113,8 @@ func fileTest(s Store, t *testing.T) {
}
}
// wait for the expiry (must exceed the 1s Expiry above, with margin for slow CI)
time.Sleep(time.Second * 2)
// wait for the expiry
time.Sleep(time.Millisecond * 200)
if results, err := s.Read("foo", ReadPrefix()); err != nil {
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
@@ -135,22 +135,22 @@ func fileTest(s Store, t *testing.T) {
}
}
// Write records with suffix matches and an already-expired record. Avoid
// wall-clock boundary sleeps here: under -race/-cover, sleeping exactly the
// TTL made this assertion flaky on slower CI runners.
// Write 3 records with various expiry and get with Suffix
records = []*Record{
{
Key: "foo",
Value: []byte("foofoo"),
},
{
Key: "barfoo",
Value: []byte("barfoobarfoo"),
Expiry: -time.Second,
Key: "barfoo",
Value: []byte("barfoobarfoo"),
Expiry: time.Millisecond * 100,
},
{
Key: "bazbarfoo",
Value: []byte("bazbarfoobazbarfoo"),
Key: "bazbarfoo",
Value: []byte("bazbarfoobazbarfoo"),
Expiry: 2 * time.Millisecond * 100,
},
}
for _, r := range records {
@@ -160,44 +160,59 @@ func fileTest(s Store, t *testing.T) {
}
if results, err := s.Read("foo", ReadSuffix()); err != nil {
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
} else if len(results) != 2 {
t.Errorf("Expected 2 unexpired suffix items, got %d (%# v)", len(results), spew.Sdump(results))
}
if err := s.Delete("bazbarfoo"); err != nil {
t.Errorf("Delete failed (%v)", err)
} else {
if len(results) != 3 {
t.Errorf("Expected 3 items, got %d", len(results))
// t.Logf("Table test: %v\n", spew.Sdump(results))
}
}
time.Sleep(time.Millisecond * 100)
if results, err := s.Read("foo", ReadSuffix()); err != nil {
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
} else if len(results) != 1 {
t.Errorf("Expected 1 unexpired suffix item, got %d (%# v)", len(results), spew.Sdump(results))
} else {
if len(results) != 2 {
t.Errorf("Expected 2 items, got %d", len(results))
// t.Logf("Table test: %v\n", spew.Sdump(results))
}
}
time.Sleep(time.Millisecond * 100)
if results, err := s.Read("foo", ReadSuffix()); err != nil {
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
} else {
if len(results) != 1 {
t.Errorf("Expected 1 item, got %d", len(results))
// t.Logf("Table test: %# v\n", spew.Sdump(results))
}
}
if err := s.Delete("foo"); err != nil {
t.Errorf("Delete failed (%v)", err)
}
if results, err := s.Read("foo", ReadSuffix()); err != nil {
t.Errorf("Couldn't read all \"foo\" keys, got %# v (%s)", spew.Sdump(results), err)
} else if len(results) != 0 {
t.Errorf("Expected 0 items, got %d (%# v)", len(results), spew.Sdump(results))
} else {
if len(results) != 0 {
t.Errorf("Expected 0 items, got %d (%# v)", len(results), spew.Sdump(results))
}
}
// Test Table, Suffix and WriteOptions
if err := s.Write(&Record{
Key: "foofoobarbar",
Value: []byte("something"),
}, WriteTTL(time.Second)); err != nil {
}, WriteTTL(time.Millisecond*100)); err != nil {
t.Error(err)
}
if err := s.Write(&Record{
Key: "foofoo",
Value: []byte("something"),
}, WriteExpiry(time.Now().Add(time.Second))); err != nil {
}, WriteExpiry(time.Now().Add(time.Millisecond*100))); err != nil {
t.Error(err)
}
if err := s.Write(&Record{
Key: "barbar",
Value: []byte("something"),
// TTL has higher precedence than expiry
}, WriteExpiry(time.Now().Add(time.Hour)), WriteTTL(time.Second)); err != nil {
}, WriteExpiry(time.Now().Add(time.Hour)), WriteTTL(time.Millisecond*100)); err != nil {
t.Error(err)
}
@@ -209,7 +224,7 @@ func fileTest(s Store, t *testing.T) {
}
}
time.Sleep(time.Second * 2) // exceed the 1s TTL/expiry above so everything has expired
time.Sleep(time.Millisecond * 100)
if results, err := s.List(); err != nil {
t.Errorf("List failed: %s", err)