Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 749a3ee382 docs: refresh planner priorities for 4248
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-07 13:56:07 +00:00
63 changed files with 289 additions and 2889 deletions
+3 -4
View File
@@ -21,10 +21,9 @@ changes, architectural rewrites. Those go to the human.
## Work queue (ranked)
1. **Ensure AtlasCloud plan-delegate sends one notification** ([#4535](https://github.com/micro/go-micro/issues/4535)) — The duplicate-delegate replay hang is fixed, so the next highest-value seam is correctness at the plan/delegate → notification boundary. AtlasCloud should receive exactly one spoken completion, because duplicate or missing notifications make the first-agent chat/inspect loop feel unreliable even when the underlying plan succeeds.
2. **Preserve A2A fallback artifact text for AtlasCloud** ([#4522](https://github.com/micro/go-micro/issues/4522)) — The completed A2A stream-fallback task can return empty text parts, which makes chat/inspect output and cross-agent handoff look successful while hiding the answer. This remains a top user-visible adoption gap because developer trust depends on a readable run → chat → inspect loop, especially when streaming falls back.
3. **Ensure AtlasCloud agent-flow sends onboarding notification** ([#4529](https://github.com/micro/go-micro/issues/4529)) — The event-driven onboarding flow can create the workspace but miss the required notification before timing out. This is a direct services → agents → workflows seam, so it belongs ahead of broader interop polish once plan/delegate notification cardinality and fallback text are reliable.
4. **Make AtlasCloud universe A2A reachability probe deterministic** ([#4504](https://github.com/micro/go-micro/issues/4504)) — The universe checkout flow completed, but the A2A reachability probe timed out under AtlasCloud. This still matters for cross-framework operability and agent discoverability, but ranks after the active notification and fallback-content regressions because those are more visible to the first-agent/0→hero lifecycle.
1. **Prevent duplicate delegated notifications in plan-delegate harness** ([#4245](https://github.com/micro/go-micro/issues/4245)) — Now-phase safety is the current red edge after the AtlasCloud 400 fallback shipped: the services → agents → workflows loop must not repeat delegated side effects while recovering or continuing a plan. Make the plan-delegate path idempotent and CI-verifiable before adding more depth.
2. **Make AtlasCloud guarded delegation pass reliably** ([#4244](https://github.com/micro/go-micro/issues/4244)) — keep cross-provider conformance close behind the side-effect fix: AtlasCloud now gets past the Minimax request-shape 400s, but the live agent harness still misses the required guarded delegate within the retry budget.
3. **Link examples wayfinding from website getting-started path** ([#4241](https://github.com/micro/go-micro/issues/4241)) — keep adoption weighted with hardening after the examples index shipped: the repo README and CLI now point at the first-agent/0→hero map, but go-micro.dev getting-started and quickstart pages should expose the same path with a CI-guarded docs smoke check.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
+7 -28
View File
@@ -1,11 +1,9 @@
name: "Loop: Release"
# Generated by `micro loop init`. Cuts the next tag when the default branch has
# new commits since the latest one, and pushes it with a PAT (CODEX_TRIGGER_TOKEN)
# so any tag-triggered release workflow fires. The bump reflects what shipped,
# read from the CHANGELOG [Unreleased] section: new features (Added/Changed) cut
# a MINOR; fixes/docs only cut a PATCH; breaking changes are skipped so a MAJOR
# stays a human decision.
# Generated by `micro loop init`. Cuts the next PATCH tag
# (vMAJOR.MINOR.PATCH+1) when the default branch has new commits
# since the latest such tag, and pushes it with a PAT (CODEX_TRIGGER_TOKEN) so any
# tag-triggered release workflow fires. Minor/major bumps stay with a human.
#
# The tag MUST be pushed with a PAT, not the default GITHUB_TOKEN: a tag pushed
# by GITHUB_TOKEN does not trigger other workflows (Actions blocks that recursion).
@@ -68,30 +66,11 @@ jobs:
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
esac
# Choose the bump from what actually shipped, read from the CHANGELOG
# [Unreleased] section (kept current by the coherence role):
# new features (### Added / ### Changed) -> MINOR
# fixes/docs only -> PATCH
# breaking (### Removed / "(breaking)") -> skip; a major is a human call
UNRELEASED=""
if [ -f CHANGELOG.md ]; then
UNRELEASED=$(awk '/^## \[Unreleased\]/{f=1; next} /^## \[/{f=0} f' CHANGELOG.md)
fi
if printf '%s\n' "$UNRELEASED" | grep -qiE '^### Removed|^### Changed \(breaking\)|BREAKING'; then
echo "CHANGELOG [Unreleased] contains breaking changes — a major release is a human decision. Skipping."
exit 0
elif printf '%s\n' "$UNRELEASED" | grep -qE '^### (Added|Changed)'; then
NEXT="v${major}.$((minor + 1)).0"
KIND="minor (new features)"
else
NEXT="v${major}.${minor}.$((patch + 1))"
KIND="patch (fixes/docs only)"
fi
echo "cutting: $NEXT — $KIND ($COUNT commits since $LATEST)"
NEXT="v${major}.${minor}.$((patch + 1))"
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
git config user.name "loop release bot"
git config user.email "noreply@users.noreply.github.com"
git tag -a "$NEXT" -m "Release $NEXT — automated $KIND ($COUNT commits since $LATEST)"
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
echo "Pushed $NEXT."
+3 -57
View File
@@ -5,10 +5,9 @@ All notable changes to Go Micro are documented here.
Format follows [Keep a Changelog](https://keepachangelog.com/) and versions
follow [Semantic Versioning](https://semver.org/), matching the git tags and
[GitHub releases](https://github.com/micro/go-micro/releases) (`v6.MINOR.PATCH`).
Releases are cut automatically as the loop merges improvements — a **minor**
bump when new features land (`### Added`/`### Changed`), a **patch** when it's
fixes/docs only; major bumps stay a human decision. The `[Unreleased]` section
below is kept current between tags and rolled into the next version when it ships.
Patch releases are cut automatically as the loop merges improvements; the
`[Unreleased]` section below is kept current between tags and rolled into the
next version when it ships.
> Earlier `2026.0x` headings are historical calendar-style markers from before
> v6 tagging; they are kept for continuity and not reused.
@@ -17,65 +16,12 @@ below is kept current between tags and rolled into the next version when it ship
## [Unreleased]
### Added
- **Agent stream provider conformance** — provider conformance now covers agent streaming behavior so streaming-capable providers stay aligned with the harness contract. (`agent/`, `internal/harness/`)
### Changed
- **Provider model call timeouts** — model call timeout enforcement now wraps provider calls more defensively, reducing hangs in agent and harness paths. (`agent/`, `ai/`)
- **First-agent harness diagnostics** — getting-started harness logs now make first-run and 0→hero failures easier to locate. (`internal/harness/`)
### Fixed
- **Partial text tool calls** — text tool-call recovery now repairs partial function-style calls more reliably before fallback parsing continues. (`agent/`)
- **Retry timeout test stability** — retry timeout coverage is less race-prone. (`agent/`)
- **Checkpointed tool-call resume** — resumed agent runs now preserve checkpointed tool calls across startup resume paths. (`agent/`)
### Documentation
- **Roadmap agent status** — public roadmap docs now reflect the current agent lifecycle status more consistently. (`internal/website/docs/`)
- **Agent resume limits** — docs now describe checkpoint resume boundaries for agent runs. (`internal/website/docs/`)
---
## [6.4.0] - July 2026
### Added
- **Provider HTTP retry signals** — provider failures now preserve HTTP status and `Retry-After` details so retry classification and backoff can respond to rate limits and unavailable providers. (`ai/`)
- **Zero-to-hero deploy dry-run verification** — the maintained 0→hero harness now covers deploy dry-run boundaries for the services → agents → workflows lifecycle. (`internal/harness/`)
- **First-agent CLI wayfinding verification** — the harness now checks that first-agent CLI wayfinding stays discoverable. (`internal/harness/`)
- **Agent startup resume verification** — agent startup resume now has focused checkpoint coverage. (`agent/`, `internal/harness/`)
- **Direct first-agent chat prompts** — first-agent flows can accept direct chat prompts, reducing friction in the first useful conversation. (`cmd/micro/`, `agent/`)
- **Workflow run info on tool spans** — agent tool spans now include workflow run details for easier trace correlation. (`agent/`, `flow/`)
### Fixed
- **Stream fallback memory** — unsupported streaming attempts no longer leave stale duplicate user turns before fallback paths continue with non-streaming agent calls. (`agent/`)
- **Function-style text tool calls** — agent fallback parsing now recognizes provider replies that render tools as function-style calls, including nested JSON arguments. (`agent/`)
- **Plan/delegate notify recovery** — plan-delegate recovery now waits for recovered notify side effects and routes retries through the communications agent that owns the notification. (`internal/harness/`)
- **Onboarding side-effect enforcement** — the agent-flow harness now fails when required onboarding side effects are missing, making lifecycle regressions visible. (`internal/harness/`)
- **Plan/delegate notify stability** — notify recovery is more deterministic across retry and replay paths. (`agent/`, `internal/harness/`)
- **AtlasCloud MiniMax tool fallback** — AtlasCloud MiniMax service-tool fallback now handles 400 responses and follow-up retries more reliably. (`ai/atlascloud/`, `agent/`)
### Documentation
- **First-agent docs wayfinding guard** — the local harness now includes a focused no-network check for first-agent and 0→hero docs links. (`Makefile`, `internal/harness/`)
---
## [6.3.18] - July 2026
### Added
- **StreamAsk close cancellation** — agent streaming calls now cancel promptly when their runner closes, avoiding orphaned stream work. (`agent/`)
- **Agent resume pending helper** — agent durability now has a focused helper for resuming pending checkpointed runs. (`agent/`)
- **Agent tool retry tracing** — agent traces now include tool retry attempts for easier debugging of retry/fallback behavior. (`agent/`)
- **Shared-broker universe harness** — the universe harness now runs against the shared broker path, improving coverage of the same runtime wiring used by services, agents, and workflows. (`internal/harness/`)
### Fixed
- **Plan/delegate retry idempotency** — agent retries now preserve side-effect and notification dedupe across conformance retry paths, including completion and owner-notification edge cases. (`agent/`, `internal/harness/`)
- **AtlasCloud text tool calls** — AtlasCloud fallback handling now recovers more text-rendered tool calls from OpenAI-compatible responses. (`ai/atlascloud/`, `agent/`)
- **OpenAI-compatible text tool calls** — OpenAI-compatible providers now recover text-rendered tool calls more reliably. (`agent/`)
- **AtlasCloud multi-step follow-ups** — AtlasCloud tool fallback handling now continues multi-step tool follow-up paths more reliably. (`ai/atlascloud/`, `agent/`)
### Documentation
- **Agent debugging quickcheck** — docs now include a focused quickcheck path for first-agent debugging. (`internal/website/docs/`)
- **Website first-agent examples map** — website docs now link the maintained examples wayfinding map for the first-agent route. (`internal/website/docs/`)
- **Examples wayfinding index** — examples docs now provide a central map for first-agent, support, and interop examples. (`examples/`, `internal/website/docs/`)
---
+4 -31
View File
@@ -8,7 +8,7 @@ LDFLAGS = -X $(GIT_IMPORT).BuildDate=$(BUILD_DATE) -X $(GIT_IMPORT).GitCommit=$(
# GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser-cross:v1.25.7
GORELEASER_DOCKER_IMAGE = ghcr.io/goreleaser/goreleaser:latest
.PHONY: test test-race test-coverage harness inner-loop cli-wayfinding docs-wayfinding install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
.PHONY: test test-race test-coverage harness install-smoke provider-conformance-mock provider-conformance lint fmt install-tools proto clean help gorelease-dry-run gorelease-dry-run-docker
# Default target
help:
@@ -19,9 +19,6 @@ help:
@echo " make test-coverage - Run tests with coverage"
@echo " make lint - Run linter"
@echo " make harness - Run deterministic getting-started and end-to-end harnesses"
@echo " make inner-loop - Verify scaffold → run/chat/inspect → deploy dry-run contract"
@echo " make cli-wayfinding - Verify installed first-agent CLI wayfinding commands"
@echo " make docs-wayfinding - Verify first-agent docs/CLI wayfinding stays in sync"
@echo " make install-smoke - Verify the local install.sh and first-run CLI smoke path"
@echo " make provider-conformance-mock - Run cross-provider harness with deterministic mock provider"
@echo " make provider-conformance - Run harnesses against configured live providers"
@@ -52,37 +49,12 @@ test-coverage:
# This mirrors the default CI path so local dogfooding catches scaffold,
# run/chat/inspect, and 0→hero regressions before a PR is opened.
harness:
$(MAKE) cli-wayfinding
$(MAKE) inner-loop
$(MAKE) install-smoke
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
./internal/harness/zero-to-hero-ci/run.sh
go run ./internal/harness/agent-flow
$(MAKE) provider-conformance-mock
# Focused provider-free CLI inner-loop contract: scaffold a service, keep the
# run/chat/inspect commands discoverable, and prove deploy dry-run reaches the
# documented boundary without remote side effects. Use this when README/docs/CLI
# drift is the concern and the full runtime harness is more than you need.
inner-loop:
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestZeroToHeroCLIBoundaries|TestZeroToHeroCommandPrintsMaintainedNoSecretPath' -count=1
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
go test ./internal/harness/zero-to-hero-ci -run 'TestZeroToHeroDeployDryRunCommandSmoke|TestNoSecretFirstAgentDebuggingSmoke|TestYourFirstAgentTutorialSmoke' -count=1
# Verify the installed CLI keeps the first-agent on-ramp commands discoverable.
# This guards the no-secret commands README/docs recommend (`micro agent demo`,
# `micro examples`, and `micro zero-to-hero`) as a CI contract.
cli-wayfinding:
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestExamplesWayfindingIndexStaysLinked|TestExamplesCommandPointsAtWayfindingIndex|TestZeroToHeroCommandPrintsMaintainedNoSecretPath' -count=1
$(MAKE) docs-wayfinding
$(MAKE) install-smoke
# Verify the README and website first-agent/0→hero wayfinding links resolve to
# maintained local docs and examples. This is a focused no-network guard for the
# developer-adoption on-ramp.
docs-wayfinding:
go test ./internal/harness/zero-to-hero-ci -run 'TestFirstAgentWayfindingDocs|TestFirstAgentWayfindingLinkTargetsResolve' -count=1
go test ./cmd/micro -run 'TestFirstAgentDocsMatchCLIOutput|TestFirstAgentWalkthroughCLIBoundaries' -count=1
# Verify the documented install script and first-run CLI command boundaries without
# provider keys or network access.
install-smoke:
@@ -138,3 +110,4 @@ gorelease-dry-run:
-w /$(NAME) \
$(GORELEASER_DOCKER_IMAGE) \
--clean --verbose --skip=publish,validate --snapshot
-6
View File
@@ -78,12 +78,6 @@ access or provider keys, use:
make install-smoke
```
To verify the focused CLI inner-loop contract — scaffold → run/chat/inspect → deploy dry-run — use:
```bash
make inner-loop
```
To run the broader local contract (including the [0→hero services → agents → workflows path](internal/website/docs/guides/zero-to-hero.md),
chat/inspect CLI boundaries, and deploy dry-run), use:
+4 -18
View File
@@ -14,9 +14,8 @@ The full, current roadmap lives at **[go-micro.dev/docs/roadmap](https://go-micr
## Where we are (v6)
Services, agents (`plan`/`delegate`, guardrails, memory, tool middleware,
checkpoint/resume, and OpenTelemetry run spans), durable flows, the MCP and A2A
gateways (both directions, including A2A streaming,
Services, agents (`plan`/`delegate`, guardrails, memory, tool middleware), durable
flows, the MCP and A2A gateways (both directions, including A2A streaming,
push notifications, and multi-turn continuation), x402 paid tools, secure by
default.
@@ -40,24 +39,11 @@ default.
propagation, retry/backoff.
- **Getting-started contract** — define and CI-verify the 0→1 and 0→hero flows.
## Shipped agent depth
- **Durable agent loop** — opt-in `Checkpoint` support lets agent `Ask` and
streaming runs persist, list pending work, and resume without replaying completed
tool calls. Human-input pauses resume through explicit input helpers.
- **Agent observability** — agent `RunInfo` now feeds OpenTelemetry spans/events
across runs, model turns, tool calls, retries, delegation lineage, and resume
checkpoints.
## Next — agentic depth
- **Durable agent loop** — resume a long run via `Checkpoint` (flows already do).
- **Streaming** — broaden provider-backed `ai.Stream` coverage and keep chat/A2A streaming end to end.
- **Resume operations polish** — keep improving CLI/docs breadcrumbs for finding
pending agent runs and deciding whether to call resume, resume-input, or stream
resume in production.
- **Observability hardening** — keep span attributes and run inspection coherent
across agents, flows, and gateways as more providers and workflow paths are
exercised.
- **Agent observability** — `RunInfo` → OpenTelemetry spans.
## Later
+5 -42
View File
@@ -33,7 +33,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"
@@ -100,17 +99,6 @@ type agentImpl struct {
// holding mu. Tool execution updates it so resumed runs can reuse
// completed tool results without replaying side effects.
currentRun *flow.Run
// delegateCalls collapses concurrent equivalent delegate tool calls so a
// provider replay cannot fan out duplicate delegated side effects before the
// durable delegate-result cache is written.
delegateMu sync.Mutex
delegateCalls map[string]*delegateCall
// stopCh lets Stop unblock Run. Without this, tests and harnesses that
// start agents in goroutines can leave Run parked forever after the RPC
// server has been stopped.
stopCh chan struct{}
}
// New creates a new Agent.
@@ -233,18 +221,16 @@ func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, erro
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
messages := append([]ai.Message(nil), a.mem.Messages()...)
messages = append(messages, ai.Message{Role: "user", Content: message})
a.mem.Add("user", message)
stream, err := a.model.Stream(ctx, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: messages,
Messages: a.mem.Messages(),
})
if err != nil {
return nil, err
}
a.mem.Add("user", message)
return &memoryRecordingStream{stream: stream, memory: a.mem}, nil
}
@@ -420,15 +406,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
continue
}
}
if toolName := partialTextToolCallName(resp.Reply, toolList); len(resp.ToolCalls) == 0 && toolName != "" && planCompletionTurn < maxPlanCompletionTurns {
if resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
}
message = fmt.Sprintf("Your previous response started a %q tool call but did not finish valid tool-call markup or JSON arguments, so no tool was executed. Retry the same step now by emitting one complete valid tool call for %q. Do not describe the action in prose, and do not claim completion until the tool call succeeds.", toolName, toolName)
a.mem.Add("user", message)
messages = a.mem.Messages()
continue
}
break
}
@@ -447,13 +424,9 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
reply += resp.Answer
}
completedToolCalls := checkpointToolCalls(run.Steps)
if a.currentRun != nil {
completedToolCalls = checkpointToolCalls(a.currentRun.Steps)
}
res := &Response{
Reply: reply,
ToolCalls: mergeCheckpointToolCalls(completedToolCalls, resp.ToolCalls),
ToolCalls: resp.ToolCalls,
Agent: a.opts.Name,
RunID: a.runID,
ParentID: parentRunID,
@@ -545,11 +518,6 @@ func (a *agentImpl) Run() error {
return fmt.Errorf("failed to start agent: %w", err)
}
stopCh := make(chan struct{})
a.mu.Lock()
a.stopCh = stopCh
a.mu.Unlock()
fmt.Printf("Agent %s registered (manages: %s)\n", a.opts.Name, strings.Join(a.opts.Services, ", "))
// Optionally serve the agent directly over the A2A protocol, calling
@@ -571,17 +539,12 @@ func (a *agentImpl) Run() error {
fmt.Printf("Agent %s serving A2A on %s\n", a.opts.Name, a.opts.A2AAddress)
}
<-stopCh
ch := make(chan struct{})
<-ch
return nil
}
func (a *agentImpl) Stop() error {
a.mu.Lock()
if a.stopCh != nil {
close(a.stopCh)
a.stopCh = nil
}
a.mu.Unlock()
if a.server != nil {
return a.server.Stop()
}
-10
View File
@@ -38,16 +38,6 @@ func TestNew(t *testing.T) {
}
}
func TestBundledProviderImportsIncludeMiniMaxForConformance(t *testing.T) {
if model := ai.New("minimax", ai.WithAPIKey("test-key")); model == nil {
t.Fatal("ai.New(\"minimax\") returned nil; agent live conformance cannot exercise MiniMax")
}
caps := ai.ProviderCapabilities("minimax")
if !caps.Stream || !caps.ToolStream {
t.Fatalf("MiniMax capabilities = %#v, want streaming and tool streaming registered", caps)
}
}
func TestChatResponseIncludesRunIDs(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
return &ai.Response{Reply: "ok"}, nil
+6 -65
View File
@@ -27,11 +27,6 @@ const (
toolHumanInput = "request_input"
)
type delegateCall struct {
done chan struct{}
res ai.ToolResult
}
// builtinTools returns the tool definitions exposed to the model in
// addition to the agent's scoped service tools.
func builtinTools() []ai.Tool {
@@ -590,7 +585,7 @@ func (a *agentImpl) handleHumanInput(call ai.ToolCall) ai.ToolResult {
// if 'to' names a registered agent, it is called via RPC. Otherwise an
// ephemeral sub-agent is created with a fresh, isolated context, asked
// the subtask, and its reply returned.
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res ai.ToolResult) {
func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) ai.ToolResult {
input := call.Input
task, _ := input["task"].(string)
if task == "" {
@@ -601,12 +596,6 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res a
return cached
}
key := delegateResultKey(to, task)
if cached, ok := a.joinDelegateCall(ctx, call.ID, key); ok {
return cached
}
defer func() { a.finishDelegateCall(key, res) }()
// An external agent on another framework, addressed by A2A URL.
if strings.HasPrefix(to, "http://") || strings.HasPrefix(to, "https://") {
reply, err := a2a.NewClient(to).Send(ctx, task)
@@ -658,38 +647,6 @@ func (a *agentImpl) handleDelegate(ctx context.Context, call ai.ToolCall) (res a
return a.storeDelegateResult(call.ID, to, task, map[string]any{"reply": resp.Reply})
}
func (a *agentImpl) joinDelegateCall(ctx context.Context, id, key string) (ai.ToolResult, bool) {
a.delegateMu.Lock()
if a.delegateCalls == nil {
a.delegateCalls = map[string]*delegateCall{}
}
if inFlight := a.delegateCalls[key]; inFlight != nil {
a.delegateMu.Unlock()
select {
case <-ctx.Done():
return errResult(id, ctx.Err().Error()), true
case <-inFlight.done:
return withToolResultID(inFlight.res, id), true
}
}
a.delegateCalls[key] = &delegateCall{done: make(chan struct{})}
a.delegateMu.Unlock()
return ai.ToolResult{}, false
}
func (a *agentImpl) finishDelegateCall(key string, res ai.ToolResult) {
a.delegateMu.Lock()
inFlight := a.delegateCalls[key]
if inFlight == nil {
a.delegateMu.Unlock()
return
}
inFlight.res = res
delete(a.delegateCalls, key)
close(inFlight.done)
a.delegateMu.Unlock()
}
func (a *agentImpl) cachedDelegateResult(id, to, task string) (ai.ToolResult, bool) {
recs, err := a.stateStore().Read(delegateResultKey(to, task))
if err != nil || len(recs) == 0 {
@@ -709,11 +666,6 @@ func (a *agentImpl) storeDelegateResult(id, to, task string, out map[string]any)
return ai.ToolResult{ID: id, Value: out, Content: string(b)}
}
func withToolResultID(res ai.ToolResult, id string) ai.ToolResult {
res.ID = id
return res
}
func delegateResultKey(to, task string) string {
fp := normalizeDelegateTarget(to) + "\x00" + normalizeDelegateTask(task)
sum := sha256.Sum256([]byte(fp))
@@ -737,28 +689,17 @@ func normalizeDelegateTask(task string) string {
}
}, task)
task = strings.Join(strings.Fields(task), " ")
if strings.Contains(task, "owner") &&
if strings.Contains(task, "notify") &&
strings.Contains(task, "owner") &&
strings.Contains(task, "acme") &&
isLaunchReadinessDelegateTask(task) {
strings.Contains(task, "launch") &&
strings.Contains(task, "plan") &&
(strings.Contains(task, "ready") || strings.Contains(task, "readiness") || strings.Contains(task, "prepared") || strings.Contains(task, "complete")) {
return "notify owner@acme.com launch-plan-ready"
}
return task
}
func isLaunchReadinessDelegateTask(task string) bool {
hasNotify := strings.Contains(task, "notify") || strings.Contains(task, "notification") || strings.Contains(task, "tell")
hasLaunch := strings.Contains(task, "launch")
hasPlanOrReadiness := strings.Contains(task, "plan") || strings.Contains(task, "readiness") || strings.Contains(task, "ready")
hasCompletion := strings.Contains(task, "ready") ||
strings.Contains(task, "readiness") ||
strings.Contains(task, "prepared") ||
strings.Contains(task, "complete") ||
strings.Contains(task, "finished") ||
strings.Contains(task, "done") ||
strings.Contains(task, "sent")
return hasNotify && hasLaunch && hasPlanOrReadiness && hasCompletion
}
// isAgent reports whether name resolves to a registered agent (a
// service advertising type=agent in its metadata).
func (a *agentImpl) isAgent(name string) bool {
+8 -53
View File
@@ -3,9 +3,7 @@ package agent
import (
"context"
"encoding/json"
"sync"
"testing"
"time"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/registry"
@@ -196,59 +194,16 @@ func TestDelegateResultCacheReusesLaunchReadinessParaphrases(t *testing.T) {
t.Fatal("storeDelegateResult returned empty content")
}
replayedTasks := []string{
"Notify the plan owner at owner @ acme.com that launch readiness is prepared and complete.",
"Tell owner at acme dot com the launch readiness notification was sent and the plan is done.",
replayedTask := "Notify the plan owner at owner @ acme.com that launch readiness is prepared and complete."
cached, ok := a.cachedDelegateResult("delegate-2", " COMMS ", replayedTask)
if !ok {
t.Fatal("cachedDelegateResult missed equivalent launch-readiness delegate replay")
}
for i, replayedTask := range replayedTasks {
cached, ok := a.cachedDelegateResult("delegate-replay", " COMMS ", replayedTask)
if !ok {
t.Fatalf("cachedDelegateResult missed equivalent launch-readiness delegate replay %d", i)
}
if cached.ID != "delegate-replay" {
t.Fatalf("cached result ID = %q, want replay call ID", cached.ID)
}
if !containsStr(cached.Content, "Notified owner@acme.com") {
t.Fatalf("cached result content = %q, want original delegate reply", cached.Content)
}
if cached.ID != "delegate-2" {
t.Fatalf("cached result ID = %q, want replay call ID", cached.ID)
}
}
func TestDelegateInFlightReplaysShareFirstResult(t *testing.T) {
a := New(Name("planner"), WithStore(store.NewMemoryStore())).(*agentImpl)
key := delegateResultKey("comms", "Notify owner@acme.com that the launch plan is ready")
if _, joined := a.joinDelegateCall(context.Background(), "delegate-1", key); joined {
t.Fatal("first delegate call unexpectedly joined an existing in-flight call")
}
var wg sync.WaitGroup
wg.Add(1)
results := make(chan ai.ToolResult, 1)
go func() {
defer wg.Done()
res, joined := a.joinDelegateCall(context.Background(), "delegate-2", key)
if !joined {
t.Error("replayed delegate call did not join the in-flight call")
return
}
results <- res
}()
select {
case res := <-results:
t.Fatalf("replayed delegate returned before first call finished: %+v", res)
case <-time.After(25 * time.Millisecond):
}
first := ai.ToolResult{ID: "delegate-1", Content: `{"reply":"Notified owner@acme.com."}`}
a.finishDelegateCall(key, first)
wg.Wait()
replayed := <-results
if replayed.ID != "delegate-2" {
t.Fatalf("replayed result ID = %q, want delegate-2", replayed.ID)
}
if replayed.Content != first.Content {
t.Fatalf("replayed content = %q, want %q", replayed.Content, first.Content)
if !containsStr(cached.Content, "Notified owner@acme.com") {
t.Fatalf("cached result content = %q, want original delegate reply", cached.Content)
}
}
-54
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"strings"
"time"
"go-micro.dev/v6/ai"
@@ -289,56 +288,3 @@ func upsertStep(steps *[]flow.StepRecord, rec flow.StepRecord) int {
*steps = append(*steps, rec)
return len(*steps) - 1
}
func checkpointToolCalls(steps []flow.StepRecord) []ai.ToolCall {
calls := make([]ai.ToolCall, 0, len(steps))
for _, step := range steps {
call, ok := checkpointToolCall(step)
if !ok {
continue
}
calls = append(calls, call)
}
return calls
}
func checkpointToolCall(step flow.StepRecord) (ai.ToolCall, bool) {
if step.Status != "done" || !strings.HasPrefix(step.Name, "tool:") {
return ai.ToolCall{}, false
}
parts := strings.SplitN(strings.TrimPrefix(step.Name, "tool:"), ":", 2)
if len(parts) != 2 || parts[0] == "" {
return ai.ToolCall{}, false
}
input := map[string]any{}
if parts[1] != "null" && parts[1] != "" {
if err := json.Unmarshal([]byte(parts[1]), &input); err != nil {
return ai.ToolCall{}, false
}
}
return ai.ToolCall{Name: parts[0], Input: input, Result: step.Result}, true
}
func mergeCheckpointToolCalls(checkpointed, current []ai.ToolCall) []ai.ToolCall {
if len(checkpointed) == 0 {
return current
}
seen := make(map[string]struct{}, len(current))
for _, call := range current {
seen[toolCallKey(call.Name, call.Input)] = struct{}{}
}
merged := make([]ai.ToolCall, 0, len(checkpointed)+len(current))
for _, call := range checkpointed {
if _, ok := seen[toolCallKey(call.Name, call.Input)]; ok {
continue
}
merged = append(merged, call)
}
merged = append(merged, current...)
return merged
}
func toolCallKey(name string, input map[string]any) string {
b, _ := json.Marshal(input)
return name + ":" + string(b)
}
+1 -75
View File
@@ -21,7 +21,7 @@ func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
return &ai.Response{Reply: "done", ToolCalls: []ai.ToolCall{{ID: "call-1", Name: "external.lookup", Result: "cached"}}}, nil
return &ai.Response{Reply: "done"}, nil
}
defer func() { fakeGen = nil }()
@@ -49,9 +49,6 @@ func TestResumeCompletedCheckpointDoesNotReplayModel(t *testing.T) {
if resumed.RunID != resp.RunID {
t.Fatalf("resumed run id = %q, want %q", resumed.RunID, resp.RunID)
}
if len(resumed.ToolCalls) != 1 || resumed.ToolCalls[0].Name != "external.lookup" || resumed.ToolCalls[0].Result != "cached" {
t.Fatalf("resumed tool calls = %#v, want persisted completed call", resumed.ToolCalls)
}
if calls != 1 {
t.Fatalf("model calls after Resume = %d, want 1", calls)
}
@@ -104,12 +101,6 @@ func TestResumeFailedCheckpointDoesNotReplayCompletedTool(t *testing.T) {
if resp.Reply != "finished from checkpoint" {
t.Fatalf("Resume reply = %q", resp.Reply)
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "external.charge" || resp.ToolCalls[0].Result != "charged" {
t.Fatalf("resumed tool calls = %#v, want preserved completed charge call", resp.ToolCalls)
}
if got := resp.ToolCalls[0].Input["order"]; got != "42" {
t.Fatalf("resumed tool input order = %#v, want 42", got)
}
if toolRuns != 1 {
t.Fatalf("tool executions after Resume = %d, want completed tool was not replayed", toolRuns)
}
@@ -406,71 +397,6 @@ func TestResumeFailedCheckpointAfterFreshAgentRestart(t *testing.T) {
}
}
func TestResumePendingAfterFreshAgentRestartDoesNotReplayCompletedTool(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
cp := flow.StoreCheckpoint(st, "startup-resume-agent")
toolRuns := 0
failFirst := true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.allocate", Input: map[string]any{"cluster": "blue"}})
if res.Content != "allocated" {
t.Fatalf("tool result = %q, want allocated", res.Content)
}
}
if failFirst {
failFirst = false
return nil, errors.New("process stopped before final response")
}
return &ai.Response{Reply: "startup recovery complete"}, nil
}
defer func() { fakeGen = nil }()
newAgent := func() *agentImpl {
return newTestAgent(Name("startup-resume-agent"), WithStore(st), WithCheckpoint(cp),
WithTool("external.allocate", "allocate capacity once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "allocated", nil
}))
}
first := newAgent()
_, err := first.Ask(ctx, "allocate blue capacity")
if err == nil {
t.Fatal("Ask succeeded, want simulated process stop")
}
if toolRuns != 1 {
t.Fatalf("tool executions after failed Ask = %d, want 1", toolRuns)
}
restarted := newAgent()
failedRun, err := ResumePending(ctx, restarted)
if err != nil {
t.Fatalf("ResumePending after restart: failedRun=%q err=%v", failedRun, err)
}
if failedRun != "" {
t.Fatalf("failed run = %q, want none", failedRun)
}
if toolRuns != 1 {
t.Fatalf("tool executions after ResumePending = %d, want completed tool not replayed", toolRuns)
}
runs, err := Pending(ctx, restarted)
if err != nil {
t.Fatalf("Pending after ResumePending: %v", err)
}
if len(runs) != 0 {
t.Fatalf("Pending after ResumePending = %#v, want none", runs)
}
summaries, err := ListRunSummaries(st, "startup-resume-agent")
if err != nil {
t.Fatalf("ListRunSummaries after ResumePending: %v", err)
}
if len(summaries) != 1 || summaries[0].Status != "done" || summaries[0].Checkpoint != "done" {
t.Fatalf("summary after ResumePending = %#v, want one done run", summaries)
}
}
func TestResumeFailedCheckpointDoesNotDuplicateCompactedMemory(t *testing.T) {
ctx := context.Background()
st := store.NewMemoryStore()
+1 -185
View File
@@ -4,7 +4,6 @@ import (
"context"
"errors"
"fmt"
"io"
"os"
"strings"
"testing"
@@ -46,120 +45,6 @@ func TestAgentProviderConformanceMatrix(t *testing.T) {
}
}
func TestAgentProviderStreamConformanceMatrix(t *testing.T) {
providers := []conformanceProvider{
{name: "fake"},
{name: "openai", key: "OPENAI_API_KEY", model: "GO_MICRO_CONFORMANCE_OPENAI_MODEL", live: true},
{name: "anthropic", key: "ANTHROPIC_API_KEY", model: "GO_MICRO_CONFORMANCE_ANTHROPIC_MODEL", live: true},
{name: "atlascloud", key: "ATLASCLOUD_API_KEY", model: "GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL", live: true},
{name: "groq", key: "GROQ_API_KEY", model: "GO_MICRO_CONFORMANCE_GROQ_MODEL", live: true},
{name: "minimax", key: "MINIMAX_API_KEY", model: "GO_MICRO_CONFORMANCE_MINIMAX_MODEL", live: true},
{name: "mistral", key: "MISTRAL_API_KEY", model: "GO_MICRO_CONFORMANCE_MISTRAL_MODEL", live: true},
{name: "together", key: "TOGETHER_API_KEY", model: "GO_MICRO_CONFORMANCE_TOGETHER_MODEL", live: true},
}
selected := selectedConformanceProviders(os.Getenv("GO_MICRO_AGENT_CONFORMANCE_PROVIDERS"))
for _, provider := range providers {
provider := provider
if len(selected) > 0 && !selected[provider.name] {
continue
}
t.Run(provider.name, func(t *testing.T) {
runAgentStreamConformanceScenario(t, provider)
})
}
}
func runAgentStreamConformanceScenario(t *testing.T, provider conformanceProvider) {
t.Helper()
if provider.live {
if os.Getenv(provider.key) == "" {
t.Skipf("%s not set; skipping live %s stream conformance", provider.key, provider.name)
}
if os.Getenv("GO_MICRO_AGENT_CONFORMANCE_LIVE") == "" {
t.Skipf("GO_MICRO_AGENT_CONFORMANCE_LIVE not set; skipping live %s stream conformance", provider.name)
}
caps := ai.ProviderCapabilities(provider.name)
if !caps.Stream {
t.Fatalf("ProviderCapabilities(%q).Stream = false, want true for stream conformance", provider.name)
}
if !caps.ToolStream {
t.Skipf("ProviderCapabilities(%q).ToolStream = false; skipping live tool stream conformance", provider.name)
}
} else {
var sawToolSchema bool
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
if req.Prompt != "Stream exactly: agent-stream-conformance-ok" {
return nil, fmt.Errorf("prompt = %q", req.Prompt)
}
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" || req.Messages[len(req.Messages)-1].Content != req.Prompt {
return nil, fmt.Errorf("messages = %#v, want current user turn", req.Messages)
}
for _, tool := range req.Tools {
if tool.Name == "conformance_echo" {
sawToolSchema = true
}
}
if !sawToolSchema {
return nil, errors.New("stream request omitted conformance tool schema")
}
return &sliceStream{chunks: []string{"agent-stream-", "conformance-ok"}}, nil
}
defer func() { fakeStream = nil }()
}
agentOpts := []Option{
Name("stream-conformance-" + provider.name),
Provider(provider.name),
APIKey(os.Getenv(provider.key)),
Prompt("Stream conformance: preserve the exact requested marker in the final answer."),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(8)),
ModelCallTimeout(45 * time.Second),
WithTool("conformance_echo", "Echo a conformance value and return a deterministic marker.", map[string]any{
"value": map[string]any{"type": "string", "description": "value to echo"},
}, func(ctx context.Context, input map[string]any) (string, error) {
return `{"marker":"agent-stream-conformance-ok"}`, nil
}),
}
if provider.model != "" {
if model := os.Getenv(provider.model); model != "" {
agentOpts = append(agentOpts, Model(model))
}
}
stream, err := New(agentOpts...).Stream(context.Background(), "Stream exactly: agent-stream-conformance-ok")
if err != nil {
t.Fatalf("Stream: %v", err)
}
defer stream.Close()
var reply strings.Builder
deadline := time.After(45 * time.Second)
for {
select {
case <-deadline:
t.Fatal("timed out waiting for streamed final output")
default:
}
chunk, err := stream.Recv()
if errors.Is(err, io.EOF) {
break
}
if err != nil {
t.Fatalf("Recv: %v", err)
}
reply.WriteString(chunk.Reply)
if strings.Contains(reply.String(), "agent-stream-conformance-ok") {
return
}
}
if got := reply.String(); !strings.Contains(got, "agent-stream-conformance-ok") {
t.Fatalf("streamed reply %q does not include conformance marker", got)
}
}
func selectedConformanceProviders(csv string) map[string]bool {
out := map[string]bool{}
for _, part := range strings.Split(csv, ",") {
@@ -402,20 +287,6 @@ func TestAgentProviderConformanceFinalDelegateRetryUsesTaggedCall(t *testing.T)
}
}
func TestAgentProviderConformanceMarkerRetryRequiresExactMarkerReply(t *testing.T) {
prompt := nextConformanceRetryPrompt(true, true, false, 2)
for _, want := range []string{
"omitted the conformance marker",
"do not call more tools",
"do not summarize",
"Reply with exactly this sentence: agent-conformance-ok after guarded delegate refusal.",
} {
if !strings.Contains(prompt, want) {
t.Fatalf("marker retry prompt %q missing %q", prompt, want)
}
}
}
func nextConformanceRetryPrompt(sawTool, sawBlockedDelegate, hasMarker bool, attempt int) string {
if attempt >= 4 && sawTool && !sawBlockedDelegate {
return "Final conformance retry: emit exactly this tagged tool call so the harness can execute the guarded delegate refusal, then include agent-conformance-ok and the refusal in the final answer: " + conformanceDelegateTaggedCall
@@ -426,7 +297,7 @@ func nextConformanceRetryPrompt(sawTool, sawBlockedDelegate, hasMarker bool, att
case !sawBlockedDelegate:
return "The previous response called conformance_echo but did not attempt the required guarded delegation. Continue the same conformance check now: call delegate exactly once with input " + conformanceDelegateInputJSON + "; do not answer in prose until that delegate call has been attempted. If native tool_calls are unavailable, emit exactly " + conformanceDelegateTaggedCall + ". The delegate is expected to be refused by policy; include that refusal and the agent-conformance marker in the final answer."
case !hasMarker:
return "The previous response completed the required tool calls but omitted the conformance marker. Continue the same conformance check now: do not call more tools, do not summarize, and do not use synonyms. Reply with exactly this sentence: agent-conformance-ok after guarded delegate refusal."
return "The previous response completed the required tool calls but omitted the conformance marker. Continue the same conformance check now: do not call more tools; answer with the prior echo result marker agent-conformance-ok and mention the guarded delegate refusal."
default:
return "Retry the provider conformance check and include the agent-conformance marker in the final answer."
}
@@ -746,61 +617,6 @@ func TestAgentExecutesProviderTextToolCallFallback(t *testing.T) {
}
}
func TestAgentRepairsPartialTextToolCallFallback(t *testing.T) {
attempts := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
return nil, errors.New("missing tool handler")
}
attempts++
if attempts == 1 {
return &ai.Response{Reply: `<tool_call name="conformance_echo">`}, nil
}
if !strings.Contains(req.Prompt, "did not finish valid tool-call markup") {
return nil, fmt.Errorf("repair prompt = %q, want partial tool-call repair guidance", req.Prompt)
}
return &ai.Response{
Reply: `<tool_call name="conformance_echo">{"value":"agent-conformance"}</tool_call>`,
}, nil
}
defer func() { fakeGen = nil }()
var sawTool bool
a := New(
Name("conformance-partial-text-tool"),
Provider("fake"),
WithRegistry(registry.NewMemoryRegistry()),
WithStore(store.NewMemoryStore()),
WithMemory(NewInMemory(4)),
WithTool("conformance_echo", "Echo a conformance value.", map[string]any{
"value": map[string]any{"type": "string"},
}, func(ctx context.Context, input map[string]any) (string, error) {
sawTool = true
if input["value"] != "agent-conformance" {
return "", fmt.Errorf("unexpected value %v", input["value"])
}
return `{"marker":"agent-conformance-ok"}`, nil
}),
)
resp, err := a.Ask(context.Background(), "Run the partial text tool call fallback.")
if err != nil {
t.Fatalf("Ask: %v", err)
}
if attempts != 2 {
t.Fatalf("attempts = %d, want repair retry", attempts)
}
if !sawTool {
t.Fatal("repaired text tool call fallback did not execute the tool")
}
if len(resp.ToolCalls) != 1 || resp.ToolCalls[0].Name != "conformance_echo" {
t.Fatalf("ToolCalls = %+v, want conformance_echo", resp.ToolCalls)
}
if !strings.Contains(resp.Reply, "agent-conformance-ok") {
t.Fatalf("Reply = %q, want tool result marker", resp.Reply)
}
}
func TestAgentExecutesTextToolCallFallbackAfterStructuredToolCall(t *testing.T) {
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
if opts.ToolHandler == nil {
-2
View File
@@ -68,8 +68,6 @@ func init() {
_ = m.Init(opts...)
return m
})
ai.RegisterStream("fake")
ai.RegisterToolStream("fake")
}
// fakeClient embeds the default client (so NewRequest works) and
+2 -3
View File
@@ -374,14 +374,13 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
return res
}
spanAttrs := appendRunInfoAttributes([]attribute.KeyValue{
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(
attribute.String(AttrRunID, info.RunID),
attribute.String(AttrParentRunID, info.ParentID),
attribute.String(AttrAgentName, info.Agent),
attribute.String(AttrToolName, call.Name),
attribute.Bool(AttrDelegate, call.Name == toolDelegate),
}, info)
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(spanAttrs...))
))
res := next(ctx, call)
dur := time.Since(start).Milliseconds()
attrs := []attribute.KeyValue{attribute.Int64(AttrLatencyMS, dur)}
+2 -43
View File
@@ -103,10 +103,8 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
t.Fatalf("model span missing model event: %#v", s.Events())
}
}
if s.Name() == spanNameToolCall {
if !spanEventHasRunInfo(s.Events(), "agent.tool", runID, "runner") {
t.Fatalf("tool span missing tool event: %#v", s.Events())
}
if s.Name() == spanNameToolCall && !spanEventHasRunInfo(s.Events(), "agent.tool", runID, "runner") {
t.Fatalf("tool span missing tool event: %#v", s.Events())
}
}
keys, err := store.Scope(st, "agent", "runner").List(store.ListPrefix("runs/"))
@@ -144,45 +142,6 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
}
}
func TestAgentOpenTelemetryToolSpanIncludesWorkflowRunInfo(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
st := store.NewMemoryStore()
a := New(Name("workflow-tool"), Provider("oteltest"), WithStore(st), TraceProvider(tp)).(*agentImpl)
handler := a.traceTool(func(context.Context, ai.ToolCall) ai.ToolResult {
return ai.ToolResult{Value: "ok"}
})
ctx := ai.WithRunInfo(context.Background(), ai.RunInfo{
RunID: "run-workflow-tool",
ParentID: "parent-run",
Agent: "workflow-tool",
Flow: "deploy",
Step: "notify",
Dispatch: "workflow",
Trigger: "manual",
})
res := handler(ctx, ai.ToolCall{ID: "call-1", Name: "notify", Input: map[string]any{"ok": true}})
if resultError(res) != "" {
t.Fatalf("tool returned error: %#v", res)
}
for _, span := range exp.GetSpans().Snapshots() {
if span.Name() != spanNameToolCall {
continue
}
attrs := spanAttributes(span.Attributes())
if attrs[AttrRunID] != "run-workflow-tool" || attrs[AttrParentRunID] != "parent-run" || attrs[AttrAgentName] != "workflow-tool" {
t.Fatalf("tool span missing run lineage: %#v", attrs)
}
if attrs[AttrFlowName] != "deploy" || attrs[AttrFlowStep] != "notify" || attrs[AttrDispatch] != "workflow" || attrs[AttrTrigger] != "manual" {
t.Fatalf("tool span missing workflow run info: %#v", attrs)
}
return
}
t.Fatalf("tool span not emitted; got %d spans", len(exp.GetSpans().Snapshots()))
}
func TestAgentOpenTelemetryToolRetryAttempts(t *testing.T) {
exp := tracetest.NewInMemoryExporter()
tp := trace.NewTracerProvider(trace.WithSyncer(exp))
-49
View File
@@ -240,55 +240,6 @@ func TestAskCancellationDuringToolCallFailsRun(t *testing.T) {
}
}
func TestSlowProviderTimeoutPreventsLateToolSideEffects(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
done := make(chan struct{})
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
close(started)
<-release
defer close(done)
if opts.ToolHandler == nil {
t.Fatal("missing tool handler")
}
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "late-1", Name: "external.create", Input: map[string]any{"title": "too late"}})
if !strings.Contains(res.Content, context.DeadlineExceeded.Error()) {
t.Errorf("late tool result = %q, want deadline exceeded", res.Content)
}
return &ai.Response{Reply: "late", ToolCalls: []ai.ToolCall{{ID: "late-1", Name: "external.create", Input: map[string]any{"title": "too late"}, Result: res.Content}}}, nil
}
defer func() { fakeGen = nil }()
toolRuns := 0
a := newTestAgent(
Name("slow-provider-late-tool"),
ModelCallTimeout(10*time.Millisecond),
WithTool("external.create", "create once", nil, func(context.Context, map[string]any) (string, error) {
toolRuns++
return "created", nil
}),
)
_, err := a.Ask(context.Background(), "provider times out before tool")
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("Ask error = %v, want deadline exceeded", err)
}
select {
case <-started:
default:
t.Fatal("provider was not called")
}
close(release)
select {
case <-done:
case <-time.After(time.Second):
t.Fatal("late provider call did not finish")
}
if toolRuns != 0 {
t.Fatalf("late tool executions = %d, want 0", toolRuns)
}
}
func TestAskCheckpointRecordsTerminalOperationalFailureStatus(t *testing.T) {
tests := []struct {
name string
-23
View File
@@ -243,29 +243,6 @@ func TestResumeStreamAskDoesNotReplayCompletedTool(t *testing.T) {
}
}
func TestAgentStreamDoesNotRecordUserWhenProviderStreamingUnsupported(t *testing.T) {
fakeStream = func(ctx context.Context, opts ai.Options, req *ai.Request) (ai.Stream, error) {
if len(req.Messages) == 0 || req.Messages[len(req.Messages)-1].Role != "user" || req.Messages[len(req.Messages)-1].Content != "stream fallback" {
t.Fatalf("stream request messages = %+v, want pending user message", req.Messages)
}
return nil, ai.ErrStreamingUnsupported
}
defer func() { fakeStream = nil }()
mem := NewInMemory(8)
a := newTestAgent(Name("stream-fallback"), WithMemory(mem), WithTool("echo", "echo text", nil, func(context.Context, map[string]any) (string, error) {
return "ok", nil
}))
_, err := a.Stream(context.Background(), "stream fallback")
if !errors.Is(err, ai.ErrStreamingUnsupported) {
t.Fatalf("Stream error = %v, want ErrStreamingUnsupported", err)
}
if got := mem.Messages(); len(got) != 0 {
t.Fatalf("memory after unsupported stream = %+v, want no recorded messages", got)
}
}
type unsupportedAgent struct{}
func (unsupportedAgent) Name() string { return "unsupported" }
+18 -197
View File
@@ -4,7 +4,6 @@ import (
"context"
"encoding/json"
"fmt"
"html"
"regexp"
"strings"
@@ -13,17 +12,14 @@ import (
var fencedJSONBlock = regexp.MustCompile("(?s)```(?:json)?\\s*(.*?)\\s*```")
var taggedToolCallBlock = regexp.MustCompile(`(?s)<[^<>]*(?:tool_call|tool_calls|function=)[^<>]*>(.*?)</[^<>]*>`)
var singleTaggedToolCall = regexp.MustCompile(`(?s)<(tool_call\b[^<>]*|[^<>]*function\s*=[^<>]*)>(.*?)</[^<>]*>`)
var taggedToolNameAttr = regexp.MustCompile(`(?i)(?:function|name|tool)\s*=\s*["\']?([^"\'\s>]+)`)
var openingTaggedToolCall = regexp.MustCompile(`(?i)<(tool_call\b[^<>]*|[^<>]*function\s*=[^<>]*)>`)
var singleTaggedToolCall = regexp.MustCompile(`(?s)<(tool_call\b[^<>]*|[^<>]*function=[^<>]*)>(.*?)</[^<>]*>`)
type textToolCall struct {
ID string `json:"id"`
Name string `json:"name"`
Tool string `json:"tool"`
Input map[string]any `json:"input"`
Arguments any `json:"arguments"`
Function *textToolCall `json:"function"`
Arguments map[string]any `json:"arguments"`
}
// executeTextToolCalls is a compatibility fallback for providers that return a
@@ -94,7 +90,6 @@ func textToolCallKey(call ai.ToolCall) string {
}
func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
text = html.UnescapeString(text)
allowed := textToolNames(tools)
if len(allowed) == 0 {
return nil
@@ -103,9 +98,6 @@ func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
if calls := decodeTaggedTextToolCalls(text, allowed); len(calls) > 0 {
return calls
}
if calls := decodeFunctionTextToolCalls(text, allowed); len(calls) > 0 {
return calls
}
for _, candidate := range jsonCandidates(text) {
if calls := decodeTextToolCalls(candidate, allowed); len(calls) > 0 {
return calls
@@ -114,37 +106,6 @@ func parseTextToolCalls(text string, tools []ai.Tool) []ai.ToolCall {
return nil
}
func partialTextToolCallName(text string, tools []ai.Tool) string {
text = html.UnescapeString(text)
allowed := textToolNames(tools)
if len(allowed) == 0 {
return ""
}
openMatches := openingTaggedToolCall.FindAllStringSubmatchIndex(text, -1)
if len(openMatches) == 0 {
return ""
}
closedMatches := singleTaggedToolCall.FindAllStringSubmatchIndex(text, -1)
for _, open := range openMatches {
closed := false
for _, match := range closedMatches {
if match[0] == open[0] {
closed = true
break
}
}
if closed {
continue
}
tag := text[open[2]:open[3]]
name := taggedToolName(tag)
if canonical := allowed[name]; canonical != "" {
return canonical
}
}
return ""
}
func textToolNames(tools []ai.Tool) map[string]string {
allowed := map[string]string{}
for _, tool := range tools {
@@ -215,7 +176,14 @@ func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
return collectTextToolCalls(nested, allowed)
}
call := mapToTextToolCall(x)
name, input := textToolCallNameAndInput(call)
name := call.Name
if name == "" {
name = call.Tool
}
input := call.Input
if input == nil {
input = call.Arguments
}
if name == "" || allowed[name] == "" || input == nil {
return nil
}
@@ -229,40 +197,6 @@ func collectTextToolCalls(v any, allowed map[string]string) []ai.ToolCall {
}
}
func textToolCallNameAndInput(call textToolCall) (string, map[string]any) {
name := call.Name
if name == "" {
name = call.Tool
}
input := call.Input
if input == nil {
input = textToolArguments(call.Arguments)
}
if call.Function != nil {
fnName, fnInput := textToolCallNameAndInput(*call.Function)
if name == "" {
name = fnName
}
if input == nil {
input = fnInput
}
}
return name, input
}
func textToolArguments(raw any) map[string]any {
switch args := raw.(type) {
case map[string]any:
return args
case string:
var input map[string]any
if err := json.Unmarshal([]byte(args), &input); err == nil {
return input
}
}
return nil
}
func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
var out []ai.ToolCall
for _, match := range singleTaggedToolCall.FindAllStringSubmatch(text, -1) {
@@ -296,130 +230,17 @@ func decodeTaggedTextToolCalls(text string, allowed map[string]string) []ai.Tool
}
func taggedToolName(tag string) string {
match := taggedToolNameAttr.FindStringSubmatch(tag)
if len(match) < 2 {
return ""
}
return strings.Trim(match[1], `"'`)
}
func decodeFunctionTextToolCalls(text string, allowed map[string]string) []ai.ToolCall {
var out []ai.ToolCall
for alias, canonical := range allowed {
for _, body := range functionCallBodies(text, alias) {
var input map[string]any
if err := json.Unmarshal([]byte(body), &input); err != nil || input == nil {
continue
for _, marker := range []string{"function=", "name=", "tool="} {
if idx := strings.Index(tag, marker); idx >= 0 {
name := strings.TrimSpace(tag[idx+len(marker):])
name = strings.Trim(name, `"'`)
if end := strings.IndexAny(name, " \t\r\n>"); end >= 0 {
name = name[:end]
}
out = append(out, ai.ToolCall{
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(alias, ".", "_")),
Name: canonical,
Input: input,
})
return strings.Trim(name, `"'`)
}
}
return out
}
func functionCallBodies(text, name string) []string {
if name == "" {
return nil
}
var bodies []string
for searchFrom := 0; searchFrom < len(text); {
idx := strings.Index(text[searchFrom:], name)
if idx < 0 {
break
}
start := searchFrom + idx
open := start + len(name)
if !isFunctionCallBoundary(text, start, open) {
searchFrom = start + len(name)
continue
}
bodyStart := open + 1
bodyEnd, ok := balancedJSONObjectEnd(text, bodyStart)
if !ok {
searchFrom = bodyStart
continue
}
bodies = append(bodies, strings.TrimSpace(text[bodyStart:bodyEnd]))
searchFrom = bodyEnd + 1
}
return bodies
}
func isFunctionCallBoundary(text string, start, open int) bool {
if open >= len(text) || text[open] != '(' {
return false
}
if start > 0 {
prev := text[start-1]
if prev == '_' || prev == '.' || prev == '-' || prev == '$' || ('0' <= prev && prev <= '9') || ('A' <= prev && prev <= 'Z') || ('a' <= prev && prev <= 'z') {
return false
}
}
for i := open + 1; i < len(text); i++ {
switch text[i] {
case ' ', '\n', '\r', '\t':
continue
case '{':
return true
default:
return false
}
}
return false
}
func balancedJSONObjectEnd(text string, start int) (int, bool) {
for start < len(text) {
switch text[start] {
case ' ', '\n', '\r', '\t':
start++
case '{':
depth := 0
inString := false
escaped := false
for i := start; i < len(text); i++ {
c := text[i]
if inString {
if escaped {
escaped = false
} else if c == '\\' {
escaped = true
} else if c == '"' {
inString = false
}
continue
}
switch c {
case '"':
inString = true
case '{':
depth++
case '}':
depth--
if depth == 0 {
for j := i + 1; j < len(text); j++ {
switch text[j] {
case ' ', '\n', '\r', '\t':
continue
case ')':
return i + 1, true
default:
return 0, false
}
}
}
}
}
return 0, false
default:
return 0, false
}
}
return 0, false
return ""
}
func firstNestedToolCalls(m map[string]any) (any, bool) {
-90
View File
@@ -56,93 +56,3 @@ func TestParseTextToolCallsCreateAliasForAddTool(t *testing.T) {
t.Fatalf("title = %v, want Design", got)
}
}
func TestParseTextToolCallsOpenAICompatibleFunctionArgumentsString(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `<tool_call>{"id":"call-2","type":"function","function":{"name":"delegate","arguments":"{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}"}}</tool_call>`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if calls[0].Name != "delegate" {
t.Fatalf("call name = %q, want delegate", calls[0].Name)
}
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
t.Fatalf("task = %v, want summarize the conformance marker", got)
}
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
t.Fatalf("to = %v, want blocked-reviewer", got)
}
}
func TestParseTextToolCallsTaggedMarkupWithSpacedNameAttribute(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `<tool_call name = "delegate">{"task":"summarize the conformance marker","to":"blocked-reviewer"}</tool_call>`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if calls[0].Name != "delegate" {
t.Fatalf("call name = %q, want delegate", calls[0].Name)
}
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
t.Fatalf("to = %v, want blocked-reviewer", got)
}
}
func TestParseTextToolCallsHTMLEscapedTaggedMarkup(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `&lt;tool_call name=&quot;delegate&quot;&gt;{"task":"summarize the conformance marker","to":"blocked-reviewer"}&lt;/tool_call&gt;`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if calls[0].Name != "delegate" {
t.Fatalf("call name = %q, want delegate", calls[0].Name)
}
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
t.Fatalf("task = %v, want summarize the conformance marker", got)
}
}
func TestParseTextToolCallsFunctionCallSyntax(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `I will now call delegate({"task":"summarize the conformance marker","to":"blocked-reviewer"}) before answering.`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if calls[0].Name != "delegate" {
t.Fatalf("call name = %q, want delegate", calls[0].Name)
}
if got := calls[0].Input["task"]; got != "summarize the conformance marker" {
t.Fatalf("task = %v, want summarize the conformance marker", got)
}
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
t.Fatalf("to = %v, want blocked-reviewer", got)
}
}
func TestParseTextToolCallsFunctionCallSyntaxHandlesNestedJSON(t *testing.T) {
tools := []ai.Tool{{Name: "delegate"}}
reply := `delegate({
"task":"summarize the {escaped} marker",
"meta":{"note":"paren ) and brace } in string"},
"to":"blocked-reviewer"
})`
calls := parseTextToolCalls(reply, tools)
if len(calls) != 1 {
t.Fatalf("parseTextToolCalls returned %d calls, want 1: %+v", len(calls), calls)
}
if got := calls[0].Input["task"]; got != "summarize the {escaped} marker" {
t.Fatalf("task = %v, want nested JSON-safe task", got)
}
if got := calls[0].Input["to"]; got != "blocked-reviewer" {
t.Fatalf("to = %v, want blocked-reviewer", got)
}
}
+1 -2
View File
@@ -19,7 +19,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("anthropic")
ai.RegisterToolStream("anthropic")
}
// Provider implements the ai.Model interface for Anthropic Claude
@@ -298,7 +297,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
// Read response
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
// Parse response
+64 -303
View File
@@ -96,7 +96,6 @@ func (p *Provider) String() string { return "atlascloud" }
func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.GenerateOption) (*ai.Response, error) {
tools := atlascloudTools(req.Tools)
compatTools, compatPrompt := atlascloudMinimaxCompatTools(p.opts.Model, req.Tools)
textToolPrompt := atlascloudMinimaxTextToolPrompt(p.opts.Model, req.Tools)
messages := []map[string]any{
{"role": "system", "content": req.SystemPrompt},
@@ -129,147 +128,92 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
apiReq["tools"] = compatTools
resp, rawMessage, err = p.callAPI(ctx, "chat-minimax-compat", apiReq)
}
if atlascloudShouldRetryMinimaxTextTools(err, textToolPrompt) {
delete(apiReq, "tools")
apiReq["messages"] = append(messages, map[string]any{"role": "system", "content": textToolPrompt})
resp, rawMessage, err = p.callAPI(ctx, "chat-minimax-text-tools", apiReq)
}
if err != nil {
return nil, err
}
}
if toolName := atlascloudPartialTextToolCallName(resp.Reply, req.Tools); toolName != "" {
repairReq := map[string]any{
"model": p.opts.Model,
"messages": append(append([]map[string]any(nil), messages...),
map[string]any{"role": "assistant", "content": resp.Reply},
map[string]any{"role": "user", "content": fmt.Sprintf("Your previous response started a %q tool call but did not finish valid tool-call markup or JSON arguments, so no tool was executed. Retry the same step now by emitting one complete valid tool call for %q. Do not describe the action in prose, and do not claim completion until the tool call succeeds.", toolName, toolName)},
),
}
if p.opts.MaxTokens > 0 {
repairReq["max_tokens"] = p.opts.MaxTokens
}
if len(tools) > 0 {
repairReq["tools"] = tools
}
resp, rawMessage, err = p.callAPI(ctx, "chat-partial-tool-repair", repairReq)
if err != nil {
return nil, fmt.Errorf("atlascloud partial text tool-call repair failed for %q: %w", toolName, err)
}
if atlascloudPartialTextToolCallName(resp.Reply, req.Tools) != "" && len(resp.ToolCalls) == 0 {
fallback := atlascloudFallbackTextToolCall(toolName, req)
if fallback == "" {
return nil, fmt.Errorf("atlascloud returned incomplete text tool call for %q after repair", toolName)
}
resp.Reply = fallback
}
}
if len(resp.ToolCalls) == 0 {
return resp, nil
}
if p.opts.ToolHandler != nil {
var allToolCalls []ai.ToolCall
allToolCalls := append([]ai.ToolCall(nil), resp.ToolCalls...)
var toolResults []string
pendingToolCalls := append([]ai.ToolCall(nil), resp.ToolCalls...)
followUpMessages := append(messages, map[string]any{
"role": "assistant",
"content": rawMessage["content"],
"tool_calls": rawMessage["tool_calls"],
})
for attempt := 0; len(pendingToolCalls) > 0 && attempt < 4; attempt++ {
for _, tc := range pendingToolCalls {
result := p.opts.ToolHandler(ctx, tc)
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,
"content": content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": followUpMessages,
}
if len(tools) > 0 {
// Keep the tool schema available during the follow-up turn. Minimax
// models behind Atlas Cloud sometimes call one required tool, inspect
// that result, and then issue a second tool call (for example a guarded
// delegate conformance check) instead of completing immediately.
followUpReq["tools"] = tools
}
followUpResp, _, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
if err != nil {
if atlascloudShouldRetryWithoutTools(err, followUpReq) {
delete(followUpReq, "tools")
followUpResp, _, err = p.callAPI(ctx, "tool-follow-up-no-tools", followUpReq)
}
if err != nil {
return nil, err
}
}
if len(followUpResp.ToolCalls) > 0 {
for i := range followUpResp.ToolCalls {
result := p.opts.ToolHandler(ctx, followUpResp.ToolCalls[i])
if result.Refused != "" {
tc.Error = result.Refused
followUpResp.ToolCalls[i].Error = result.Refused
}
if result.Content != "" {
tc.Result = result.Content
followUpResp.ToolCalls[i].Result = result.Content
toolResults = append(toolResults, result.Content)
}
allToolCalls = append(allToolCalls, tc)
resp.ToolCalls = allToolCalls
followUpMessages = append(followUpMessages, map[string]any{
"role": "tool",
"tool_call_id": tc.ID,
"content": result.Content,
})
}
followUpReq := map[string]any{
"model": p.opts.Model,
"messages": followUpMessages,
allToolCalls = append(allToolCalls, followUpResp.ToolCalls...)
resp.ToolCalls = allToolCalls
}
if followUpResp.Reply != "" {
if strings.Contains(followUpResp.Reply, "<tool_call") || strings.Contains(followUpResp.Reply, "function=") {
// Preserve follow-up assistant content as Reply, not Answer, when
// it may contain a text-encoded tool call. The agent harness
// inspects Reply for text fallback calls after Generate returns,
// which covers AtlasCloud/minimax turns that emit a second
// required call (for example guarded delegate) as markup instead
// of native tool_calls.
resp.Reply = followUpResp.Reply
} else {
resp.Answer = followUpResp.Reply
}
if len(tools) > 0 {
// Keep the tool schema available during follow-up turns. Minimax
// models behind Atlas Cloud sometimes complete a multi-tool task
// one call at a time (plan, then service tools, then delegate).
followUpReq["tools"] = tools
}
followUpResp, followUpRawMessage, err := p.callAPI(ctx, "tool-follow-up", followUpReq)
if err != nil {
if atlascloudShouldRetryWithoutTools(err, followUpReq) {
delete(followUpReq, "tools")
followUpReq["messages"] = atlascloudFollowUpMessagesWithoutTools(p.opts.Model, followUpMessages)
followUpResp, followUpRawMessage, err = p.callAPI(ctx, "tool-follow-up-no-tools", followUpReq)
}
if err != nil {
return nil, err
}
}
if len(followUpResp.ToolCalls) == 0 {
if followUpResp.Reply != "" {
if strings.Contains(followUpResp.Reply, "<tool_call") || strings.Contains(followUpResp.Reply, "function=") {
// Preserve follow-up assistant content as Reply, not Answer, when
// it may contain a text-encoded tool call. The agent harness
// inspects Reply for text fallback calls after Generate returns.
resp.Reply = followUpResp.Reply
} else {
resp.Answer = atlascloudAnswerWithRequiredToolMarkers(followUpResp.Reply, toolResults, allToolCalls)
}
} else if len(toolResults) > 0 {
resp.Answer = strings.Join(toolResults, "\n")
}
break
}
followUpMessages = append(followUpMessages, map[string]any{
"role": "assistant",
"content": followUpRawMessage["content"],
"tool_calls": followUpRawMessage["tool_calls"],
})
pendingToolCalls = followUpResp.ToolCalls
} else if len(toolResults) > 0 {
resp.Answer = strings.Join(toolResults, "\n")
}
}
return resp, nil
}
func atlascloudAnswerWithRequiredToolMarkers(answer string, toolResults []string, toolCalls []ai.ToolCall) string {
if strings.Contains(answer, "agent-conformance") || !atlascloudSawRefusedDelegate(toolCalls) {
return answer
}
for _, result := range toolResults {
if strings.Contains(result, "agent-conformance") {
return strings.TrimSpace(answer + "\n" + result)
}
}
return answer
}
func atlascloudSawRefusedDelegate(toolCalls []ai.ToolCall) bool {
for _, call := range toolCalls {
if call.Name == "delegate" && call.Error != "" {
return true
}
}
return false
}
// 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) {
@@ -382,32 +326,17 @@ func (s *atlasStream) Close() error {
}
type atlascloudAPIError struct {
Status string
Code int
Retry time.Duration
Phase string
Summary string
Body string
Status string
StatusCode int
Phase string
Summary string
Body string
}
func (e *atlascloudAPIError) Error() string {
return fmt.Sprintf("API error (%s) during atlascloud %s request (%s): %s", e.Status, e.Phase, e.Summary, e.Body)
}
func (e *atlascloudAPIError) StatusCode() int {
if e == nil {
return 0
}
return e.Code
}
func (e *atlascloudAPIError) RetryAfter() time.Duration {
if e == nil {
return 0
}
return e.Retry
}
func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any) (*ai.Response, map[string]any, error) {
reqBody, err := json.Marshal(req)
if err != nil {
@@ -431,12 +360,7 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
retryAfter := time.Duration(0)
var retryErr interface{ RetryAfter() time.Duration }
if errors.As(ai.NewHTTPError(httpResp, respBody), &retryErr) {
retryAfter = retryErr.RetryAfter()
}
return nil, nil, &atlascloudAPIError{Status: httpResp.Status, Code: httpResp.StatusCode, Retry: retryAfter, Phase: phase, Summary: atlascloudRequestSummary(req), Body: string(respBody)}
return nil, nil, &atlascloudAPIError{Status: httpResp.Status, StatusCode: httpResp.StatusCode, Phase: phase, Summary: atlascloudRequestSummary(req), Body: string(respBody)}
}
var chatResp struct {
@@ -481,143 +405,6 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
return response, rawMessage, nil
}
func atlascloudFollowUpMessagesWithoutTools(model string, messages []map[string]any) []map[string]any {
if !atlascloudIsMinimaxModel(model) {
return messages
}
out := make([]map[string]any, 0, len(messages)+1)
for _, msg := range messages {
role, _ := msg["role"].(string)
switch role {
case "assistant":
converted := map[string]any{"role": "assistant"}
if content, _ := msg["content"].(string); content != "" {
converted["content"] = content
} else if calls, ok := msg["tool_calls"]; ok {
converted["content"] = "Tool call requested: " + atlascloudToolCallsText(calls)
} else {
converted["content"] = ""
}
out = append(out, converted)
case "tool":
toolID, _ := msg["tool_call_id"].(string)
content, _ := msg["content"].(string)
if toolID != "" {
content = "Tool result for " + toolID + ": " + content
} else {
content = "Tool result: " + content
}
out = append(out, map[string]any{"role": "user", "content": content})
default:
copyMsg := make(map[string]any, len(msg))
for k, v := range msg {
copyMsg[k] = v
}
out = append(out, copyMsg)
}
}
return out
}
func atlascloudToolCallsText(calls any) string {
b, err := json.Marshal(calls)
if err != nil {
return fmt.Sprint(calls)
}
return string(b)
}
func atlascloudPartialTextToolCallName(text string, tools []ai.Tool) string {
if !strings.Contains(text, "<tool_call") {
return ""
}
if strings.Contains(text, "</tool_call>") {
return ""
}
for _, tool := range tools {
for _, name := range []string{tool.Name, tool.OriginalName} {
if name == "" {
continue
}
if strings.Contains(text, `name="`+name+`"`) || strings.Contains(text, `name='`+name+`'`) {
return tool.Name
}
}
}
return ""
}
func atlascloudFallbackTextToolCall(toolName string, req *ai.Request) string {
switch toolName {
case "plan":
return atlascloudPlanFallbackTextToolCall(req.Prompt)
case "delegate":
return atlascloudDelegateFallbackTextToolCall(req)
default:
return ""
}
}
func atlascloudPlanFallbackTextToolCall(prompt string) string {
task := strings.TrimSpace(prompt)
if task == "" {
task = "continue the requested work"
}
args, err := json.Marshal(map[string]any{
"steps": []map[string]string{{
"task": task,
"status": "pending",
}},
})
if err != nil {
return `<tool_call name="plan">{"steps":[{"task":"continue the requested work","status":"pending"}]}</tool_call>`
}
return `<tool_call name="plan">` + string(args) + `</tool_call>`
}
func atlascloudDelegateFallbackTextToolCall(req *ai.Request) string {
ctxText := atlascloudRequestText(req)
task := strings.TrimSpace(req.Prompt)
if task == "" {
task = strings.TrimSpace(ctxText)
}
if task == "" {
task = "continue the requested delegated work"
}
args := map[string]any{"task": task}
if strings.Contains(strings.ToLower(ctxText), "comms") {
args["to"] = "comms"
}
b, err := json.Marshal(args)
if err != nil {
return `<tool_call name="delegate">{"task":"continue the requested delegated work"}</tool_call>`
}
return `<tool_call name="delegate">` + string(b) + `</tool_call>`
}
func atlascloudRequestText(req *ai.Request) string {
if req == nil {
return ""
}
var parts []string
if req.SystemPrompt != "" {
parts = append(parts, req.SystemPrompt)
}
for _, msg := range req.Messages {
switch c := msg.Content.(type) {
case string:
parts = append(parts, c)
default:
parts = append(parts, fmt.Sprint(c))
}
}
if req.Prompt != "" {
parts = append(parts, req.Prompt)
}
return strings.Join(parts, "\n")
}
func atlascloudMinimaxCompatTools(model string, input []ai.Tool) ([]map[string]any, string) {
if !atlascloudIsMinimaxModel(model) || len(input) == 0 {
return nil, ""
@@ -641,32 +428,6 @@ func atlascloudMinimaxCompatTools(model string, input []ai.Tool) ([]map[string]a
return atlascloudTools(native), prompt
}
func atlascloudMinimaxTextToolPrompt(model string, input []ai.Tool) string {
if !atlascloudIsMinimaxModel(model) || len(input) == 0 {
return ""
}
names := make([]string, 0, len(input))
for _, tool := range input {
if tool.Name != "" {
names = append(names, tool.Name)
}
}
if len(names) == 0 {
return ""
}
return "AtlasCloud/minimax text-tool compatibility: the native tools payload was rejected. " +
"Call exactly one needed tool from this list by emitting exactly <tool_call name=\"tool_name\">{...}</tool_call>: " +
strings.Join(names, ", ") + ". Do not answer in prose instead of emitting the tag."
}
func atlascloudShouldRetryMinimaxTextTools(err error, prompt string) bool {
if prompt == "" {
return false
}
var apiErr *atlascloudAPIError
return errors.As(err, &apiErr) && apiErr.StatusCode() == http.StatusBadRequest
}
func atlascloudIsMinimaxModel(model string) bool {
model = strings.ToLower(model)
return strings.Contains(model, "minimax")
@@ -677,7 +438,7 @@ func atlascloudShouldRetryMinimaxCompat(err error, compatTools []map[string]any)
return false
}
var apiErr *atlascloudAPIError
return errors.As(err, &apiErr) && apiErr.StatusCode() == http.StatusBadRequest
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest
}
func atlascloudShouldRetryWithoutTools(err error, req map[string]any) bool {
@@ -685,7 +446,7 @@ func atlascloudShouldRetryWithoutTools(err error, req map[string]any) bool {
return false
}
var apiErr *atlascloudAPIError
return errors.As(err, &apiErr) && apiErr.StatusCode() == http.StatusBadRequest
return errors.As(err, &apiErr) && apiErr.StatusCode == http.StatusBadRequest
}
func atlascloudTools(input []ai.Tool) []map[string]any {
+2 -243
View File
@@ -355,8 +355,6 @@ func TestProvider_GenerateExecutesFollowUpToolCall(t *testing.T) {
_, _ = 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":"","tool_calls":[{"id":"call-2","function":{"name":"delegate","arguments":"{\"task\":\"summarize the conformance marker\",\"to\":\"blocked-reviewer\"}"}}]}}]}`))
case 3:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"blocked by policy"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
@@ -403,78 +401,11 @@ func TestProvider_GenerateExecutesFollowUpToolCall(t *testing.T) {
if !strings.Contains(resp.Answer, "blocked by policy") {
t.Fatalf("Answer = %q, want follow-up tool result", resp.Answer)
}
if !strings.Contains(resp.Answer, "agent-conformance-ok") {
t.Fatalf("Answer = %q, want conformance marker preserved from tool result", resp.Answer)
}
if _, ok := bodies[1]["tools"].([]any); !ok {
t.Fatalf("follow-up request did not include tools: %#v", bodies[1])
}
}
func TestProvider_GenerateExecutesMultiStepFollowUpToolCalls(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-plan","function":{"name":"plan","arguments":"{\"steps\":[{\"task\":\"create tasks\"},{\"task\":\"notify owner\"}]}"}}]}}]}`))
case 2:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-add","function":{"name":"task_TaskService_Add","arguments":"{\"title\":\"Design\"}"}}]}}]}`))
case 3:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"","tool_calls":[{"id":"call-delegate","function":{"name":"delegate","arguments":"{\"task\":\"notify owner@acme.com\",\"to\":\"comms\"}"}}]}}]}`))
case 4:
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
}
}))
defer ts.Close()
var calls []string
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithToolHandler(func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
calls = append(calls, call.Name)
return ai.ToolResult{ID: call.ID, Content: `{"ok":true}`}
}),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "plan, create tasks, and delegate notification",
Tools: []ai.Tool{
{Name: "plan", Description: "record a plan", Properties: map[string]any{"steps": map[string]any{"type": "array"}}},
{Name: "task_TaskService_Add", Description: "add task", Properties: map[string]any{"title": map[string]any{"type": "string"}}},
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}, "to": map[string]any{"type": "string"}}},
},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
wantCalls := []string{"plan", "task_TaskService_Add", "delegate"}
if strings.Join(calls, ",") != strings.Join(wantCalls, ",") {
t.Fatalf("tool calls = %v, want %v", calls, wantCalls)
}
if len(resp.ToolCalls) != 3 {
t.Fatalf("ToolCalls = %+v, want all multi-step calls", resp.ToolCalls)
}
if resp.Answer != "done" {
t.Fatalf("Answer = %q, want final follow-up reply", resp.Answer)
}
if len(bodies) != 4 {
t.Fatalf("requests = %d, want initial plus three follow-ups", len(bodies))
}
for i := 1; i < 4; i++ {
if _, ok := bodies[i]["tools"].([]any); !ok {
t.Fatalf("follow-up request %d did not include tools: %#v", i+1, bodies[i])
}
}
}
func TestProvider_GeneratePreservesFollowUpTextToolCallInReply(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -523,111 +454,6 @@ func TestProvider_GeneratePreservesFollowUpTextToolCallInReply(t *testing.T) {
}
}
func TestProvider_GenerateRepairsInitialPartialTextToolCall(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_call name=\"plan\">"}}]}`))
case 2:
messages := body["messages"].([]any)
last := messages[len(messages)-1].(map[string]any)
if last["role"] != "user" || !strings.Contains(last["content"].(string), "did not finish valid tool-call markup") {
t.Fatalf("repair prompt = %#v, want partial tool-call guidance", last)
}
if _, ok := body["tools"]; !ok {
t.Fatalf("repair request did not keep tools available: %#v", body)
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"plan\">{\"steps\":[{\"task\":\"create tasks\"}]}</tool_call>"}}]}`))
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"),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "plan and delegate",
Tools: []ai.Tool{
{Name: "plan", Description: "record a plan", Properties: map[string]any{"steps": map[string]any{"type": "array"}}},
{Name: "delegate", Description: "delegate work", Properties: map[string]any{"task": map[string]any{"type": "string"}}},
},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !strings.Contains(resp.Reply, `<tool_call name="plan">`) || !strings.Contains(resp.Reply, `</tool_call>`) {
t.Fatalf("Reply = %q, want completed text tool call", resp.Reply)
}
if len(bodies) != 2 {
t.Fatalf("requests = %d, want initial plus repair", len(bodies))
}
}
func TestProvider_GenerateFallsBackAfterRepeatedPartialPlanTextToolCall(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"plan\">"}}]}`))
}))
defer ts.Close()
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("minimaxai/minimax-m3"),
)
resp, err := p.Generate(context.Background(), &ai.Request{
Prompt: "plan and delegate",
Tools: []ai.Tool{{Name: "plan", Description: "record a plan"}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
if !strings.Contains(resp.Reply, `<tool_call name="plan">`) || !strings.Contains(resp.Reply, `</tool_call>`) {
t.Fatalf("Reply = %q, want completed fallback plan text tool call", resp.Reply)
}
if !strings.Contains(resp.Reply, "plan and delegate") {
t.Fatalf("Reply = %q, want fallback plan seeded from prompt", resp.Reply)
}
}
func TestProvider_GenerateFallsBackAfterRepeatedPartialDelegateTextToolCall(t *testing.T) {
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json")
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"delegate\">"}}]}`))
}))
defer ts.Close()
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("minimaxai/minimax-m3"),
)
resp, err := p.Generate(context.Background(), &ai.Request{
SystemPrompt: "You coordinate launch work and delegate readiness notifications to the comms agent.",
Prompt: "delegate the owner readiness notification to comms",
Tools: []ai.Tool{{Name: "delegate", Description: "delegate work"}},
})
if err != nil {
t.Fatalf("Generate returned error: %v", err)
}
for _, want := range []string{`<tool_call name="delegate">`, `"task":"delegate the owner readiness notification to comms"`, `"to":"comms"`, `</tool_call>`} {
if !strings.Contains(resp.Reply, want) {
t.Fatalf("Reply = %q, want delegate fallback containing %q", resp.Reply, want)
}
}
}
func TestProvider_GenerateRetriesMinimaxBuiltInsAsTextTools(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -686,60 +512,6 @@ func TestProvider_GenerateRetriesMinimaxBuiltInsAsTextTools(t *testing.T) {
}
}
func TestProvider_GenerateRetriesMinimaxServiceToolsAsTextTools(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:
http.Error(w, `{"code":400,"msg":"bad request"}`, http.StatusBadRequest)
case 2:
if _, ok := body["tools"]; ok {
t.Fatalf("text-tool retry included native tools: %#v", body["tools"])
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"<tool_call name=\"conformance_echo\">{\"value\":\"agent-conformance\"}</tool_call>"}}]}`))
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"))
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 !strings.Contains(resp.Reply, `<tool_call name="conformance_echo">`) {
t.Fatalf("Reply = %q, want text service-tool fallback", resp.Reply)
}
if len(bodies) != 2 {
t.Fatalf("requests = %d, want initial plus text-tool retry", len(bodies))
}
if _, ok := bodies[0]["tools"].([]any); !ok {
t.Fatalf("initial request did not include native tools: %#v", bodies[0])
}
msgs := bodies[1]["messages"].([]any)
compat := msgs[len(msgs)-1].(map[string]any)
content := compat["content"].(string)
for _, want := range []string{"native tools payload was rejected", `<tool_call name="tool_name">`, "conformance_echo"} {
if !strings.Contains(content, want) {
t.Fatalf("text-tool instruction %q missing %q", content, want)
}
}
}
func TestProvider_GenerateFollowUpRetriesWithoutToolsOnBadRequest(t *testing.T) {
var bodies []map[string]any
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
@@ -758,19 +530,6 @@ func TestProvider_GenerateFollowUpRetriesWithoutToolsOnBadRequest(t *testing.T)
if _, ok := body["tools"]; ok {
t.Fatalf("no-tools retry still included tools: %#v", body["tools"])
}
messages := body["messages"].([]any)
last := messages[len(messages)-1].(map[string]any)
if last["role"] == "tool" {
http.Error(w, `{"code":400,"msg":"trailing tool message rejected"}`, http.StatusBadRequest)
return
}
if last["role"] != "user" || !strings.Contains(last["content"].(string), "Tool result for call-1") {
t.Fatalf("no-tools retry last message = %#v, want user-visible tool result", last)
}
assistant := messages[len(messages)-2].(map[string]any)
if _, ok := assistant["tool_calls"]; ok {
t.Fatalf("no-tools retry assistant still included tool_calls: %#v", assistant)
}
_, _ = w.Write([]byte(`{"choices":[{"message":{"content":"done"}}]}`))
default:
t.Fatalf("unexpected API call %d", len(bodies))
@@ -818,7 +577,7 @@ func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T)
p := NewProvider(
ai.WithAPIKey("test-key"),
ai.WithBaseURL(ts.URL),
ai.WithModel("deepseek-ai/DeepSeek-V3-0324"),
ai.WithModel("minimaxai/minimax-m3"),
)
_, err := p.Generate(context.Background(), &ai.Request{
Prompt: "call a tool",
@@ -832,7 +591,7 @@ func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T)
t.Fatal("Generate error = nil, want 400")
}
msg := err.Error()
for _, want := range []string{"400 Bad Request", "atlascloud chat request", "model=deepseek-ai/DeepSeek-V3-0324", "tools=1", "tool_names=conformance_echo"} {
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)
}
+5 -24
View File
@@ -23,10 +23,6 @@ type Capabilities struct {
// Providers that only satisfy the Model interface with ErrStreamingUnsupported
// leave this false until their Stream implementation is usable.
Stream bool `json:"stream"`
// ToolStream reports whether the provider supports agent Stream requests that
// include tool schemas. Providers may support plain token streaming while
// leaving this false when their streaming API cannot accept tools.
ToolStream bool `json:"tool_stream"`
}
// ProviderCapabilities reports the capabilities registered for provider.
@@ -35,14 +31,12 @@ func ProviderCapabilities(provider string) Capabilities {
_, hasImage := imageProviders[provider]
_, hasVideo := videoProviders[provider]
_, hasStream := streamProviders[provider]
_, hasToolStream := toolStreamProviders[provider]
return Capabilities{
Model: hasModel,
Image: hasImage,
Video: hasVideo,
Stream: hasStream,
ToolStream: hasToolStream,
Model: hasModel,
Image: hasImage,
Video: hasVideo,
Stream: hasStream,
}
}
@@ -64,9 +58,6 @@ func CapabilityMatrix() map[string]Capabilities {
for name := range streamProviders {
names[name] = struct{}{}
}
for name := range toolStreamProviders {
names[name] = struct{}{}
}
matrix := make(map[string]Capabilities, len(names))
for name := range names {
@@ -97,18 +88,10 @@ func RegisterStream(provider string) {
streamProviders[provider] = struct{}{}
}
// RegisterToolStream records that provider can accept tool schemas in Stream
// requests. This is intentionally separate from RegisterStream because some
// providers can stream tokens but cannot expose tools while streaming.
func RegisterToolStream(provider string) {
toolStreamProviders[provider] = struct{}{}
}
var streamProviders = make(map[string]struct{})
var toolStreamProviders = make(map[string]struct{})
// RegisteredProviders returns the registered provider names in sorted order.
// kind may be "model", "image", "video", "stream", "tool_stream", or empty for the union of all
// kind may be "model", "image", "video", "stream", or empty for the union of all
// provider registries.
func RegisteredProviders(kind string) []string {
names := map[string]struct{}{}
@@ -138,8 +121,6 @@ func RegisteredProviders(kind string) []string {
add(providers)
case "stream":
add(streamProviders)
case "tool_stream":
add(toolStreamProviders)
case "image":
add(imageProviders)
case "video":
+7 -21
View File
@@ -44,14 +44,14 @@ func TestRegisteredProviders(t *testing.T) {
func TestCapabilityRows(t *testing.T) {
got := ai.CapabilityRows()
want := []ai.CapabilityRow{
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
{Provider: "anthropic", Capabilities: ai.Capabilities{Model: true, Stream: true}},
{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, ToolStream: true}},
{Provider: "minimax", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
{Provider: "mistral", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: true}},
{Provider: "openai", Capabilities: ai.Capabilities{Model: true, Image: true, Stream: true, ToolStream: true}},
{Provider: "together", Capabilities: ai.Capabilities{Model: true, Stream: true, ToolStream: 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}},
}
if !reflect.DeepEqual(got, want) {
t.Fatalf("CapabilityRows() = %#v, want %#v", got, want)
@@ -71,7 +71,7 @@ func TestCapabilityMatrix(t *testing.T) {
}
}
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true, ToolStream: true}) {
if caps := ai.ProviderCapabilities("openai"); caps != (ai.Capabilities{Model: true, Image: true, Stream: true}) {
t.Fatalf("ProviderCapabilities(openai) = %#v", caps)
}
if caps := ai.ProviderCapabilities("atlascloud"); caps != (ai.Capabilities{Model: true, Image: true, Video: true, Stream: true}) {
@@ -95,17 +95,3 @@ func TestRegisterStream(t *testing.T) {
t.Fatalf("RegisteredProviders(stream) = %#v, want %#v", got, want)
}
}
func TestRegisterToolStream(t *testing.T) {
ai.RegisterToolStream("test-tool-stream")
if caps := ai.ProviderCapabilities("test-tool-stream"); caps != (ai.Capabilities{ToolStream: true}) {
t.Fatalf("ProviderCapabilities(test-tool-stream) = %#v", caps)
}
got := ai.RegisteredProviders("tool_stream")
want := []string{"anthropic", "groq", "minimax", "mistral", "openai", "test-tool-stream", "together"}
if !reflect.DeepEqual(got, want) {
t.Fatalf("RegisteredProviders(tool_stream) = %#v, want %#v", got, want)
}
}
+1 -1
View File
@@ -163,7 +163,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var geminiResp struct {
+1 -2
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("groq")
ai.RegisterToolStream("groq")
}
type Provider struct {
@@ -148,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
+1 -2
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("minimax")
ai.RegisterToolStream("minimax")
}
type Provider struct {
@@ -148,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
+1 -2
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("mistral")
ai.RegisterToolStream("mistral")
}
type Provider struct {
@@ -148,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
-1
View File
@@ -45,7 +45,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("ollama")
ai.RegisterToolStream("ollama")
}
// Provider implements the ai.Model interface for Ollama.
+1 -2
View File
@@ -22,7 +22,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("openai")
ai.RegisterToolStream("openai")
}
// Provider implements the ai.Model interface for OpenAI
@@ -286,7 +285,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
// Read response
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
// Parse response
+1 -82
View File
@@ -4,8 +4,6 @@ import (
"context"
"errors"
"fmt"
"net/http"
"strconv"
"strings"
"time"
)
@@ -21,64 +19,6 @@ type RetryAfterCoder interface {
RetryAfter() time.Duration
}
// HTTPError describes a failed provider HTTP response while preserving the
// status code and Retry-After signal for retry classifiers.
type HTTPError struct {
Status string
Code int
Body string
Header http.Header
}
func (e *HTTPError) Error() string {
if e == nil {
return ""
}
return fmt.Sprintf("API error (%s): %s", e.Status, e.Body)
}
func (e *HTTPError) StatusCode() int {
if e == nil {
return 0
}
return e.Code
}
func (e *HTTPError) RetryAfter() time.Duration {
if e == nil {
return 0
}
return parseRetryAfter(e.Header.Get("Retry-After"), time.Now())
}
func NewHTTPError(resp *http.Response, body []byte) error {
if resp == nil {
return errors.New("API error: nil response")
}
return &HTTPError{Status: resp.Status, Code: resp.StatusCode, Body: string(body), Header: resp.Header.Clone()}
}
func parseRetryAfter(value string, now time.Time) time.Duration {
value = strings.TrimSpace(value)
if value == "" {
return 0
}
if seconds, err := strconv.Atoi(value); err == nil {
if seconds <= 0 {
return 0
}
return time.Duration(seconds) * time.Second
}
when, err := http.ParseTime(value)
if err != nil {
return 0
}
if delay := when.Sub(now); delay > 0 {
return delay
}
return 0
}
// ErrorKind classifies provider-boundary failures into stable buckets callers
// can inspect without parsing provider-specific error strings.
type ErrorKind string
@@ -157,7 +97,7 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
info.MaxAttempts = policy.MaxAttempts
callCtx = WithRunInfo(callCtx, info)
}
resp, err := generateAttempt(callCtx, m, req, opts...)
resp, err := m.Generate(callCtx, req, opts...)
cancel()
// Caller cancellation/deadline always wins and is not retried, even if
@@ -197,27 +137,6 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
return nil, &RetryError{Attempts: policy.MaxAttempts, Kind: ClassifyError(last), Err: last}
}
func generateAttempt(ctx context.Context, m Model, req *Request, opts ...GenerateOption) (*Response, error) {
if err := ctx.Err(); err != nil {
return nil, err
}
type result struct {
resp *Response
err error
}
done := make(chan result, 1)
go func() {
resp, err := m.Generate(ctx, req, opts...)
done <- result{resp: resp, err: err}
}()
select {
case res := <-done:
return res.resp, res.err
case <-ctx.Done():
return nil, ctx.Err()
}
}
func retryBackoff(err error, attempt int, base time.Duration) time.Duration {
backoff := base
if backoff <= 0 {
+4 -162
View File
@@ -3,8 +3,6 @@ package ai
import (
"context"
"errors"
"net/http"
"sync/atomic"
"testing"
"time"
)
@@ -70,9 +68,9 @@ func TestGenerateWithRetryDoesNotRetryCallerCancellation(t *testing.T) {
}
func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
var attempts atomic.Int32
attempts := 0
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
attempts.Add(1)
attempts++
<-ctx.Done()
return nil, ctx.Err()
}}
@@ -92,8 +90,8 @@ func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("error = %v, want context.DeadlineExceeded", err)
}
if got := attempts.Load(); got != 2 {
t.Fatalf("attempts = %d, want 2", got)
if attempts != 2 {
t.Fatalf("attempts = %d, want 2", attempts)
}
}
@@ -136,34 +134,6 @@ func TestGenerateWithRetryAddsAttemptMetadataToRunInfo(t *testing.T) {
}
}
func TestGenerateWithRetryReturnsWhenProviderIgnoresTimeout(t *testing.T) {
started := make(chan struct{})
release := make(chan struct{})
model := retryModel{generate: func(ctx context.Context, req *Request, opts ...GenerateOption) (*Response, error) {
close(started)
<-release
return &Response{Reply: "late"}, nil
}}
defer close(release)
start := time.Now()
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
Timeout: 10 * time.Millisecond,
MaxAttempts: 1,
})
if !errors.Is(err, context.DeadlineExceeded) {
t.Fatalf("GenerateWithRetry error = %v, want deadline exceeded", err)
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("GenerateWithRetry took %s after deadline, want prompt return", elapsed)
}
select {
case <-started:
default:
t.Fatal("provider was not called")
}
}
type statusErr int
func (e statusErr) Error() string { return "provider status" }
@@ -251,131 +221,3 @@ func TestGenerateWithRetryCapsRetryAfter(t *testing.T) {
t.Fatalf("retryBackoff() = %s, want 30s cap", got)
}
}
func TestGenerateWithRetryDoesNotRetryPermanentProviderErrors(t *testing.T) {
attempts := 0
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts++
return nil, statusErr(400)
}}
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 3,
Backoff: time.Millisecond,
})
if !errors.Is(err, statusErr(400)) {
t.Fatalf("error = %v, want original provider status", err)
}
var retryErr *RetryError
if errors.As(err, &retryErr) {
t.Fatalf("error = %T %[1]v, want permanent provider error without retry wrapper", err)
}
if attempts != 1 {
t.Fatalf("attempts = %d, want no retry for permanent provider errors", attempts)
}
}
func TestGenerateWithRetryDefaultsToSingleAttempt(t *testing.T) {
attempts := 0
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts++
return nil, errors.New("temporary provider outage")
}}
_, err := GenerateWithRetry(context.Background(), model, &Request{Prompt: "hi"}, GeneratePolicy{
Backoff: time.Millisecond,
})
var retryErr *RetryError
if !errors.As(err, &retryErr) {
t.Fatalf("error = %T %[1]v, want retry error for exhausted transient attempt", err)
}
if retryErr.Attempts != 1 {
t.Fatalf("retry attempts = %d, want default single attempt", retryErr.Attempts)
}
if attempts != 1 {
t.Fatalf("model attempts = %d, want default single attempt", attempts)
}
}
func TestGenerateWithRetryStopsDuringBackoffWhenCallerCancels(t *testing.T) {
ctx, cancel := context.WithCancel(context.Background())
defer cancel()
var attempts atomic.Int32
model := retryModel{generate: func(context.Context, *Request, ...GenerateOption) (*Response, error) {
attempts.Add(1)
return nil, statusErr(503)
}}
errc := make(chan error, 1)
go func() {
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 3,
Backoff: time.Hour,
})
errc <- err
}()
deadline := time.After(time.Second)
for attempts.Load() == 0 {
select {
case err := <-errc:
t.Fatalf("GenerateWithRetry returned before first attempt cancellation: %v", err)
case <-deadline:
t.Fatal("provider was not called")
default:
time.Sleep(time.Millisecond)
}
}
start := time.Now()
cancel()
select {
case err := <-errc:
if !errors.Is(err, context.Canceled) {
t.Fatalf("error = %v, want context.Canceled", err)
}
case <-time.After(200 * time.Millisecond):
t.Fatal("GenerateWithRetry did not stop promptly during backoff cancellation")
}
if elapsed := time.Since(start); elapsed > 200*time.Millisecond {
t.Fatalf("backoff cancellation took %s, want prompt return", elapsed)
}
if got := attempts.Load(); got != 1 {
t.Fatalf("attempts = %d, want cancellation before retry", got)
}
}
func TestRetryBackoffUsesExponentialBaseAndCap(t *testing.T) {
if got := retryBackoff(statusErr(503), 1, 10*time.Millisecond); got != 10*time.Millisecond {
t.Fatalf("attempt 1 backoff = %s, want 10ms", got)
}
if got := retryBackoff(statusErr(503), 2, 10*time.Millisecond); got != 20*time.Millisecond {
t.Fatalf("attempt 2 backoff = %s, want 20ms", got)
}
if got := retryBackoff(statusErr(503), 3, 10*time.Millisecond); got != 40*time.Millisecond {
t.Fatalf("attempt 3 backoff = %s, want 40ms", got)
}
if got := retryBackoff(statusErr(503), 20, time.Second); got != 30*time.Second {
t.Fatalf("large backoff = %s, want 30s cap", got)
}
}
func TestHTTPErrorExposesStatusAndRetryAfter(t *testing.T) {
resp := &http.Response{
Status: "429 Too Many Requests",
StatusCode: http.StatusTooManyRequests,
Header: http.Header{"Retry-After": []string{"2"}},
}
err := NewHTTPError(resp, []byte("slow down"))
if got := ClassifyError(err); got != ErrorKindRateLimited {
t.Fatalf("ClassifyError() = %q, want %q", got, ErrorKindRateLimited)
}
var retryAfter RetryAfterCoder
if !errors.As(err, &retryAfter) {
t.Fatalf("NewHTTPError does not expose RetryAfterCoder")
}
if got := retryAfter.RetryAfter(); got != 2*time.Second {
t.Fatalf("RetryAfter() = %s, want 2s", got)
}
}
+1 -2
View File
@@ -30,7 +30,6 @@ func init() {
return NewProvider(opts...)
})
ai.RegisterStream("together")
ai.RegisterToolStream("together")
}
type Provider struct {
@@ -148,7 +147,7 @@ func (p *Provider) callAPI(ctx context.Context, req map[string]any) (*ai.Respons
respBody, _ := io.ReadAll(httpResp.Body)
if httpResp.StatusCode != http.StatusOK {
return nil, nil, ai.NewHTTPError(httpResp, respBody)
return nil, nil, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
}
var chatResp struct {
+1 -2
View File
@@ -67,11 +67,10 @@ provider-free agent path:
```
micro agent demo
micro examples
micro zero-to-hero
```
Those commands point at the smallest mock-model first-agent example, the no-secret
transcript, and the 0→hero support app before you add provider-backed chat.
transcript, and the support app before you add provider-backed chat.
### Output
+18 -30
View File
@@ -74,9 +74,7 @@ a new service automatically and start using it.
Examples:
ANTHROPIC_API_KEY=sk-ant-... micro chat --provider anthropic
micro chat --provider openai --prompt "list all users"
micro chat assistant --prompt "create a task"`,
ArgsUsage: "[agent]",
micro chat --provider openai --prompt "list all users"`,
Flags: []cli.Flag{
&cli.StringFlag{Name: "provider", Usage: "AI provider (anthropic, openai, gemini, groq, mistral, together, atlascloud)", EnvVars: []string{"MICRO_AI_PROVIDER"}},
&cli.StringFlag{Name: "api_key", Usage: "API key for the provider", EnvVars: []string{"MICRO_AI_API_KEY"}},
@@ -318,7 +316,6 @@ func run(c *cli.Context) error {
baseURL := c.String("base_url")
singlePrompt := c.String("prompt")
streamOutput := c.Bool("stream")
targetAgent := c.Args().First()
if provider == "" {
provider = ai.AutoDetectProvider(baseURL)
@@ -326,36 +323,15 @@ func run(c *cli.Context) error {
if apiKey == "" {
apiKey = fallbackAPIKey(provider)
}
if apiKey == "" {
return fmt.Errorf("no API key configured; set --api_key or %s", envVarForProvider(provider))
}
reg := registry.DefaultRegistry
cl := clt.DefaultClient
tools := ai.NewTools(reg, ai.ToolClient(cl))
s := &session{
provider: provider,
apiKey: apiKey,
tools: tools,
reg: reg,
cl: cl,
hist: ai.NewHistory(50),
stream: streamOutput,
}
hasAgents := s.discoverAgents()
if targetAgent != "" {
if _, ok := s.agents[targetAgent]; !ok {
return fmt.Errorf("agent %q is not registered; run `micro agent list` to see available agents", targetAgent)
}
s.agents = map[string]agentInfo{targetAgent: s.agents[targetAgent]}
hasAgents = true
}
if targetAgent != "" && singlePrompt != "" {
return s.ask(c.Context, singlePrompt)
}
if apiKey == "" {
return fmt.Errorf("no API key configured; set --api_key or %s", envVarForProvider(provider))
}
// Built-in agent capabilities (plan, delegate), reused from the
// agent package so the direct-service fallback matches a real agent.
builtinTools, builtinHandle := agent.Builtins(
@@ -367,8 +343,17 @@ func run(c *cli.Context) error {
agent.APIKey(apiKey),
)
s.builtinTools = builtinTools
s.builtinHandle = builtinHandle
s := &session{
provider: provider,
apiKey: apiKey,
tools: tools,
reg: reg,
cl: cl,
hist: ai.NewHistory(50),
builtinTools: builtinTools,
builtinHandle: builtinHandle,
stream: streamOutput,
}
s.refreshTools()
// Wrap the tool handler to intercept generate calls
@@ -402,6 +387,9 @@ func run(c *cli.Context) error {
defer s.cleanup()
// Discover registered agents
hasAgents := s.discoverAgents()
if singlePrompt != "" {
return s.ask(c.Context, singlePrompt)
}
+7 -7
View File
@@ -31,7 +31,7 @@ func TestZeroToOneContract(t *testing.T) {
}
}
generated.assertLocalModule(t)
generated.replaceModule(t)
generated.build(t)
generated.run(t)
generated.call(t, "Alice", "Hello Alice")
@@ -52,7 +52,7 @@ func TestZeroToOneNoMCPContract(t *testing.T) {
t.Fatalf("--no-mcp generated main.go with MCP wiring:\n%s", main)
}
generated.assertLocalModule(t)
generated.replaceModule(t)
generated.build(t)
generated.run(t)
generated.call(t, "Bob", "Hello Bob")
@@ -112,7 +112,6 @@ func generateService(t *testing.T, name string, args ...string) generatedService
if err != nil {
t.Fatal(err)
}
t.Setenv("MICRO_NEW_GO_MICRO_REPLACE", repoRoot)
tmp := t.TempDir()
oldwd, err := os.Getwd()
@@ -143,7 +142,7 @@ func generateService(t *testing.T, name string, args ...string) generatedService
return generatedService{dir: filepath.Join(tmp, name), repoRoot: repoRoot}
}
func (g generatedService) assertLocalModule(t *testing.T) {
func (g generatedService) replaceModule(t *testing.T) {
t.Helper()
modPath := filepath.Join(g.dir, "go.mod")
@@ -151,9 +150,10 @@ func (g generatedService) assertLocalModule(t *testing.T) {
if err != nil {
t.Fatal(err)
}
want := "replace go-micro.dev/v6 => " + filepath.ToSlash(g.repoRoot)
if !strings.Contains(string(mod), want) {
t.Fatalf("generated go.mod missing local replace %q:\n%s", want, mod)
modText := strings.Replace(string(mod), "go-micro.dev/v6 latest", "go-micro.dev/v6 v6.0.0", 1)
modText += "\nreplace go-micro.dev/v6 => " + filepath.ToSlash(g.repoRoot) + "\n"
if err := os.WriteFile(modPath, []byte(modText), 0644); err != nil {
t.Fatal(err)
}
}
-7
View File
@@ -39,8 +39,6 @@ type config struct {
UseGoPath bool
// MicroVersion is the go-micro version to require in go.mod
MicroVersion string
// MicroReplace optionally points generated services at a local go-micro checkout.
MicroReplace string
// Files
Files []file
// Comments
@@ -71,10 +69,6 @@ func microVersion() string {
return "latest"
}
func microReplace() string {
return filepath.ToSlash(os.Getenv("MICRO_NEW_GO_MICRO_REPLACE"))
}
type file struct {
Path string
Tmpl string
@@ -221,7 +215,6 @@ func Run(ctx *cli.Context) error {
GoPath: goPath,
UseGoPath: false,
MicroVersion: microVersion(),
MicroReplace: microReplace(),
}
if useProto {
+2 -6
View File
@@ -10,9 +10,7 @@ require (
github.com/golang/protobuf latest
google.golang.org/protobuf latest
)
{{if .MicroReplace}}
replace go-micro.dev/v6 => {{.MicroReplace}}
{{end}}`
`
// ModuleNoProto is the default go.mod: no protobuf dependencies.
// MicroVersion is the version this CLI was built from (or "latest"), so a
@@ -22,7 +20,5 @@ replace go-micro.dev/v6 => {{.MicroReplace}}
go 1.23
require go-micro.dev/v6 {{.MicroVersion}}
{{if .MicroReplace}}
replace go-micro.dev/v6 => {{.MicroReplace}}
{{end}}`
`
)
+1 -123
View File
@@ -2,8 +2,6 @@ package main
import (
"bytes"
"os"
"path/filepath"
"strings"
"testing"
@@ -43,7 +41,7 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
}
chat := commandByName(t, "chat")
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") || !strings.Contains(chat.Description, `micro chat assistant --prompt`) {
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)
}
@@ -141,126 +139,6 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
}
}
func TestFirstAgentDocsMatchCLIOutput(t *testing.T) {
root := filepath.Clean(filepath.Join("..", ".."))
outputs := map[string]string{
"micro docs": commandOutput(t, commandByName(t, "docs")),
"micro examples": commandOutput(t, commandByName(t, "examples")),
"micro zero-to-hero": commandOutput(t, commandByName(t, "zero-to-hero")),
}
agent := commandByName(t, "agent")
outputs["micro agent demo"] = commandOutput(t, subcommandByName(t, agent, "demo"))
contracts := []struct {
name string
file string
markers []string
}{
{
name: "README first-agent on-ramp",
file: filepath.Join(root, "README.md"),
markers: []string{
"micro agent demo",
"micro examples",
"micro zero-to-hero",
"examples/first-agent/",
"examples/support/",
"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 first-agent on-ramp",
file: filepath.Join(root, "internal", "website", "docs", "getting-started.md"),
markers: []string{
"micro agent demo",
"micro examples",
"micro zero-to-hero",
"github.com/micro/go-micro/tree/master/examples/first-agent",
"github.com/micro/go-micro/tree/master/examples/support",
"guides/no-secret-first-agent.html",
"guides/your-first-agent.html",
"guides/debugging-agents.html",
"guides/zero-to-hero.html",
},
},
}
for _, contract := range contracts {
doc := readTestFile(t, contract.file)
for _, marker := range contract.markers {
if !strings.Contains(doc, marker) {
t.Fatalf("%s missing documented first-agent marker %q", contract.name, marker)
}
if isCLIContractMarker(marker) && !cliOutputsContain(outputs, marker) {
t.Fatalf("%s documents %q, but none of the first-agent CLI outputs mention it; keep README/website breadcrumbs aligned with micro agent demo/examples/zero-to-hero", contract.name, marker)
}
assertMaintainedFirstAgentPath(t, root, marker)
}
}
}
func commandOutput(t *testing.T, command *cli.Command) string {
t.Helper()
var out bytes.Buffer
app := cli.NewApp()
app.Writer = &out
if err := command.Action(cli.NewContext(app, nil, nil)); err != nil {
t.Fatalf("%s failed: %v", command.Name, err)
}
return out.String()
}
func cliOutputsContain(outputs map[string]string, marker string) bool {
for command, out := range outputs {
if command == marker || strings.Contains(out, marker) {
return true
}
}
return false
}
func isCLIContractMarker(marker string) bool {
return strings.HasPrefix(marker, "micro ") || strings.HasPrefix(marker, "go run ") || strings.HasPrefix(marker, "go test ") || strings.Contains(marker, ".html")
}
func assertMaintainedFirstAgentPath(t *testing.T, root, marker string) {
t.Helper()
pathChecks := map[string]string{
"go run ./examples/first-agent": "examples/first-agent",
"examples/first-agent/": "examples/first-agent",
"examples/support/": "examples/support",
"internal/website/docs/guides/no-secret-first-agent.md": "internal/website/docs/guides/no-secret-first-agent.md",
"internal/website/docs/guides/your-first-agent.md": "internal/website/docs/guides/your-first-agent.md",
"internal/website/docs/guides/debugging-agents.md": "internal/website/docs/guides/debugging-agents.md",
"internal/website/docs/guides/zero-to-hero.md": "internal/website/docs/guides/zero-to-hero.md",
"guides/no-secret-first-agent.html": "internal/website/docs/guides/no-secret-first-agent.md",
"guides/your-first-agent.html": "internal/website/docs/guides/your-first-agent.md",
"guides/debugging-agents.html": "internal/website/docs/guides/debugging-agents.md",
"guides/zero-to-hero.html": "internal/website/docs/guides/zero-to-hero.md",
"github.com/micro/go-micro/tree/master/examples/first-agent": "examples/first-agent",
"github.com/micro/go-micro/tree/master/examples/support": "examples/support",
}
path, ok := pathChecks[marker]
if !ok {
return
}
if _, err := os.Stat(filepath.Join(root, filepath.FromSlash(path))); err != nil {
t.Fatalf("documented first-agent path %q from marker %q does not resolve: %v", path, marker, err)
}
}
func readTestFile(t *testing.T, path string) string {
t.Helper()
b, err := os.ReadFile(path)
if err != nil {
t.Fatalf("read %s: %v", path, err)
}
return string(b)
}
func commandByName(t *testing.T, name string) *cli.Command {
t.Helper()
for _, command := range microcmd.DefaultCmd.App().Commands {
+7 -28
View File
@@ -1,11 +1,9 @@
name: "Loop: Release"
# Generated by `micro loop init`. Cuts the next tag when the default branch has
# new commits since the latest one, and pushes it with a PAT (<< .TokenSecret >>)
# so any tag-triggered release workflow fires. The bump reflects what shipped,
# read from the CHANGELOG [Unreleased] section: new features (Added/Changed) cut
# a MINOR; fixes/docs only cut a PATCH; breaking changes are skipped so a MAJOR
# stays a human decision.
# Generated by `micro loop init`. Cuts the next PATCH tag
# (<< .TagPrefix >>MAJOR.MINOR.PATCH+1) when the default branch has new commits
# since the latest such tag, and pushes it with a PAT (<< .TokenSecret >>) so any
# tag-triggered release workflow fires. Minor/major bumps stay with a human.
#
# The tag MUST be pushed with a PAT, not the default GITHUB_TOKEN: a tag pushed
# by GITHUB_TOKEN does not trigger other workflows (Actions blocks that recursion).
@@ -68,30 +66,11 @@ jobs:
[0-9]*.[0-9]*.[0-9]*) ;;
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
esac
# Choose the bump from what actually shipped, read from the CHANGELOG
# [Unreleased] section (kept current by the coherence role):
# new features (### Added / ### Changed) -> MINOR
# fixes/docs only -> PATCH
# breaking (### Removed / "(breaking)") -> skip; a major is a human call
UNRELEASED=""
if [ -f CHANGELOG.md ]; then
UNRELEASED=$(awk '/^## \[Unreleased\]/{f=1; next} /^## \[/{f=0} f' CHANGELOG.md)
fi
if printf '%s\n' "$UNRELEASED" | grep -qiE '^### Removed|^### Changed \(breaking\)|BREAKING'; then
echo "CHANGELOG [Unreleased] contains breaking changes — a major release is a human decision. Skipping."
exit 0
elif printf '%s\n' "$UNRELEASED" | grep -qE '^### (Added|Changed)'; then
NEXT="<< .TagPrefix >>${major}.$((minor + 1)).0"
KIND="minor (new features)"
else
NEXT="<< .TagPrefix >>${major}.${minor}.$((patch + 1))"
KIND="patch (fixes/docs only)"
fi
echo "cutting: $NEXT$KIND ($COUNT commits since $LATEST)"
NEXT="<< .TagPrefix >>${major}.${minor}.$((patch + 1))"
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
git config user.name "loop release bot"
git config user.email "noreply@users.noreply.github.com"
git tag -a "$NEXT" -m "Release $NEXT — automated $KIND ($COUNT commits since $LATEST)"
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
echo "Pushed $NEXT."
-1
View File
@@ -17,7 +17,6 @@ provider API keys you want to exercise:
| Atlas Cloud | `ATLASCLOUD_API_KEY` | `GO_MICRO_CONFORMANCE_ATLASCLOUD_MODEL` |
| Gemini | `GEMINI_API_KEY` | `GO_MICRO_CONFORMANCE_GEMINI_MODEL` |
| Groq | `GROQ_API_KEY` | `GO_MICRO_CONFORMANCE_GROQ_MODEL` |
| MiniMax | `MINIMAX_API_KEY` | `GO_MICRO_CONFORMANCE_MINIMAX_MODEL` |
| Mistral | `MISTRAL_API_KEY` | `GO_MICRO_CONFORMANCE_MISTRAL_MODEL` |
| Together | `TOGETHER_API_KEY` | `GO_MICRO_CONFORMANCE_TOGETHER_MODEL` |
+15 -58
View File
@@ -49,31 +49,19 @@ type CreateResponse struct {
}
type WorkspaceService struct {
mu sync.Mutex
n int
byOwner map[string]*Workspace
mu sync.Mutex
n int
}
// Create provisions a workspace for a new user.
// @example {"owner": "alice@acme.com"}
func (s *WorkspaceService) Create(ctx context.Context, req *CreateRequest, rsp *CreateResponse) error {
s.mu.Lock()
if s.byOwner == nil {
s.byOwner = make(map[string]*Workspace)
}
if ws, ok := s.byOwner[req.Owner]; ok {
s.mu.Unlock()
fmt.Printf(" \033[32m[workspace]\033[0m duplicate suppressed %s for %s\n", ws.ID, req.Owner)
rsp.Workspace = ws
return nil
}
s.n++
id := fmt.Sprintf("ws-%d", s.n)
ws := &Workspace{ID: id, Owner: req.Owner}
s.byOwner[req.Owner] = ws
s.mu.Unlock()
fmt.Printf(" \033[32m[workspace]\033[0m created %s for %s\n", id, req.Owner)
rsp.Workspace = ws
rsp.Workspace = &Workspace{ID: id, Owner: req.Owner}
return nil
}
@@ -91,26 +79,14 @@ type SendResponse struct {
Sent bool `json:"sent"`
}
type NotifyService struct {
mu sync.Mutex
n int
sent map[string]bool
mu sync.Mutex
n int
}
// Send delivers a notification message to a recipient.
// @example {"to": "alice@acme.com", "message": "Welcome"}
func (s *NotifyService) Send(ctx context.Context, req *SendRequest, rsp *SendResponse) error {
key := req.To + "\x00" + req.Message
s.mu.Lock()
if s.sent == nil {
s.sent = make(map[string]bool)
}
if s.sent[key] {
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m duplicate suppressed to=%s message=%q\n", req.To, req.Message)
rsp.Sent = true
return nil
}
s.sent[key] = true
s.n++
s.mu.Unlock()
fmt.Printf(" \033[35m[notify]\033[0m 📨 to=%s message=%q\n", req.To, req.Message)
@@ -202,24 +178,6 @@ func waitFor(reg registry.Registry, name string) {
}
}
func waitForOnboardingSideEffects(ctx context.Context, wsSvc *WorkspaceService, ntSvc *NotifyService) error {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
workspaces, notifications := wsSvc.count(), ntSvc.count()
if workspaces >= 1 && notifications >= 1 {
return nil
}
select {
case <-ctx.Done():
return fmt.Errorf("agent-flow missing required onboarding side effects before timeout: workspaces=%d/1 notifications=%d/1", workspaces, notifications)
case <-ticker.C:
}
}
}
func main() {
provider := flag.String("provider", "mock", "LLM provider: mock (default), anthropic, openai, ...")
flag.Parse()
@@ -299,21 +257,20 @@ func main() {
os.Exit(1)
}
// Wait for the agent to finish acting, and fail the harness if the
// provider returns a successful reply without the required service side
// effects. The 0→hero/provider conformance path must not print success
// unless the services → agent → workflow contract actually happened.
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc)
cancel()
// Wait for the agent to finish acting.
deadline := time.Now().Add(30 * time.Second)
for time.Now().Before(deadline) {
if ntSvc.count() >= 1 && wsSvc.count() >= 1 {
break
}
time.Sleep(50 * time.Millisecond)
}
fmt.Printf("\n\033[1mresult:\033[0m workspaces created=%d, notifications sent=%d\n", wsSvc.count(), ntSvc.count())
if rs := f.Results(); len(rs) > 0 {
fmt.Printf("flow reply: %s\n", rs[len(rs)-1].Reply)
}
if err != nil {
fmt.Printf("\n\033[31m✗ %v\033[0m\n", err)
os.Exit(1)
if wsSvc.count() >= 1 && ntSvc.count() >= 1 {
fmt.Println("\n\033[32m✓ the agent onboarded the user — triggered by an event, not a prompt\033[0m")
}
fmt.Println("\n\033[32m✓ the agent onboarded the user — triggered by an event, not a prompt\033[0m")
}
-70
View File
@@ -1,8 +1,6 @@
package main
import (
"context"
"strings"
"testing"
"time"
@@ -101,71 +99,3 @@ func TestEventTriggersAgentNoPrompt(t *testing.T) {
t.Errorf("flow recorded no result for the event")
}
}
func TestWaitForOnboardingSideEffectsFailsWhenMissing(t *testing.T) {
wsSvc := new(WorkspaceService)
ntSvc := new(NotifyService)
ctx, cancel := context.WithTimeout(context.Background(), time.Millisecond)
defer cancel()
err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc)
if err == nil {
t.Fatal("waitForOnboardingSideEffects returned nil, want missing side effects error")
}
if got := err.Error(); !strings.Contains(got, "workspaces=0/1") || !strings.Contains(got, "notifications=0/1") {
t.Fatalf("waitForOnboardingSideEffects error %q does not report missing side effects", got)
}
}
func TestWaitForOnboardingSideEffectsPassesWhenComplete(t *testing.T) {
wsSvc := new(WorkspaceService)
ntSvc := new(NotifyService)
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, &CreateResponse{}); err != nil {
t.Fatalf("create workspace: %v", err)
}
if err := ntSvc.Send(context.Background(), &SendRequest{To: "alice@acme.com", Message: "Welcome"}, &SendResponse{}); err != nil {
t.Fatalf("send notification: %v", err)
}
ctx, cancel := context.WithTimeout(context.Background(), time.Second)
defer cancel()
if err := waitForOnboardingSideEffects(ctx, wsSvc, ntSvc); err != nil {
t.Fatalf("waitForOnboardingSideEffects returned %v, want nil", err)
}
}
func TestWorkspaceCreateSuppressesDuplicateOwner(t *testing.T) {
wsSvc := new(WorkspaceService)
first := new(CreateResponse)
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, first); err != nil {
t.Fatalf("create first workspace: %v", err)
}
second := new(CreateResponse)
if err := wsSvc.Create(context.Background(), &CreateRequest{Owner: "alice@acme.com"}, second); err != nil {
t.Fatalf("create duplicate workspace: %v", err)
}
if got := wsSvc.count(); got != 1 {
t.Fatalf("workspace creations = %d, want 1 after duplicate owner replay", got)
}
if first.Workspace == nil || second.Workspace == nil || second.Workspace.ID != first.Workspace.ID {
t.Fatalf("duplicate create returned workspace %#v, want original %#v", second.Workspace, first.Workspace)
}
}
func TestNotifySendSuppressesDuplicateMessage(t *testing.T) {
ntSvc := new(NotifyService)
req := &SendRequest{To: "alice@acme.com", Message: "Welcome — your workspace is ready."}
if err := ntSvc.Send(context.Background(), req, &SendResponse{}); err != nil {
t.Fatalf("send first notification: %v", err)
}
if err := ntSvc.Send(context.Background(), req, &SendResponse{}); err != nil {
t.Fatalf("send duplicate notification: %v", err)
}
if got := ntSvc.count(); got != 1 {
t.Fatalf("notifications sent = %d, want 1 after duplicate message replay", got)
}
}
+17 -73
View File
@@ -187,14 +187,13 @@ func (s *NotifyService) duplicateAttempts() int {
func notifyDedupKey(to, message string) string {
recipient := canonicalLaunchNotifyRecipient(normalizeNotifyText(to))
body := normalizeNotifyText(message)
if recipient == "owner@acme.com" && isLaunchReadinessNotify(body) {
if isLaunchReadinessNotify(body) {
body = "launch-readiness"
}
return recipient + "\x00" + body
}
func canonicalLaunchNotifyRecipient(recipient string) string {
recipient = canonicalSpokenEmailRecipient(recipient)
switch recipient {
case "owner", "launch owner", "plan owner", "owner acme com", "owner@acme com", "owner @ acme com":
return "owner@acme.com"
@@ -206,14 +205,6 @@ func canonicalLaunchNotifyRecipient(recipient string) string {
}
}
func canonicalSpokenEmailRecipient(recipient string) string {
fields := strings.Fields(recipient)
if len(fields) == 5 && fields[1] == "at" && fields[3] == "dot" {
return fields[0] + "@" + fields[2] + "." + fields[4]
}
return recipient
}
func normalizeNotifyText(message string) string {
message = strings.ToLower(strings.TrimSpace(message))
message = strings.Map(func(r rune) rune {
@@ -230,18 +221,12 @@ func normalizeNotifyText(message string) string {
}
func isLaunchReadinessNotify(message string) bool {
hasLaunch := strings.Contains(message, "launch")
hasPlanOrReadiness := strings.Contains(message, "plan") ||
strings.Contains(message, "readiness") ||
strings.Contains(message, "ready")
hasCompletion := strings.Contains(message, "ready") ||
strings.Contains(message, "readiness") ||
strings.Contains(message, "prepared") ||
strings.Contains(message, "complete") ||
strings.Contains(message, "finished") ||
strings.Contains(message, "done") ||
strings.Contains(message, "sent")
return hasLaunch && hasPlanOrReadiness && hasCompletion
return strings.Contains(message, "launch") &&
strings.Contains(message, "plan") &&
(strings.Contains(message, "ready") ||
strings.Contains(message, "readiness") ||
strings.Contains(message, "prepared") ||
strings.Contains(message, "complete"))
}
// ---------------------------------------------------------------------------
@@ -439,20 +424,14 @@ func runPlanDelegate(provider string) error {
if err := task.Handle(taskSvc); err != nil {
return fmt.Errorf("task handle: %w", err)
}
if err := task.Start(); err != nil {
return fmt.Errorf("task start: %w", err)
}
defer task.Stop()
go task.Run()
notifySvc := new(NotifyService)
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := notify.Handle(notifySvc); err != nil {
return fmt.Errorf("notify handle: %w", err)
}
if err := notify.Start(); err != nil {
return fmt.Errorf("notify start: %w", err)
}
defer notify.Stop()
go notify.Run()
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
commsOpts := []agent.Option{
@@ -503,9 +482,9 @@ func runPlanDelegate(provider string) error {
f := flow.New("zero-to-hero",
flow.Steps(
flow.Step{Name: "conductor", Run: planDelegateConductorStep(conductor, taskSvc, notifySvc)},
flow.Step{Name: "conductor", Run: planDelegateConductorStep(conductor)},
flow.Step{Name: "require-notify", Run: requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
_, err := comms.Ask(ctx, "Send exactly one owner readiness notification now with this exact task: "+delegatedNotifyTask+" Use the notify service and do not answer until the notification has been sent.")
_, 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
})},
),
@@ -524,10 +503,7 @@ func runPlanDelegate(provider string) error {
executeDone <- f.Execute(ctx, "launch readiness")
}()
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc, func(ctx context.Context) error {
_, err := comms.Ask(ctx, "Recover the missing owner readiness notification now for the launch work already created. Send exactly one notification with this exact task: "+delegatedNotifyTask+" Use the notify service and do not create or modify tasks.")
return err
}); err != nil {
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc); err != nil {
return err
}
@@ -537,7 +513,7 @@ func runPlanDelegate(provider string) error {
} else {
return fmt.Errorf("plan was not persisted")
}
if taskSvc.count() == 0 || notifySvc.count() != 1 {
if taskSvc.count() != 3 || notifySvc.count() != 1 {
return fmt.Errorf("unexpected side effects: tasks=%d notify=%d", taskSvc.count(), notifySvc.count())
}
@@ -545,15 +521,11 @@ func runPlanDelegate(provider string) error {
return nil
}
func planDelegateConductorStep(conductor agent.Agent, taskSvc *TaskService, notifySvc *NotifyService) flow.StepFunc {
func planDelegateConductorStep(conductor agent.Agent) flow.StepFunc {
return func(ctx context.Context, in flow.State) (flow.State, error) {
prompt := "Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: " + in.String()
rsp, err := conductor.Ask(ctx, prompt)
if err != nil {
if isUnfinishedPlanError(err) && taskSvc != nil && notifySvc != nil && taskSvc.count() > 0 && notifySvc.count() == 0 {
fmt.Printf("\n\033[33mwarning:\033[0m conductor stopped with unfinished delegation after creating tasks; continuing to require-notify recovery: %v\n", err)
return in, nil
}
return in, err
}
if rsp != nil && rsp.Reply != "" {
@@ -563,13 +535,6 @@ func planDelegateConductorStep(conductor agent.Agent, taskSvc *TaskService, noti
}
}
func isUnfinishedPlanError(err error) bool {
if err == nil {
return false
}
return strings.Contains(strings.ToLower(err.Error()), "unfinished plan steps")
}
func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) flow.StepFunc {
return func(ctx context.Context, in flow.State) (flow.State, error) {
tasks := taskSvc.count()
@@ -577,7 +542,7 @@ func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService,
if notify == 1 {
return in, nil
}
if recoverMissingNotify == nil || tasks == 0 || notify != 0 {
if recoverMissingNotify == nil || tasks != 3 || notify != 0 {
return in, fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
}
settled, err := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
@@ -589,13 +554,6 @@ func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService,
if err := recoverMissingNotify(ctx); err != nil {
return in, fmt.Errorf("delegation completed without required notify side effect and recovery failed: notify=%d, want 1: %w", notify, err)
}
settled, err = waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
if err != nil {
return in, err
}
if !settled {
return in, fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notifySvc.count())
}
}
if notify = notifySvc.count(); notify != 1 {
return in, fmt.Errorf("delegation recovery completed without required notify side effect: notify=%d, want 1", notify)
@@ -604,7 +562,7 @@ func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService,
}
}
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService) error {
ticker := time.NewTicker(50 * time.Millisecond)
defer ticker.Stop()
for {
@@ -614,26 +572,12 @@ func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notif
notify := notifySvc.count()
if err != nil {
if isClientTimeout(err) {
if tasks > 0 && notify == 1 {
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)
}
if isUnfinishedPlanError(err) && tasks > 0 && notify == 0 && recoverMissingNotify != nil {
fmt.Printf("\n\033[33mwarning:\033[0m flow stopped after partial plan side effects; recovering missing delegated notify: %v\n", err)
if recoverErr := recoverMissingNotify(context.Background()); recoverErr != nil {
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d and recovery failed: %w", tasks, notify, recoverErr)
}
settled, waitErr := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
if waitErr != nil {
return waitErr
}
if settled {
return nil
}
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: delegation recovery completed without required notify side effect: notify=%d, want 1", tasks, notify, notifySvc.count())
}
return fmt.Errorf("flow execute after side effects tasks=%d notify=%d: %w", tasks, notify, err)
}
if notify != 1 {
+9 -146
View File
@@ -53,20 +53,14 @@ func TestPlanDelegateEndToEnd(t *testing.T) {
if err := task.Handle(taskSvc); err != nil {
t.Fatalf("handle task: %v", err)
}
if err := task.Start(); err != nil {
t.Fatalf("start task: %v", err)
}
defer task.Stop()
go task.Run()
notifySvc := new(NotifyService)
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := notify.Handle(notifySvc); err != nil {
t.Fatalf("handle notify: %v", err)
}
if err := notify.Start(); err != nil {
t.Fatalf("start notify: %v", err)
}
defer notify.Stop()
go notify.Run()
// Real comms agent (owns notify), registered so delegate reaches it over RPC.
comms := agent.New(
@@ -142,20 +136,14 @@ func TestFlowDispatchesToAgentEndToEnd(t *testing.T) {
if err := task.Handle(taskSvc); err != nil {
t.Fatalf("handle task: %v", err)
}
if err := task.Start(); err != nil {
t.Fatalf("start task: %v", err)
}
defer task.Stop()
go task.Run()
notifySvc := new(NotifyService)
notify := service.New(service.Name("notify"), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
if err := notify.Handle(notifySvc); err != nil {
t.Fatalf("handle notify: %v", err)
}
if err := notify.Start(); err != nil {
t.Fatalf("start notify: %v", err)
}
defer notify.Stop()
go notify.Run()
comms := agent.New(
agent.Name("comms"),
@@ -261,30 +249,6 @@ func TestPlanDelegateIdempotentDuplicateDelegateReplay(t *testing.T) {
}
}
func TestNotifyServiceDeduplicatesAtlasCloudLaunchReadinessParaphrases(t *testing.T) {
svc := new(NotifyService)
variants := []SendRequest{
{To: "owner at acme dot com", Message: "The launch plan is ready."},
{To: "launch owner", Message: "Launch readiness is complete."},
{To: "Owner <owner@acme.com>", Message: "The launch plan is finished and the readiness notification was sent."},
}
for _, req := range variants {
var rsp SendResponse
if err := svc.Send(context.Background(), &req, &rsp); err != nil {
t.Fatalf("Send(%+v): %v", req, err)
}
if !rsp.Sent {
t.Fatalf("Send(%+v) returned sent=false", req)
}
}
if got := svc.count(); got != 1 {
t.Fatalf("notify side effects = %d, want 1 for launch-readiness paraphrase replays", got)
}
if got := svc.duplicateAttempts(); got != len(variants)-1 {
t.Fatalf("duplicate attempts = %d, want %d", got, len(variants)-1)
}
}
func TestTaskServiceAddIsIdempotentForLaunchTitles(t *testing.T) {
svc := new(TaskService)
for _, title := range []string{"Design", "design task", "Build", "Build launch task", "Ship", "ship readiness"} {
@@ -312,7 +276,7 @@ func TestPlanDelegateExecutionAcceptsDuplicateNotifyReplay(t *testing.T) {
done := make(chan error, 1)
done <- nil
if err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil); err != nil {
if err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc); err != nil {
t.Fatalf("waitForPlanDelegateExecution returned %v, want duplicate replay accepted", err)
}
if got := notifySvc.count(); got != 1 {
@@ -323,42 +287,12 @@ func TestPlanDelegateExecutionAcceptsDuplicateNotifyReplay(t *testing.T) {
}
}
func TestPlanDelegateExecutionRecoversUnfinishedPlanAfterPartialTaskSideEffect(t *testing.T) {
taskSvc := new(TaskService)
var addRsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: "Design"}, &addRsp); err != nil {
t.Fatalf("Add: %v", err)
}
notifySvc := new(NotifyService)
done := make(chan error, 1)
done <- errors.New("agent run abc has unfinished plan steps: Delegate readiness notification to comms agent")
recovered := false
err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, func(ctx context.Context) error {
recovered = true
var sendRsp SendResponse
return notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &sendRsp)
})
if err != nil {
t.Fatalf("waitForPlanDelegateExecution returned %v, want partial notify recovery", err)
}
if !recovered {
t.Fatal("missing notify recovery did not run")
}
if got := taskSvc.count(); got != 1 {
t.Fatalf("task count = %d, want completed partial task to stay singular", got)
}
if got := notifySvc.count(); got != 1 {
t.Fatalf("notify count = %d, want recovered notify side effect", got)
}
}
func TestPlanDelegateExecutionRejectsClaimedCompletionWithoutNotify(t *testing.T) {
notifySvc := new(NotifyService)
done := make(chan error, 1)
done <- nil
err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil)
err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc)
if err == nil {
t.Fatal("waitForPlanDelegateExecution returned nil, want missing notify side-effect error")
}
@@ -367,45 +301,6 @@ func TestPlanDelegateExecutionRejectsClaimedCompletionWithoutNotify(t *testing.T
}
}
type failingAgent struct {
err error
}
func (a failingAgent) Name() string { return "failing" }
func (a failingAgent) Init(...agent.Option) {}
func (a failingAgent) Options() agent.Options { return agent.Options{} }
func (a failingAgent) Ask(context.Context, string) (*agent.Response, error) { return nil, a.err }
func (a failingAgent) Stream(context.Context, string) (ai.Stream, error) { return nil, a.err }
func (a failingAgent) Run() error { return nil }
func (a failingAgent) Stop() error { return nil }
func (a failingAgent) String() string { return "failing" }
func TestPlanDelegateConductorAllowsNotifyRecoveryAfterUnfinishedDelegation(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
var rsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
}
notifySvc := new(NotifyService)
step := planDelegateConductorStep(failingAgent{err: errors.New("agent run abc has unfinished plan steps: Delegate readiness notification to comms agent")}, taskSvc, notifySvc)
if _, err := step(context.Background(), flow.State{}); err != nil {
t.Fatalf("planDelegateConductorStep returned %v, want require-notify recovery to run", err)
}
}
func TestPlanDelegateConductorKeepsUnfinishedTaskFailureActionable(t *testing.T) {
step := planDelegateConductorStep(failingAgent{err: errors.New("agent run abc has unfinished plan steps: Create Build task")}, new(TaskService), new(NotifyService))
err := func() error { _, err := step(context.Background(), flow.State{}); return err }()
if err == nil {
t.Fatal("planDelegateConductorStep returned nil, want unfinished task error")
}
if got := err.Error(); !strings.Contains(got, "Create Build task") {
t.Fatalf("error = %q, want original unfinished task detail", got)
}
}
func TestPlanDelegateExecutionRecoversMissingNotifyOnce(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
@@ -472,37 +367,6 @@ func TestPlanDelegateExecutionWaitsForInFlightNotifyAfterFlowCompletion(t *testi
}
}
func TestPlanDelegateRecoveryWaitsForRecoveredNotifySideEffect(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
var rsp AddResponse
if err := taskSvc.Add(context.Background(), &AddRequest{Title: title}, &rsp); err != nil {
t.Fatalf("Add(%q): %v", title, err)
}
}
notifySvc := new(NotifyService)
recovered := false
_, err := requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
recovered = true
go func() {
time.Sleep(100 * time.Millisecond)
var rsp SendResponse
_ = notifySvc.Send(ctx, &SendRequest{To: "owner@acme.com", Message: "The launch plan is ready"}, &rsp)
}()
return nil
})(context.Background(), flow.State{})
if err != nil {
t.Fatalf("requireDelegatedNotifyStep returned %v, want delayed recovery success", err)
}
if !recovered {
t.Fatal("missing notify recovery did not run")
}
if got := notifySvc.count(); got != 1 {
t.Fatalf("notify count = %d, want recovered notify side effect", got)
}
}
func TestPlanDelegateExecutionAcceptsClientTimeoutAfterSideEffects(t *testing.T) {
taskSvc := new(TaskService)
for _, title := range []string{"Design", "Build", "Ship"} {
@@ -520,7 +384,7 @@ func TestPlanDelegateExecutionAcceptsClientTimeoutAfterSideEffects(t *testing.T)
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 {
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc); err != nil {
t.Fatalf("waitForPlanDelegateExecution returned %v, want completed side effects to satisfy client timeout", err)
}
}
@@ -529,7 +393,7 @@ func TestPlanDelegateExecutionClassifiesClientTimeoutBeforeSideEffects(t *testin
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)
err := waitForPlanDelegateExecution(done, new(TaskService), new(NotifyService))
if err == nil {
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before side effects to fail")
}
@@ -556,7 +420,7 @@ func TestPlanDelegateExecutionClassifiesPartialClientTimeout(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, taskSvc, new(NotifyService), nil)
err := waitForPlanDelegateExecution(done, taskSvc, new(NotifyService))
if err == nil {
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before notify to fail")
}
@@ -598,7 +462,6 @@ func TestNotifyServiceCollapsesProviderReadinessParaphrases(t *testing.T) {
requests := []SendRequest{
{To: "owner@acme.com", Message: "The launch plan is ready"},
{To: "owner @ acme.com", Message: "Launch plan ready."},
{To: "owner at acme dot com", Message: "The launch plan is ready."},
{To: "launch owner", Message: "The launch readiness plan is prepared."},
{To: "plan owner", Message: "Launch plan is complete!"},
}
@@ -206,10 +206,10 @@ func writeSummaryMarkdown(path string, summary conformanceSummary) error {
func capabilityMarkdown(rows []ai.CapabilityRow) string {
var b strings.Builder
b.WriteString("| Provider | Model | Image | Video | Streaming | Tool streaming |\n")
b.WriteString("| --- | --- | --- | --- | --- | --- |\n")
b.WriteString("| Provider | Model | Image | Video | Streaming |\n")
b.WriteString("| --- | --- | --- | --- | --- |\n")
for _, row := range rows {
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s | %s |\n", row.Provider, mark(row.Model), mark(row.Image), mark(row.Video), mark(row.Stream), mark(row.ToolStream))
fmt.Fprintf(&b, "| %s | %s | %s | %s | %s |\n", row.Provider, mark(row.Model), mark(row.Image), mark(row.Video), mark(row.Stream))
}
return b.String()
}
@@ -250,9 +250,9 @@ func mark(ok bool) string {
func printCapabilityMatrix() {
fmt.Println("Provider capability matrix:")
fmt.Println("provider model image video stream tool-stream")
fmt.Println("provider model image video stream")
for _, row := range ai.CapabilityRows() {
fmt.Printf("%-12s %-5s %-5s %-5s %-6s %-11s\n", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video), yesNo(row.Stream), yesNo(row.ToolStream))
fmt.Printf("%-12s %-5s %-5s %-5s %-6s\n", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video), yesNo(row.Stream))
}
fmt.Println()
}
@@ -421,7 +421,7 @@ func runAgentConformance(provider string, timeout time.Duration) error {
if provider == "mock" {
testProvider = "fake"
}
cmd := exec.CommandContext(ctx, "go", "test", "./agent", "-run", "TestAgentProvider(ConformanceMatrix|StreamConformanceMatrix)", "-count=1", "-v")
cmd := exec.CommandContext(ctx, "go", "test", "./agent", "-run", "TestAgentProviderConformanceMatrix", "-count=1", "-v")
cmd.Dir = repoRoot()
cmd.Stdout = os.Stdout
cmd.Stderr = os.Stderr
@@ -93,7 +93,7 @@ func TestWriteCapabilityMarkdown(t *testing.T) {
}
got := string(b)
for _, want := range []string{
"| Provider | Model | Image | Video | Streaming | Tool streaming |",
"| Provider | Model | Image | Video | Streaming |",
"| mock | ✅ | — | — | — |",
"| vision | — | ✅ | ✅ | — |",
} {
+1 -2
View File
@@ -414,7 +414,7 @@ func runUniverse(provider string) int {
// Services.
inv, pay, ord, ntf := new(Inventory), new(Payment), new(Orders), new(Notify)
for name, h := range map[string]any{"inventory": inv, "payment": pay, "orders": ord, "notify": ntf} {
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Broker(br), service.Client(cl))
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Client(cl))
svc.Handle(h)
go svc.Run()
}
@@ -429,7 +429,6 @@ func runUniverse(provider string) int {
agent.Address("127.0.0.1:0"),
agent.Provider(provider), agent.APIKey(apiKey),
agent.MaxSteps(5),
agent.WithBroker(br),
agent.WrapTool(func(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
atomic.AddInt64(&wrapped, 1)
+13 -22
View File
@@ -4,21 +4,19 @@ This directory owns the no-secret reference scenario for the Go Micro
services → agents → workflows lifecycle. It is intentionally small and
scripted so CI can run it on every push without external services or model keys.
`run.sh` verifies the complete first-agent 0→hero contract together:
`run.sh` verifies five boundaries together:
1. **Scaffold** — the maintained `micro new` 0→1 contract still creates
runnable services from a clean workspace.
2. **First agent**`micro agent demo`, `micro examples`, `micro agent preflight`,
`micro run`, `micro chat`, and `micro inspect agent <name>` remain available
as the documented first-agent walkthrough path.
3. **Run**`micro run` remains available as the local development entry point.
4. **Chat**`micro chat` remains available as the interactive agent entry point.
5. **Inspect/debugging**`micro inspect agent <name>`, `micro agent history <name>`,
1. **First agent**`micro new`, `micro agent preflight`, `micro run`,
`micro chat`, and `micro inspect agent <name>` remain available as the
documented first-agent walkthrough path.
2. **Run**`micro run` remains available as the local development entry point.
3. **Chat**`micro chat` remains available as the interactive agent entry point.
4. **Inspect/debugging**`micro inspect agent <name>`, `micro agent history <name>`,
and `micro inspect flow <name>` remain available as the local run-history
inspection step. The no-secret debugging smoke seeds durable agent run history
and memory, then runs the documented inspect/history commands without provider
credentials; `micro flow runs` preserves durable workflow history inspection.
6. **Deploy**`micro deploy --dry-run <target>` remains available as the
5. **Deploy**`micro deploy --dry-run <target>` remains available as the
deployment-boundary checkpoint. The dry run resolves configured deploy targets
and services and prints the remote build/copy/systemd/health plan without
building binaries, opening SSH connections, running `rsync`, or touching
@@ -32,23 +30,16 @@ and A2A with only the LLM mocked.
The default GitHub harness workflow runs this script on every push and pull
request after the install smoke check and 0→1 scaffold contract. Developers can
verify the first-agent on-ramp links and CLI command-output parity with
`make docs-wayfinding` whenever README or website first-agent breadcrumbs,
`micro agent demo`, `micro examples`, or `micro zero-to-hero` change. That
check is provider-free and fails if documented command names, guide links, or
maintained no-secret example paths drift from the CLI outputs. To verify the
installed first-run CLI seam alone, use `make install-smoke`; to run just the documented
agent debugging quickcheck with
`go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1`,
or run the same no-secret contract locally with:
verify the installer seam alone with `make install-smoke`, or run the same
no-secret contract locally with:
```sh
make harness
```
That target intentionally exercises the first-agent docs wayfinding guard, the
install script smoke path, both 0→1 scaffold variants, the 0→hero scenario, the
event-driven agent-flow harness, and mock provider conformance, so
That target intentionally exercises the install script smoke path, both 0→1
scaffold variants, the 0→hero scenario, the event-driven agent-flow harness, and
mock provider conformance, so
the public scaffold → run/chat → inspect → deploy lifecycle stays executable
outside CI as well. Live provider checks remain separate and gated by configured
API keys (`make provider-conformance` or the scheduled/manual CI job).
@@ -20,7 +20,6 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
guide := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "zero-to-hero.md"))
for _, want := range []string{
"make harness",
"make inner-loop",
"go test ./cmd/micro/cli/new -run TestZeroToOne -count=1",
"go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1",
"go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1",
@@ -39,39 +38,10 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
}
}
runScript := readFile(t, filepath.Join(root, "internal", "harness", "zero-to-hero-ci", "run.sh"))
for _, want := range []string{
"go test ./cmd/micro/cli/new -run TestZeroToOne -count=1",
"go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestExamplesWayfindingIndexStaysLinked|TestExamplesCommandPointsAtWayfindingIndex|TestZeroToHeroCLIBoundaries|TestZeroToHeroCommandPrintsMaintainedNoSecretPath' -count=1",
"go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1",
"go test ./examples/first-agent -run TestRunFirstAgent -count=1",
"go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1",
} {
if !strings.Contains(runScript, want) {
t.Fatalf("0→hero CI run script missing lifecycle command %q", want)
}
}
for _, want := range []string{
"scaffold:",
"run/chat/inspect:",
"deploy dry-run:",
"chat/inspect:",
"first-agent app:",
"0→hero app:",
"flow history:",
} {
if !strings.Contains(runScript, want) {
t.Fatalf("0→hero CI run script missing debuggable boundary label %q", want)
}
}
readme := readFile(t, filepath.Join(root, "README.md"))
if !strings.Contains(readme, "internal/website/docs/guides/zero-to-hero.md") {
t.Fatal("README does not point to the canonical 0→hero guide")
}
if !strings.Contains(readme, "make inner-loop") {
t.Fatal("README does not expose the focused CLI inner-loop contract")
}
nav := readFile(t, filepath.Join(root, "internal", "website", "_data", "navigation.yml"))
if !strings.Contains(nav, "0→hero Reference") || !strings.Contains(nav, "/docs/guides/zero-to-hero.html") {
@@ -79,62 +49,6 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
}
}
func TestZeroToHeroDeployDryRunCommandSmoke(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
absRoot, err := filepath.Abs(root)
if err != nil {
t.Fatalf("resolve repository root: %v", err)
}
bin := filepath.Join(t.TempDir(), "micro")
build := exec.Command("go", "build", "-o", bin, "./cmd/micro")
build.Dir = absRoot
if out, err := build.CombinedOutput(); err != nil {
t.Fatalf("build micro CLI for deploy dry-run smoke: %v\n%s", err, out)
}
workspace := t.TempDir()
writeFile(t, filepath.Join(workspace, "micro.mu"), `service api
path ./api
deploy prod
ssh deploy@prod.example.com
path /srv/micro
`)
if err := os.Mkdir(filepath.Join(workspace, "api"), 0o755); err != nil {
t.Fatalf("create service dir: %v", err)
}
cmd := exec.Command(bin, "deploy", "--dry-run", "prod")
cmd.Dir = workspace
cmd.Env = append(os.Environ(), "MICRO_CONFIG_FILE="+filepath.Join(workspace, "micro.mu"))
out, err := cmd.CombinedOutput()
if err != nil {
t.Fatalf("documented deploy dry-run command failed: %v\n%s", err, out)
}
got := string(out)
for _, want := range []string{
"micro deploy --dry-run",
"Target",
"deploy@prod.example.com",
"Remote path",
"/srv/micro",
"Services",
"api",
"No SSH, rsync, systemd, or remote deployment was performed.",
} {
if !strings.Contains(got, want) {
t.Fatalf("deploy dry-run output missing %q:\n%s", want, got)
}
}
guide := readFile(t, filepath.Join(absRoot, "internal", "website", "docs", "guides", "zero-to-hero.md"))
if !strings.Contains(guide, "micro deploy --dry-run prod") {
t.Fatal("0→hero guide must document the same deploy dry-run command covered by CI")
}
}
func TestGuidesNavigationLeadsWithDoing(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
nav := readFile(t, filepath.Join(root, "internal", "website", "_data", "navigation.yml"))
@@ -289,12 +203,10 @@ func TestFirstAgentWayfindingDocs(t *testing.T) {
links: []string{
"internal/website/docs/guides/install-troubleshooting.md",
"micro agent demo",
"micro examples",
"micro zero-to-hero",
"internal/website/docs/guides/no-secret-first-agent.md",
"internal/website/docs/guides/your-first-agent.md",
"internal/website/docs/guides/debugging-agents.md",
"micro inspect agent <name>",
"internal/website/docs/guides/zero-to-hero.md",
},
},
@@ -316,26 +228,6 @@ func TestFirstAgentWayfindingDocs(t *testing.T) {
"./support/",
},
},
{
name: "repository examples wayfinding index",
file: filepath.Join(root, "examples", "INDEX.md"),
heading: "## Recommended adoption path",
links: []string{
"./hello-world/",
"./first-agent/",
"./support/",
},
},
{
name: "micro README first-agent on-ramp",
file: filepath.Join(root, "cmd", "micro", "README.md"),
heading: "## First agent on-ramp",
links: []string{
"micro agent demo",
"micro examples",
"micro zero-to-hero",
},
},
{
name: "website examples index",
file: filepath.Join(root, "internal", "website", "docs", "examples", "index.md"),
@@ -355,49 +247,10 @@ func TestFirstAgentWayfindingDocs(t *testing.T) {
links: []string{
"guides/install-troubleshooting.html",
"micro agent demo",
"micro examples",
"micro zero-to-hero",
"https://github.com/micro/go-micro/blob/master/examples/INDEX.md",
"https://github.com/micro/go-micro/tree/master/examples/support",
"https://github.com/micro/go-micro/tree/master/examples/first-agent",
"guides/no-secret-first-agent.html",
"guides/your-first-agent.html",
"guides/debugging-agents.html",
"micro inspect agent <name>",
"guides/zero-to-hero.html",
},
},
{
name: "website quickstart next steps",
file: filepath.Join(root, "internal", "website", "docs", "quickstart.md"),
heading: "## Next Steps",
links: []string{
"guides/install-troubleshooting.html",
"micro agent demo",
"micro examples",
"micro zero-to-hero",
"https://github.com/micro/go-micro/blob/master/examples/INDEX.md",
"https://github.com/micro/go-micro/tree/master/examples/support",
"https://github.com/micro/go-micro/tree/master/examples/first-agent",
"guides/no-secret-first-agent.html",
"guides/your-first-agent.html",
"guides/debugging-agents.html",
"micro inspect agent <name>",
"guides/zero-to-hero.html",
},
},
{
name: "website docs index learn more",
file: filepath.Join(root, "internal", "website", "docs", "index.md"),
heading: "## Learn More",
links: []string{
"getting-started.html",
"https://github.com/micro/go-micro/blob/master/examples/INDEX.md",
"https://github.com/micro/go-micro/tree/master/examples/support",
"guides/no-secret-first-agent.html",
"guides/your-first-agent.html",
"guides/debugging-agents.html",
"micro inspect agent <name>",
"guides/zero-to-hero.html",
},
},
@@ -444,11 +297,6 @@ func TestFirstAgentWayfindingLinkTargetsResolve(t *testing.T) {
file: filepath.Join(root, "examples", "README.md"),
heading: "## Recommended first-agent path",
},
{
name: "repository examples wayfinding index",
file: filepath.Join(root, "examples", "INDEX.md"),
heading: "## Recommended adoption path",
},
{
name: "website examples index",
file: filepath.Join(root, "internal", "website", "docs", "examples", "index.md"),
@@ -459,16 +307,6 @@ func TestFirstAgentWayfindingLinkTargetsResolve(t *testing.T) {
file: filepath.Join(root, "internal", "website", "docs", "getting-started.md"),
heading: "### First-agent on-ramp",
},
{
name: "website quickstart next steps",
file: filepath.Join(root, "internal", "website", "docs", "quickstart.md"),
heading: "## Next Steps",
},
{
name: "website docs index learn more",
file: filepath.Join(root, "internal", "website", "docs", "index.md"),
heading: "## Learn More",
},
}
for _, check := range checks {
@@ -485,99 +323,6 @@ func TestFirstAgentWayfindingLinkTargetsResolve(t *testing.T) {
}
}
func TestFirstAgentGuideChainDocumentsRequiredNextSteps(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
checks := []struct {
name string
file string
markers []string
}{
{
name: "no-secret transcript hands off to live build and debug",
file: filepath.Join(root, "internal", "website", "docs", "guides", "no-secret-first-agent.md"),
markers: []string{
"micro agent demo",
"go run ./examples/first-agent",
"go run ./examples/support",
"go test ./examples/first-agent -run TestRunFirstAgent -count=1",
"go test ./examples/support -run TestRunSupportMockSmoke -count=1",
"make harness",
"micro agent preflight",
"micro run",
"micro chat assistant",
"micro inspect agent assistant",
"Debugging your agent",
"debugging-agents.html",
},
},
{
name: "your-first-agent keeps no-secret, preflight, doctor, inspect, and debug nearby",
file: filepath.Join(root, "internal", "website", "docs", "guides", "your-first-agent.md"),
markers: []string{
"no-secret-first-agent.html",
"go run ./examples/support",
"micro agent preflight",
"micro agent doctor",
"micro run",
"micro chat assistant",
"micro inspect agent assistant",
"debugging-agents.html",
"zero-to-hero.html",
},
},
{
name: "debugging guide starts at install/preflight and preserves inspect/history recovery",
file: filepath.Join(root, "internal", "website", "docs", "guides", "debugging-agents.md"),
markers: []string{
"install-troubleshooting.html",
"micro agent preflight",
"micro agent doctor",
"micro run",
"micro chat",
"micro inspect agent support",
"micro agent history",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1",
},
},
{
name: "zero-to-hero guide exposes the provider-free contract commands",
file: filepath.Join(root, "internal", "website", "docs", "guides", "zero-to-hero.md"),
markers: []string{
"go test ./internal/harness/zero-to-hero-ci -run TestFirstAgentWayfindingDocs -count=1",
"micro zero-to-hero",
"go run ./examples/first-agent",
"go run ./examples/support",
"make harness",
"go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1",
"make provider-conformance-mock",
},
},
}
for _, check := range checks {
t.Run(check.name, func(t *testing.T) {
doc := readFile(t, check.file)
for _, marker := range check.markers {
if !strings.Contains(doc, marker) {
t.Fatalf("%s missing required first-agent next-step marker %q", check.name, marker)
}
if firstAgentMarkerIsLink(marker) {
assertWayfindingTargetExists(t, root, check.file, marker)
}
}
})
}
}
func firstAgentMarkerIsLink(marker string) bool {
return strings.HasSuffix(marker, ".html") ||
strings.HasSuffix(marker, ".md") ||
strings.HasPrefix(marker, "./") ||
strings.HasPrefix(marker, "../") ||
strings.HasPrefix(marker, "https://github.com/micro/go-micro/")
}
func TestFirstAgentLifecycleCommandOrderIsDocumented(t *testing.T) {
root := filepath.Clean(filepath.Join("..", "..", ".."))
checks := []struct {
@@ -751,7 +496,6 @@ func TestGettingStartedDocsLeadWithNoSecretFirstRun(t *testing.T) {
"micro zero-to-hero",
"guides/no-secret-first-agent.html",
"guides/debugging-agents.html",
"micro inspect agent <name>",
"guides/zero-to-hero.html",
},
},
@@ -819,24 +563,6 @@ func TestNoSecretFirstAgentTranscript(t *testing.T) {
}
}
debuggingGuide := readFile(t, filepath.Join(root, "internal", "website", "docs", "guides", "debugging-agents.md"))
for _, want := range []string{
"Provider-free quickcheck",
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1",
"micro inspect agent assistant --limit 1",
"micro inspect agent --status done",
"micro agent history assistant",
} {
if !strings.Contains(debuggingGuide, want) {
t.Fatalf("debugging guide missing provider-free quickcheck marker %q", want)
}
}
harnessReadme := readFile(t, filepath.Join(root, "internal", "harness", "zero-to-hero-ci", "README.md"))
if !strings.Contains(harnessReadme, "go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1") {
t.Fatal("0→hero harness README does not expose the agent debugging quickcheck command")
}
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")
+7 -27
View File
@@ -4,36 +4,16 @@ set -euo pipefail
ROOT="$(cd "$(dirname "${BASH_SOURCE[0]}")/../../.." && pwd)"
cd "$ROOT"
run_step() {
local name=$1
shift
printf '\n==> %s\n' "$name"
printf '+ %q' "$@"
printf '\n'
"$@"
}
# Keep the developer inner-loop boundaries executable and discoverable in CI
# without secrets or long-running daemons. Step names mirror the documented
# install → scaffold → run/chat → inspect → deploy-dry-run seams so failures
# identify the broken part of the getting-started contract.
run_step "scaffold: 0→1 service contract" \
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
run_step "run/chat/inspect: first-agent CLI boundaries" \
go test ./cmd/micro -run 'TestFirstAgentWalkthroughCLIBoundaries|TestExamplesWayfindingIndexStaysLinked|TestExamplesCommandPointsAtWayfindingIndex|TestZeroToHeroCLIBoundaries|TestZeroToHeroCommandPrintsMaintainedNoSecretPath' -count=1
run_step "deploy dry-run: configured target plan" \
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
run_step "chat/inspect: no-secret first-agent transcript and docs" \
go test ./internal/harness/zero-to-hero-ci -run 'TestNoSecretFirstAgentTranscript|TestNoSecretFirstAgentDebuggingSmoke|TestZeroToHeroReferenceDocs|TestZeroToHeroDeployDryRunCommandSmoke|TestYourFirstAgentTutorialSmoke' -count=1
# 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|TestNoSecretFirstAgentDebuggingSmoke|TestZeroToHeroReferenceDocs|TestYourFirstAgentTutorialSmoke' -count=1
# Deterministic no-secret reference scenarios. These use the real Go Micro
# runtime and mock only the LLM provider. The support example is the maintained
# runnable 0→hero app; keep it in this CI path so its documented run/chat/inspect
# journey cannot drift from the framework.
run_step "first-agent app: runnable provider-free example" \
go test ./examples/first-agent -run TestRunFirstAgent -count=1
run_step "0→hero app: support lifecycle smoke" \
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
run_step "flow history: deterministic services → agents → workflows harnesses" \
go test ./internal/harness/universe ./internal/harness/plan-delegate -run 'Test.*Harness|TestPlanDelegateEndToEnd|TestPlanDelegateFlowHandoff' -count=1
go test ./examples/first-agent -run TestRunFirstAgent -count=1
go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1
go test ./internal/harness/universe ./internal/harness/plan-delegate -run 'Test.*Harness|TestPlanDelegateEndToEnd|TestPlanDelegateFlowHandoff' -count=1
-45
View File
@@ -1,45 +0,0 @@
---
layout: blog
title: "What's New in Go Micro: v6.3.15"
permalink: /blog/34
description: "Go Micro v6.3.15 tightens the first-agent on-ramp, adds Anthropic streaming, and hardens plan/delegate plus text tool-call recovery."
---
# What's New in Go Micro: v6.3.15
*July 5, 2026 • By the Go Micro Team*
Go Micro v6.3.15 is a small but useful harness release: less friction for the first agent, better streaming provider coverage, and more reliable execution when models and delegates do not behave perfectly.
## Anthropic now streams
The Anthropic provider now supports Messages SSE streaming and is registered as a streaming-capable provider. That means Go Micro agents can use Anthropic in the same streaming path as the other streaming-capable providers, with request/response parser coverage and provider capability docs kept in sync.
## The first-agent path is easier to start
The on-ramp now has a smallest runnable first-agent example: a mock-model, no-secret agent you can run before adding provider keys. The CLI and docs also point new users toward the maintained first-agent path after scaffold/run milestones, so the next step after a service is clearer: run the example, build the agent, debug it, then walk the 0→hero services → agents → workflows path.
## Plan/delegate is more deterministic
Plan/delegate runs got another reliability pass. Completed plan steps are preserved, ordering is guarded, notify-before-completion is required in the flow path, and checkpoint continuation is more stable. These are the kinds of harness fixes that matter when an agent does real work over multiple tool calls instead of just answering a prompt.
## Tool-call recovery keeps improving
Provider text tool-call fallback paths now recover more of the awkward cases: tagged calls, `Create`-suffixed calls, mixed text/tool-call output, and AtlasCloud follow-up calls. The goal is pragmatic: when a weaker or non-standard provider emits something close to a tool call, the harness should still make progress when it can do so safely.
## A2A payment groundwork
The A2A gateway now has the shared payment-mandate foundation needed for AP2-style agent payment flows. It is groundwork, not a full product story yet, but it keeps Go Micro's agent interop and paid-tool direction moving together.
## Read the changelog
The full release notes are in the [CHANGELOG](https://github.com/micro/go-micro/blob/master/CHANGELOG.md).
---
*Go Micro is an open source agent harness and service framework for Go. [Star us on GitHub](https://github.com/micro/go-micro).*
<div class="post-nav">
<div><a href="/blog/33">&larr; The Loop, Shipped: Introducing micro loop</a></div>
<div><a href="/blog/">All Posts</a></div>
</div>
-7
View File
@@ -11,13 +11,6 @@ permalink: /blog/
<div class="posts">
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/34">What's New in Go Micro: v6.3.15</a></h2>
<p class="meta" style="color: #666; font-size: 0.85rem;">July 5, 2026</p>
<p>Go Micro v6.3.15 tightens the first-agent on-ramp, adds Anthropic streaming, hardens plan/delegate execution, and improves provider text tool-call recovery.</p>
<a href="/blog/34">Read more &rarr;</a>
</article>
<article style="margin-bottom: 2rem; padding-bottom: 1.5rem; border-bottom: 1px solid #e5e5e5;">
<h2 style="margin: 0 0 0.5rem;"><a href="/blog/33">The Loop, Shipped: Introducing micro loop</a></h2>
<p class="meta" style="color: #666; font-size: 0.85rem;">July 2, 2026</p>
+5 -6
View File
@@ -59,12 +59,11 @@ After this quick start, follow the agent path in order:
2. `micro agent demo` — print the provider-free first-agent demo command and next docs steps from the installed CLI.
3. `micro examples` — print the maintained provider-free runnable examples in copy/paste order.
4. `micro zero-to-hero` — print the maintained one-command no-secret lifecycle harness and runnable examples.
5. [Examples wayfinding index](https://github.com/micro/go-micro/blob/master/examples/INDEX.md) — choose the smallest no-secret first-agent, maintained [0→hero support reference](https://github.com/micro/go-micro/tree/master/examples/support), and next interop examples from one map.
6. [Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent) — run one service-backed agent with a mock model and no provider key.
7. [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.
8. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
9. [Debugging your agent](guides/debugging-agents.html) — use `micro inspect agent <name>` to inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent surprises you.
10. [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`.
5. [Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent) — run one service-backed agent with a mock model and no provider key.
6. [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.
7. [Your First Agent](guides/your-first-agent.html) — build a service-backed agent and talk to it with `micro chat`.
8. [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.
9. [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`.
## Write a Service
@@ -30,23 +30,23 @@ imports are linked in:
```go
for _, row := range ai.CapabilityRows() {
fmt.Printf("%s: chat=%t image=%t video=%t stream=%t tool_stream=%t\n", row.Provider, row.Model, row.Image, row.Video, row.Stream, row.ToolStream)
fmt.Printf("%s: chat=%t image=%t video=%t stream=%t\n", row.Provider, row.Model, row.Image, row.Video, row.Stream)
}
```
The built-in providers currently register these capability interfaces:
| Provider | Chat/text (`ai.Model`) | Image (`ai.ImageModel`) | Video (`ai.VideoModel`) | Streaming (`ai.Stream`) | Tool streaming |
| --- | --- | --- | --- | --- | --- |
| `anthropic` | Yes | No | No | Yes | Yes |
| `atlascloud` | Yes | Yes | Yes | Yes | No |
| `gemini` | Yes | No | No | No | No |
| `groq` | Yes | No | No | Yes | Yes |
| `minimax` | Yes | No | No | Yes | Yes |
| `mistral` | Yes | No | No | Yes | Yes |
| `ollama` | Yes | No | No | Yes | Yes |
| `openai` | Yes | Yes | No | Yes | Yes |
| `together` | Yes | No | No | Yes | Yes |
| Provider | Chat/text (`ai.Model`) | Image (`ai.ImageModel`) | Video (`ai.VideoModel`) | Streaming (`ai.Stream`) |
| --- | --- | --- | --- | --- |
| `anthropic` | Yes | No | No | Yes |
| `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 |
| `together` | Yes | No | No | Yes |
## Step 1: Implement the `ai.Model` Interface
@@ -116,18 +116,6 @@ state (`agent/<name>/runs/...`). The persisted timeline is recorded even without
an OpenTelemetry exporter, so `micro inspect agent` remains useful in local
no-secret development.
Provider-free quickcheck: if you want to verify the documented inspect path
before involving a live model, run the same smoke check CI uses:
```sh
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1
```
That test seeds a local `assistant` run history and memory transcript, then runs
`micro inspect agent assistant --limit 1`, `micro inspect agent --status done
--json assistant`, and `micro agent history assistant` with provider credentials
cleared.
## 4. See tool calls as they happen
When you are embedding an agent in Go and need live tool visibility, use the
@@ -34,7 +34,7 @@ func TestAIProviderGuideCapabilityMatrixMatchesRegistry(t *testing.T) {
guide := string(b)
for _, row := range ai.CapabilityRows() {
want := fmt.Sprintf("| `%s` | %s | %s | %s | %s | %s |", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video), yesNo(row.Stream), yesNo(row.ToolStream))
want := fmt.Sprintf("| `%s` | %s | %s | %s | %s |", row.Provider, yesNo(row.Model), yesNo(row.Image), yesNo(row.Video), yesNo(row.Stream))
if !strings.Contains(guide, want) {
t.Fatalf("AI provider guide capability matrix is stale; missing row %q", want)
}
+1 -8
View File
@@ -23,7 +23,7 @@ cloud credentials?"
| Run | `micro run` remains the local development entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
| Chat | `micro chat` remains the interactive agent entry point. | `go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1` |
| Inspect | `micro inspect agent <name>`, `micro agent history <name>`, `micro inspect flow <flow>`, and `micro flow runs <flow>` remain discoverable for run history; the no-secret debugging smoke seeds durable agent history and runs the documented inspect/history commands without provider keys. | `go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1` |
| Deploy | `micro deploy --dry-run prod` resolves the documented deploy target without touching remote infrastructure. | `go test ./internal/harness/zero-to-hero-ci -run TestZeroToHeroDeployDryRunCommandSmoke -count=1` |
| Deploy | `micro deploy --dry-run` resolves deploy targets without touching remote infrastructure. | `go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1` |
| Smallest first agent | `examples/first-agent` runs one service-backed agent with a deterministic mock model and no provider key. | `go test ./examples/first-agent -run TestRunFirstAgent -count=1` |
| Runtime reference app | `examples/support` runs typed services, an agent using those services as tools, an event-driven flow handoff, and an approval gate with only the model mocked. | `go test ./examples/support -run 'TestRunSupportMockSmoke|TestZeroToHeroReadmeDocumentsLifecycle' -count=1` |
| Runtime harnesses | Real services, agents, durable flows, store-backed history, delegation, and A2A run with only the model mocked. | `./internal/harness/zero-to-hero-ci/run.sh` and `make provider-conformance-mock` |
@@ -69,12 +69,6 @@ SSH access, or remote service is required.
## Run focused checks while iterating
Use the dedicated inner-loop target when you need the provider-free CLI contract in one focused command:
```sh
make inner-loop
```
Use the smaller checks when you are working on one seam:
```sh
@@ -90,7 +84,6 @@ go test ./cmd/micro -run TestFirstAgentWalkthroughCLIBoundaries -count=1
# CLI inner-loop commands: run, chat, inspect, flow runs, deploy --dry-run.
go test ./cmd/micro -run TestZeroToHeroCLIBoundaries -count=1
go test ./cmd/micro/cli/deploy -run TestDeployDryRun -count=1
go test ./internal/harness/zero-to-hero-ci -run TestZeroToHeroDeployDryRunCommandSmoke -count=1
# Smallest no-secret service-backed first agent.
go test ./examples/first-agent -run TestRunFirstAgent -count=1
+3 -5
View File
@@ -16,18 +16,16 @@ 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 in the same order as the README: `micro agent demo` for the installed no-secret CLI affordance, `micro examples` for the provider-free examples map, `micro zero-to-hero` for the maintained lifecycle harness, [examples wayfinding index](https://github.com/micro/go-micro/blob/master/examples/INDEX.md) for the runnable examples map, [the 0→hero support reference](https://github.com/micro/go-micro/tree/master/examples/support) for the full no-secret lifecycle example, [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 use `micro inspect agent <name>` for 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: `micro agent demo` for the installed no-secret CLI affordance, `micro examples` for copy/pasteable runnable examples, [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.
Otherwise continue to read the docs for more information about the framework.
## Contents
- [Getting Started](getting-started.html)
- [0→hero Reference](guides/zero-to-hero.html) - Walk scaffold → run → chat → `micro inspect agent <name>` → deploy dry-run with CI-backed commands
- [0→hero Reference](guides/zero-to-hero.html) - Walk scaffold → run → chat → inspect → deploy dry-run with CI-backed commands
- `micro agent demo` - Show the provider-free first-agent demo command and next docs steps
- `micro examples` - Show provider-free first-agent examples in copy/paste order
- [Examples wayfinding index](https://github.com/micro/go-micro/blob/master/examples/INDEX.md) - Choose the first-agent, support, and interop examples from one map
- [0→hero support reference](https://github.com/micro/go-micro/tree/master/examples/support) - Run the maintained no-secret services → agents → workflows example
- [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
@@ -52,7 +50,7 @@ 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 → `micro inspect agent <name>` → deploy dry-run with CI-backed commands
- [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
+5 -6
View File
@@ -45,12 +45,11 @@ You now have the service half of the services → agents → workflows lifecycle
2. `micro agent demo` - print the provider-free first-agent demo command and the next docs steps from the installed CLI.
3. `micro examples` - print the maintained provider-free runnable examples in copy/paste order.
4. `micro zero-to-hero` - print the maintained one-command no-secret lifecycle harness and runnable examples.
5. **[Examples wayfinding index](https://github.com/micro/go-micro/blob/master/examples/INDEX.md)** - choose the smallest no-secret first-agent, maintained **[0→hero support reference](https://github.com/micro/go-micro/tree/master/examples/support)**, and next interop examples from one map.
6. **[Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent)** - run a mock-model, no-secret agent before adding provider keys.
7. **[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.
8. **[Your First Agent](guides/your-first-agent.html)** - turn this service into an agent-callable tool, chat with it, and learn the `micro agent preflight``micro run``micro chat` loop.
9. **[Debugging your agent](guides/debugging-agents.html)** - use `micro inspect agent <name>` to inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent does something surprising.
10. **[0→hero Reference](guides/zero-to-hero.html)** - walk the maintained scaffold → run → chat → inspect → deploy dry-run path that proves services, agents, and workflows together.
5. **[Smallest first-agent example](https://github.com/micro/go-micro/tree/master/examples/first-agent)** - run a mock-model, no-secret agent before adding provider keys.
6. **[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.
7. **[Your First Agent](guides/your-first-agent.html)** - turn this service into an agent-callable tool, chat with it, and learn the `micro agent preflight``micro run``micro chat` loop.
8. **[Debugging your agent](guides/debugging-agents.html)** - inspect service registration, tool calls, run history, memory, provider failures, and flow handoffs when the agent does something surprising.
9. **[0→hero Reference](guides/zero-to-hero.html)** - walk the maintained scaffold → run → chat → inspect → deploy dry-run path that proves services, agents, and workflows together.
After that first-agent path, branch out to:
+2 -16
View File
@@ -11,7 +11,7 @@ Go Micro is a framework for building **agents and services** in Go. An agent is
The foundation is in place:
- **Services** — register, discover, RPC, events; every endpoint is automatically an MCP tool.
- **Agents** — a model with memory and tools that manages services, with `plan`, `delegate`, guardrails (`MaxSteps`, `LoopLimit`, `ApproveTool`), tool-execution middleware (`WrapTool`), run metadata, checkpoint/resume, and OpenTelemetry run spans built in.
- **Agents** — a model with memory and tools that manages services, with `plan`, `delegate`, and guardrails (`MaxSteps`, `LoopLimit`, `ApproveTool`) built in, plus tool-execution middleware (`WrapTool`) and run metadata.
- **Flows** — durable, event-driven workflows: ordered steps that checkpoint and resume after a crash.
- **Interop** — the MCP gateway (services as tools) and the A2A gateway (agents as agents, both directions, including A2A streaming, push notifications, and multi-turn continuation), both generated from the registry; x402 for paid tools.
- **Secure by default** — TLS verification on, state scoped per component.
@@ -34,24 +34,10 @@ The priority is that what exists works everywhere, under real conditions.
- **Failure & resilience.** Provider timeouts, rate limits, and cancellation mid-run; deadline/`context` propagation through the agent loop; retry and backoff at the model call.
- **The getting-started contract.** Define and CI-verify the 0→1 and 0→hero flows so they can't silently break.
## Shipped agent depth
- **Durable agent loop.** Opt-in `Checkpoint` support now lets agent `Ask` and
streaming runs persist, list pending work, and resume without replaying completed
tool calls. Human-input pauses resume through explicit input helpers.
- **Agent observability.** `RunInfo` now feeds OpenTelemetry spans and events for
agent runs, model turns, tool calls, retries, delegation lineage, and resume
checkpoints so production runs are traceable.
## Next — agentic depth
- **Streaming.** Broaden provider-backed `ai.Stream` coverage and keep chat plus A2A `message/stream` working end to end for real chat and long-task UX.
- **Resume operations polish.** Keep improving CLI/docs breadcrumbs for finding
pending agent runs and deciding whether to call resume, resume-input, or stream
resume in production.
- **Observability hardening.** Keep span attributes and run inspection coherent
across agents, flows, and gateways as more providers and workflow paths are
exercised.
- **Agent observability.** Wire the new `RunInfo` into OpenTelemetry spans so a run — steps, tool calls, delegation — is traceable. This is also what anyone running it in production will need.
## Later
+1 -4
View File
@@ -215,10 +215,7 @@ func AgentWithCheckpoint(c Checkpoint) AgentOption { return agent.WithCheckpoint
func AgentPending(ctx context.Context, a Agent) ([]FlowRun, error) { return agent.Pending(ctx, a) }
// AgentResume resumes a checkpointed agent run by id. Completed runs return
// the persisted response, including completed tool-call metadata, without
// calling the model or replaying tool calls. Incomplete runs resume from the
// saved prompt plus completed tool checkpoints; a provider call interrupted
// mid-stream is retried rather than continued byte-for-byte.
// the persisted response without calling the model or replaying tool calls.
func AgentResume(ctx context.Context, a Agent, runID string) (*AgentResponse, error) {
return agent.Resume(ctx, a, runID)
}