Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| bca3ec2e1d |
@@ -21,9 +21,9 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Handle AtlasCloud Minimax 400s for native service tools** ([#4354](https://github.com/micro/go-micro/issues/4354)) — The previous AtlasCloud marker retry item (#4348) shipped, but the live provider has now exposed an earlier Now-phase conformance break: Minimax rejects native service-tool payloads in the agent, universe, and A2A stream fallback harnesses. Fixing the single-service-tool path restores the services-as-tools promise behind first-agent trust and unblocks several live adoption-facing examples without changing public APIs.
|
||||
2. **Fix AtlasCloud Minimax tool follow-up retry after tool results** ([#4355](https://github.com/micro/go-micro/issues/4355)) — Once native tool payloads are accepted, the next AtlasCloud gap is the follow-up retry transcript after a tool result. This protects plan/delegate plus service side effects from failing after partial work, directly supporting the services → agents → workflows lifecycle under real provider behavior.
|
||||
3. **Trace agent RunInfo in OpenTelemetry spans** ([#4315](https://github.com/micro/go-micro/issues/4315)) — After the live conformance regressions are stable, the highest Next-phase operability gap is connecting existing run metadata to traces so real agent runs can be debugged across steps, tool calls, delegation, failures, services, and flows without inventing a new surface.
|
||||
1. **Fix AtlasCloud A2A streaming fallback tool-call reporting** ([#4297](https://github.com/micro/go-micro/issues/4297)) — The guarded-delegation failure closed with #4307, so the top remaining Now-phase provider defect is the A2A streaming fallback path reporting neither tool-call evidence nor run metadata (`tool=false runInfo=false`). A2A streaming is a critical interop seam for agents-as-services, and the fix should stay narrowly focused on surfacing tool/run evidence in the existing harness.
|
||||
2. **Propagate cancellation and retry signals through provider model calls** ([#4273](https://github.com/micro/go-micro/issues/4273)) — Once the current live AtlasCloud reporting failure is stable, the remaining Now-phase resilience seam is provider-boundary cancellation/deadline/retry behavior across the agent loop. Keep it focused on fakes/tests and avoid public API or architecture changes.
|
||||
3. **CI-verify zero-to-hero deploy dry-run path** ([#4309](https://github.com/micro/go-micro/issues/4309)) — Developer adoption remains the current goal, and the docs now have a strong no-secret first-agent path; the next adoption gap is making sure the documented scaffold → run → chat → inspect → deploy dry-run lifecycle is continuously verified through the deploy boundary, not just described. This keeps the 0→hero contract honest without displacing the two active Now-phase provider/resilience fixes.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -16,37 +16,12 @@ next version when it ships.
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### 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/`)
|
||||
|
||||
### 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/`)
|
||||
|
||||
### 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/`)
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -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 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 docs-wayfinding 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,7 +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 cli-wayfinding - Verify installed first-agent CLI wayfinding commands"
|
||||
@echo " make docs-wayfinding - Verify first-agent docs wayfinding links resolve locally"
|
||||
@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"
|
||||
@@ -51,20 +50,13 @@ 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) docs-wayfinding
|
||||
$(MAKE) install-smoke
|
||||
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
|
||||
./internal/harness/zero-to-hero-ci/run.sh
|
||||
go run ./internal/harness/agent-flow
|
||||
$(MAKE) provider-conformance-mock
|
||||
|
||||
# Verify the 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|TestExamplesWayfinding' -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.
|
||||
|
||||
+2
-4
@@ -227,18 +227,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
|
||||
}
|
||||
|
||||
|
||||
@@ -397,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()
|
||||
|
||||
@@ -287,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
|
||||
@@ -311,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."
|
||||
}
|
||||
|
||||
@@ -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" }
|
||||
|
||||
@@ -297,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
|
||||
|
||||
@@ -327,32 +327,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 {
|
||||
@@ -376,12 +361,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 {
|
||||
@@ -459,7 +439,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 {
|
||||
@@ -467,7 +447,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 {
|
||||
|
||||
+1
-1
@@ -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
-1
@@ -147,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 {
|
||||
|
||||
@@ -147,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 {
|
||||
|
||||
@@ -147,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
-1
@@ -285,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
|
||||
|
||||
-60
@@ -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
|
||||
|
||||
@@ -3,7 +3,6 @@ package ai
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -222,23 +221,3 @@ func TestGenerateWithRetryCapsRetryAfter(t *testing.T) {
|
||||
t.Fatalf("retryBackoff() = %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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -147,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 {
|
||||
|
||||
@@ -178,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()
|
||||
@@ -275,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")
|
||||
}
|
||||
|
||||
@@ -1,8 +1,6 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -101,37 +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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -482,7 +482,7 @@ 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.")
|
||||
return err
|
||||
@@ -521,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() == 3 && 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 != "" {
|
||||
@@ -539,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()
|
||||
|
||||
@@ -301,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"} {
|
||||
|
||||
@@ -62,62 +62,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"))
|
||||
|
||||
@@ -9,7 +9,7 @@ cd "$ROOT"
|
||||
go test ./cmd/micro/cli/new -run TestZeroToOne -count=1
|
||||
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|TestZeroToHeroDeployDryRunCommandSmoke|TestYourFirstAgentTutorialSmoke' -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
|
||||
|
||||
@@ -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` |
|
||||
@@ -84,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
|
||||
|
||||
Reference in New Issue
Block a user