Compare commits
21 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| d97c981bcc | |||
| 7734f5605d | |||
| affd61e69b | |||
| 171da77817 | |||
| 83a6b2004a | |||
| ae5ead7914 | |||
| a1d17ec984 | |||
| a8b89c2142 | |||
| 76fea3f01c | |||
| a5f8ff518d | |||
| cf9c015cf8 | |||
| 7248270f28 | |||
| 243d7904e8 | |||
| 364fb33cc8 | |||
| 7697b2587c | |||
| fa36114d73 | |||
| 5c5186a230 | |||
| 58a76bc32f | |||
| 2c562c8827 | |||
| ff1c6173cd | |||
| 2c7b2dbe51 |
@@ -21,7 +21,9 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **Make AtlasCloud guarded delegation pass reliably** ([#4244](https://github.com/micro/go-micro/issues/4244)) — Now-phase cross-provider conformance is now the highest open loop item after the website first-agent examples wayfinding shipped in #4266 and #4241 was closed; keep it in queue until the live agent harness consistently observes the guarded delegate within the retry budget.
|
||||
1. **Make agent-flow fail on missing onboarding side effects** ([#4328](https://github.com/micro/go-micro/issues/4328)) — Now-phase hardening and developer trust both depend on the 0→hero/provider harness failing when the services → agents → workflows contract is not actually satisfied. The recent deploy dry-run verifier closed the documented deploy-boundary gap; the next highest-value gap is preventing a live-provider agent-flow run from printing success while required workspace/notification side effects are absent.
|
||||
2. **Stabilize plan-delegate after unfinished delegation** ([#4322](https://github.com/micro/go-micro/issues/4322)) — This is the remaining Now-phase conformance failure in the multi-agent 0→hero path: real services and durable flow state can advance, but the delegated notification step may remain unfinished after partial side effects. Stabilizing that recovery keeps plan/delegate from being a brittle demo seam and protects the service → agent → workflow lifecycle under live-provider variance.
|
||||
3. **Trace agent RunInfo in OpenTelemetry spans** ([#4315](https://github.com/micro/go-micro/issues/4315)) — Once the live harness failures above fail deterministically and recover reliably, 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.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -16,12 +16,37 @@ 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 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,6 +19,7 @@ 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 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"
|
||||
@echo " make provider-conformance - Run harnesses against configured live providers"
|
||||
@@ -49,12 +50,19 @@ 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) 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 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
|
||||
|
||||
# Verify the documented install script and first-run CLI command boundaries without
|
||||
# provider keys or network access.
|
||||
install-smoke:
|
||||
@@ -110,4 +118,3 @@ gorelease-dry-run:
|
||||
-w /$(NAME) \
|
||||
$(GORELEASER_DOCKER_IMAGE) \
|
||||
--clean --verbose --skip=publish,validate --snapshot
|
||||
|
||||
|
||||
+4
-2
@@ -227,16 +227,18 @@ func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, erro
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("discover tools: %w", err)
|
||||
}
|
||||
a.mem.Add("user", message)
|
||||
messages := append([]ai.Message(nil), a.mem.Messages()...)
|
||||
messages = append(messages, ai.Message{Role: "user", Content: message})
|
||||
stream, err := a.model.Stream(ctx, &ai.Request{
|
||||
Prompt: message,
|
||||
SystemPrompt: a.buildPrompt(),
|
||||
Tools: toolList,
|
||||
Messages: a.mem.Messages(),
|
||||
Messages: messages,
|
||||
})
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
a.mem.Add("user", message)
|
||||
return &memoryRecordingStream{stream: stream, memory: a.mem}, nil
|
||||
}
|
||||
|
||||
|
||||
@@ -243,6 +243,29 @@ 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" }
|
||||
|
||||
@@ -102,6 +102,9 @@ 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
|
||||
@@ -268,6 +271,125 @@ func taggedToolName(tag string) string {
|
||||
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
|
||||
}
|
||||
out = append(out, ai.ToolCall{
|
||||
ID: fmt.Sprintf("text-call-%s", strings.ReplaceAll(alias, ".", "_")),
|
||||
Name: canonical,
|
||||
Input: input,
|
||||
})
|
||||
}
|
||||
}
|
||||
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
|
||||
}
|
||||
|
||||
func firstNestedToolCalls(m map[string]any) (any, bool) {
|
||||
for _, key := range []string{"tool_calls", "toolCalls", "calls"} {
|
||||
if v, ok := m[key]; ok {
|
||||
|
||||
@@ -107,3 +107,42 @@ func TestParseTextToolCallsHTMLEscapedTaggedMarkup(t *testing.T) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
|
||||
@@ -327,17 +327,32 @@ func (s *atlasStream) Close() error {
|
||||
}
|
||||
|
||||
type atlascloudAPIError struct {
|
||||
Status string
|
||||
StatusCode int
|
||||
Phase string
|
||||
Summary string
|
||||
Body string
|
||||
Status string
|
||||
Code int
|
||||
Retry time.Duration
|
||||
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 {
|
||||
@@ -361,7 +376,12 @@ func (p *Provider) callAPI(ctx context.Context, phase string, req map[string]any
|
||||
|
||||
respBody, _ := io.ReadAll(httpResp.Body)
|
||||
if httpResp.StatusCode != http.StatusOK {
|
||||
return nil, nil, &atlascloudAPIError{Status: httpResp.Status, StatusCode: httpResp.StatusCode, Phase: phase, Summary: atlascloudRequestSummary(req), Body: string(respBody)}
|
||||
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)}
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
@@ -439,7 +459,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 {
|
||||
@@ -447,7 +467,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, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, 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, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, 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, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, 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, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, 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, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
|
||||
// Parse response
|
||||
|
||||
+60
@@ -4,6 +4,8 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"strconv"
|
||||
"strings"
|
||||
"time"
|
||||
)
|
||||
@@ -19,6 +21,64 @@ 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,6 +3,7 @@ package ai
|
||||
import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -221,3 +222,23 @@ 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, fmt.Errorf("API error (%s): %s", httpResp.Status, string(respBody))
|
||||
return nil, nil, ai.NewHTTPError(httpResp, respBody)
|
||||
}
|
||||
|
||||
var chatResp struct {
|
||||
|
||||
@@ -484,7 +484,7 @@ func runPlanDelegate(provider string) error {
|
||||
flow.Steps(
|
||||
flow.Step{Name: "conductor", Run: planDelegateConductorStep(conductor)},
|
||||
flow.Step{Name: "require-notify", Run: requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := conductor.Ask(ctx, "The Design, Build, and Ship tasks already exist, but the owner notification is still missing. Delegate exactly one notification to the \"comms\" agent now with this exact subtask: "+delegatedNotifyTask+" Do not create more tasks and do not answer until comms has handled the notification.")
|
||||
_, 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
|
||||
})},
|
||||
),
|
||||
@@ -554,6 +554,13 @@ 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)
|
||||
|
||||
@@ -367,6 +367,37 @@ 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"} {
|
||||
|
||||
@@ -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.Client(cl))
|
||||
svc := service.New(service.Name(name), service.Address("127.0.0.1:0"), service.Registry(reg), service.Broker(br), service.Client(cl))
|
||||
svc.Handle(h)
|
||||
go svc.Run()
|
||||
}
|
||||
@@ -429,6 +429,7 @@ 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)
|
||||
|
||||
@@ -4,19 +4,21 @@ 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 five boundaries together:
|
||||
`run.sh` verifies the complete first-agent 0→hero contract together:
|
||||
|
||||
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>`,
|
||||
1. **Scaffold** — the maintained `micro new` 0→1 contract still creates
|
||||
runnable services from a clean workspace.
|
||||
2. **First agent** — `micro agent preflight`, `micro run`, `micro chat`, and
|
||||
`micro inspect agent <name>` remain available as the documented first-agent
|
||||
walkthrough path.
|
||||
3. **Run** — `micro run` remains available as the local development entry point.
|
||||
4. **Chat** — `micro chat` remains available as the interactive agent entry point.
|
||||
5. **Inspect/debugging** — `micro inspect agent <name>`, `micro agent history <name>`,
|
||||
and `micro inspect flow <name>` remain available as the local run-history
|
||||
inspection step. The no-secret debugging smoke seeds durable agent run history
|
||||
and memory, then runs the documented inspect/history commands without provider
|
||||
credentials; `micro flow runs` preserves durable workflow history inspection.
|
||||
5. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
6. **Deploy** — `micro deploy --dry-run <target>` remains available as the
|
||||
deployment-boundary checkpoint. The dry run resolves configured deploy targets
|
||||
and services and prints the remote build/copy/systemd/health plan without
|
||||
building binaries, opening SSH connections, running `rsync`, or touching
|
||||
@@ -30,16 +32,19 @@ 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 installer seam alone with `make install-smoke`, or run the same
|
||||
no-secret contract locally with:
|
||||
verify the first-agent on-ramp links alone with `make docs-wayfinding`, verify
|
||||
the installer seam alone with `make install-smoke`, run just the documented
|
||||
agent debugging quickcheck with
|
||||
`go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1`,
|
||||
or run the same no-secret contract locally with:
|
||||
|
||||
```sh
|
||||
make harness
|
||||
```
|
||||
|
||||
That target intentionally exercises the 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 first-agent docs wayfinding guard, the
|
||||
install script smoke path, both 0→1 scaffold variants, the 0→hero scenario, the
|
||||
event-driven agent-flow harness, and mock provider conformance, so
|
||||
the public scaffold → run/chat → inspect → deploy lifecycle stays executable
|
||||
outside CI as well. Live provider checks remain separate and gated by configured
|
||||
API keys (`make provider-conformance` or the scheduled/manual CI job).
|
||||
|
||||
@@ -38,6 +38,19 @@ 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|TestZeroToHeroCLIBoundaries' -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)
|
||||
}
|
||||
}
|
||||
|
||||
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")
|
||||
@@ -49,6 +62,62 @@ 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"))
|
||||
@@ -607,6 +676,24 @@ 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")
|
||||
|
||||
@@ -6,9 +6,10 @@ cd "$ROOT"
|
||||
|
||||
# Keep the developer inner-loop boundaries executable and discoverable in CI
|
||||
# without secrets or long-running daemons.
|
||||
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|TestYourFirstAgentTutorialSmoke' -count=1
|
||||
go test ./internal/harness/zero-to-hero-ci -run 'TestNoSecretFirstAgentTranscript|TestNoSecretFirstAgentDebuggingSmoke|TestZeroToHeroReferenceDocs|TestZeroToHeroDeployDryRunCommandSmoke|TestYourFirstAgentTutorialSmoke' -count=1
|
||||
|
||||
# Deterministic no-secret reference scenarios. These use the real Go Micro
|
||||
# runtime and mock only the LLM provider. The support example is the maintained
|
||||
|
||||
@@ -116,6 +116,18 @@ 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
|
||||
|
||||
@@ -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` resolves deploy targets without touching remote infrastructure. | `go test ./cmd/micro/cli/deploy -run TestDeployDryRun -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` |
|
||||
| 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,6 +84,7 @@ 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