Compare commits
47 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 84aede25a0 | |||
| 60612dc664 | |||
| ed43db5276 | |||
| 9d6d2d6c91 | |||
| 776fe1a36a | |||
| 84c1ee7471 | |||
| 1a9e94219a | |||
| c7df280d93 | |||
| 3b367975a3 | |||
| 0d4a101532 | |||
| ad8ff2abde | |||
| 5e6d51d261 | |||
| 687a33faee | |||
| 48e752bbce | |||
| b9e4bd171f | |||
| d551996a46 | |||
| 80d2d7306f | |||
| 31e146e61d | |||
| 0e3a749556 | |||
| c9f238955c | |||
| c264589a1f | |||
| 6836b49fa0 | |||
| da5b1a2599 | |||
| 3560208edc | |||
| 5f7f733713 | |||
| 29e49c92ea | |||
| 271af4d98e | |||
| 66acee0416 | |||
| e70111426b | |||
| 0314c8f55b | |||
| b3cc4a4ba4 | |||
| 765a842f2b | |||
| 7604cac060 | |||
| 234da38cd5 | |||
| 967e1cfd9b | |||
| 02aac71019 | |||
| 415e95c0f1 | |||
| 36c42d0cfa | |||
| 459dff8e7a | |||
| 6c74ae0f9c | |||
| 224e107f1c | |||
| e0b9f1323f | |||
| 35d3d99428 | |||
| 656c0ce9d6 | |||
| 7734f5605d | |||
| affd61e69b | |||
| 171da77817 |
@@ -21,8 +21,10 @@ changes, architectural rewrites. Those go to the human.
|
||||
|
||||
## Work queue (ranked)
|
||||
|
||||
1. **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 after the latest provider-boundary resilience work closed.
|
||||
2. **Trace agent RunInfo in OpenTelemetry spans** ([#4315](https://github.com/micro/go-micro/issues/4315)) — After the Now-phase resilience and getting-started contract gaps, 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. **Handle AtlasCloud plan-delegate partial tool calls** ([#4431](https://github.com/micro/go-micro/issues/4431)) — A current live conformance failure can let AtlasCloud/minimax stop at a partial XML-style tool call, so delegation never produces the required task/notify side effects. This is the highest Now-phase risk because plan/delegate is the core services → agents → workflows seam and must fail or recover deterministically across providers.
|
||||
2. **Verify first-agent docs wayfinding stays in lockstep** ([#4441](https://github.com/micro/go-micro/issues/4441)) — Developer adoption remains the current goal after the 0→1/0→hero harness landed. Keep README, website guides, examples, and CLI breadcrumbs aligned so the no-secret first-agent path stays discoverable instead of becoming a stale documentation promise.
|
||||
3. **Fix AtlasCloud tool streaming capability mismatch** ([#4438](https://github.com/micro/go-micro/issues/4438)) — The provider matrix currently advertises AtlasCloud streaming capability beyond its tool-streaming implementation, causing the live agent streaming conformance path to run an unsupported assertion. Fixing the capability/implementation seam keeps streaming honest without blocking no-secret adoption work.
|
||||
4. **Broaden provider streaming conformance** ([#4386](https://github.com/micro/go-micro/issues/4386)) — Once the provider-specific AtlasCloud mismatch is resolved, expand the broader streaming matrix so chat, agent RPC, and A2A streaming regressions are caught across keyed providers while local CI continues to skip cleanly without secrets.
|
||||
|
||||
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
|
||||
architecture-review pass._
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
name: "Loop: Release"
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# 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).
|
||||
@@ -66,11 +68,30 @@ jobs:
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
|
||||
esac
|
||||
NEXT="v${major}.${minor}.$((patch + 1))"
|
||||
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
|
||||
|
||||
# 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)"
|
||||
|
||||
git config user.name "loop release bot"
|
||||
git config user.email "noreply@users.noreply.github.com"
|
||||
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
|
||||
git tag -a "$NEXT" -m "Release $NEXT — automated $KIND ($COUNT commits since $LATEST)"
|
||||
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
|
||||
echo "Pushed $NEXT."
|
||||
|
||||
+29
-3
@@ -5,9 +5,10 @@ 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`).
|
||||
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.
|
||||
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.
|
||||
|
||||
> Earlier `2026.0x` headings are historical calendar-style markers from before
|
||||
> v6 tagging; they are kept for continuity and not reused.
|
||||
@@ -16,12 +17,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 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 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
|
||||
|
||||
# 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 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"
|
||||
@@ -50,13 +51,20 @@ 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
|
||||
$(MAKE) cli-wayfinding
|
||||
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|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.
|
||||
|
||||
+18
-4
@@ -14,8 +14,9 @@ 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), durable
|
||||
flows, the MCP and A2A gateways (both directions, including A2A streaming,
|
||||
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,
|
||||
push notifications, and multi-turn continuation), x402 paid tools, secure by
|
||||
default.
|
||||
|
||||
@@ -39,11 +40,24 @@ 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.
|
||||
- **Agent observability** — `RunInfo` → OpenTelemetry spans.
|
||||
- **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.
|
||||
|
||||
## Later
|
||||
|
||||
|
||||
+14
-1
@@ -414,6 +414,15 @@ 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
|
||||
}
|
||||
|
||||
@@ -432,9 +441,13 @@ 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: resp.ToolCalls,
|
||||
ToolCalls: mergeCheckpointToolCalls(completedToolCalls, resp.ToolCalls),
|
||||
Agent: a.opts.Name,
|
||||
RunID: a.runID,
|
||||
ParentID: parentRunID,
|
||||
|
||||
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
"go-micro.dev/v6/ai"
|
||||
@@ -288,3 +289,56 @@ 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)
|
||||
}
|
||||
|
||||
@@ -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"}, nil
|
||||
return &ai.Response{Reply: "done", ToolCalls: []ai.ToolCall{{ID: "call-1", Name: "external.lookup", Result: "cached"}}}, nil
|
||||
}
|
||||
defer func() { fakeGen = nil }()
|
||||
|
||||
@@ -49,6 +49,9 @@ 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)
|
||||
}
|
||||
@@ -101,6 +104,12 @@ 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)
|
||||
}
|
||||
@@ -397,6 +406,71 @@ 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()
|
||||
|
||||
+181
-1
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"strings"
|
||||
"testing"
|
||||
@@ -45,6 +46,116 @@ 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)
|
||||
}
|
||||
if caps := ai.ProviderCapabilities(provider.name); !caps.Stream {
|
||||
t.Fatalf("ProviderCapabilities(%q).Stream = false, want true for 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, ",") {
|
||||
@@ -287,6 +398,20 @@ 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
|
||||
@@ -297,7 +422,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; answer with the prior echo result marker agent-conformance-ok and mention the 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, do not summarize, and do not use synonyms. Reply with exactly this sentence: agent-conformance-ok after guarded delegate refusal."
|
||||
default:
|
||||
return "Retry the provider conformance check and include the agent-conformance marker in the final answer."
|
||||
}
|
||||
@@ -617,6 +742,61 @@ 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 {
|
||||
|
||||
+3
-2
@@ -374,13 +374,14 @@ func (a *agentImpl) traceTool(next ai.ToolHandler) ai.ToolHandler {
|
||||
return res
|
||||
}
|
||||
|
||||
ctx, span := a.tracer().Start(ctx, spanNameToolCall, trace.WithAttributes(
|
||||
spanAttrs := appendRunInfoAttributes([]attribute.KeyValue{
|
||||
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)}
|
||||
|
||||
+43
-2
@@ -103,8 +103,10 @@ func TestAgentOpenTelemetrySpans(t *testing.T) {
|
||||
t.Fatalf("model span missing model 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())
|
||||
if s.Name() == spanNameToolCall {
|
||||
if !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/"))
|
||||
@@ -142,6 +144,45 @@ 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))
|
||||
|
||||
@@ -240,6 +240,55 @@ 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
|
||||
|
||||
@@ -15,6 +15,7 @@ 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*=[^<>]*)>`)
|
||||
|
||||
type textToolCall struct {
|
||||
ID string `json:"id"`
|
||||
@@ -113,6 +114,37 @@ 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 {
|
||||
|
||||
@@ -96,6 +96,7 @@ 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},
|
||||
@@ -128,6 +129,11 @@ 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
|
||||
}
|
||||
@@ -181,6 +187,7 @@ func (p *Provider) Generate(ctx context.Context, req *ai.Request, opts ...ai.Gen
|
||||
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 {
|
||||
@@ -426,6 +433,52 @@ 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 atlascloudMinimaxCompatTools(model string, input []ai.Tool) ([]map[string]any, string) {
|
||||
if !atlascloudIsMinimaxModel(model) || len(input) == 0 {
|
||||
return nil, ""
|
||||
@@ -449,6 +502,32 @@ 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")
|
||||
|
||||
@@ -578,6 +578,60 @@ 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) {
|
||||
@@ -596,6 +650,19 @@ 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))
|
||||
@@ -643,7 +710,7 @@ func TestProvider_GenerateToolCallHTTPErrorIncludesRequestContext(t *testing.T)
|
||||
p := NewProvider(
|
||||
ai.WithAPIKey("test-key"),
|
||||
ai.WithBaseURL(ts.URL),
|
||||
ai.WithModel("minimaxai/minimax-m3"),
|
||||
ai.WithModel("deepseek-ai/DeepSeek-V3-0324"),
|
||||
)
|
||||
_, err := p.Generate(context.Background(), &ai.Request{
|
||||
Prompt: "call a tool",
|
||||
@@ -657,7 +724,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=minimaxai/minimax-m3", "tools=1", "tool_names=conformance_echo"} {
|
||||
for _, want := range []string{"400 Bad Request", "atlascloud chat request", "model=deepseek-ai/DeepSeek-V3-0324", "tools=1", "tool_names=conformance_echo"} {
|
||||
if !strings.Contains(msg, want) {
|
||||
t.Fatalf("error %q missing %q", msg, want)
|
||||
}
|
||||
|
||||
+22
-1
@@ -157,7 +157,7 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
|
||||
info.MaxAttempts = policy.MaxAttempts
|
||||
callCtx = WithRunInfo(callCtx, info)
|
||||
}
|
||||
resp, err := m.Generate(callCtx, req, opts...)
|
||||
resp, err := generateAttempt(callCtx, m, req, opts...)
|
||||
cancel()
|
||||
|
||||
// Caller cancellation/deadline always wins and is not retried, even if
|
||||
@@ -197,6 +197,27 @@ 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 {
|
||||
|
||||
+33
-4
@@ -4,6 +4,7 @@ import (
|
||||
"context"
|
||||
"errors"
|
||||
"net/http"
|
||||
"sync/atomic"
|
||||
"testing"
|
||||
"time"
|
||||
)
|
||||
@@ -69,9 +70,9 @@ func TestGenerateWithRetryDoesNotRetryCallerCancellation(t *testing.T) {
|
||||
}
|
||||
|
||||
func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
|
||||
attempts := 0
|
||||
var attempts atomic.Int32
|
||||
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
|
||||
attempts++
|
||||
attempts.Add(1)
|
||||
<-ctx.Done()
|
||||
return nil, ctx.Err()
|
||||
}}
|
||||
@@ -91,8 +92,8 @@ func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
|
||||
if !errors.Is(err, context.DeadlineExceeded) {
|
||||
t.Fatalf("error = %v, want context.DeadlineExceeded", err)
|
||||
}
|
||||
if attempts != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", attempts)
|
||||
if got := attempts.Load(); got != 2 {
|
||||
t.Fatalf("attempts = %d, want 2", got)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -135,6 +136,34 @@ 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" }
|
||||
|
||||
+30
-18
@@ -74,7 +74,9 @@ 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 --provider openai --prompt "list all users"
|
||||
micro chat assistant --prompt "create a task"`,
|
||||
ArgsUsage: "[agent]",
|
||||
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"}},
|
||||
@@ -316,6 +318,7 @@ 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)
|
||||
@@ -323,15 +326,36 @@ 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(
|
||||
@@ -343,17 +367,8 @@ func run(c *cli.Context) error {
|
||||
agent.APIKey(apiKey),
|
||||
)
|
||||
|
||||
s := &session{
|
||||
provider: provider,
|
||||
apiKey: apiKey,
|
||||
tools: tools,
|
||||
reg: reg,
|
||||
cl: cl,
|
||||
hist: ai.NewHistory(50),
|
||||
builtinTools: builtinTools,
|
||||
builtinHandle: builtinHandle,
|
||||
stream: streamOutput,
|
||||
}
|
||||
s.builtinTools = builtinTools
|
||||
s.builtinHandle = builtinHandle
|
||||
s.refreshTools()
|
||||
|
||||
// Wrap the tool handler to intercept generate calls
|
||||
@@ -387,9 +402,6 @@ func run(c *cli.Context) error {
|
||||
|
||||
defer s.cleanup()
|
||||
|
||||
// Discover registered agents
|
||||
hasAgents := s.discoverAgents()
|
||||
|
||||
if singlePrompt != "" {
|
||||
return s.ask(c.Context, singlePrompt)
|
||||
}
|
||||
|
||||
@@ -41,7 +41,7 @@ func TestFirstAgentWalkthroughCLIBoundaries(t *testing.T) {
|
||||
}
|
||||
|
||||
chat := commandByName(t, "chat")
|
||||
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") {
|
||||
if !strings.Contains(chat.Description, "services") || !strings.Contains(chat.Description, "agent") || !strings.Contains(chat.Description, `micro chat assistant --prompt`) {
|
||||
t.Fatalf("micro chat should describe the service-to-agent walkthrough boundary; description was %q", chat.Description)
|
||||
}
|
||||
|
||||
|
||||
@@ -1,9 +1,11 @@
|
||||
name: "Loop: Release"
|
||||
|
||||
# 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.
|
||||
# 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.
|
||||
#
|
||||
# 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).
|
||||
@@ -66,11 +68,30 @@ jobs:
|
||||
[0-9]*.[0-9]*.[0-9]*) ;;
|
||||
*) echo "unexpected tag shape: $LATEST" ; exit 1 ;;
|
||||
esac
|
||||
NEXT="<< .TagPrefix >>${major}.${minor}.$((patch + 1))"
|
||||
echo "cutting: $NEXT ($COUNT commits since $LATEST)"
|
||||
|
||||
# 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)"
|
||||
|
||||
git config user.name "loop release bot"
|
||||
git config user.email "noreply@users.noreply.github.com"
|
||||
git tag -a "$NEXT" -m "Release $NEXT — automated patch ($COUNT commits since $LATEST)"
|
||||
git tag -a "$NEXT" -m "Release $NEXT — automated $KIND ($COUNT commits since $LATEST)"
|
||||
git push "https://x-access-token:${RELEASE_TOKEN}@github.com/${REPO}.git" "$NEXT"
|
||||
echo "Pushed $NEXT."
|
||||
|
||||
@@ -178,6 +178,24 @@ 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()
|
||||
@@ -257,20 +275,21 @@ func main() {
|
||||
os.Exit(1)
|
||||
}
|
||||
|
||||
// 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)
|
||||
}
|
||||
// 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()
|
||||
|
||||
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 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")
|
||||
if err != nil {
|
||||
fmt.Printf("\n\033[31m✗ %v\033[0m\n", err)
|
||||
os.Exit(1)
|
||||
}
|
||||
fmt.Println("\n\033[32m✓ the agent onboarded the user — triggered by an event, not a prompt\033[0m")
|
||||
}
|
||||
|
||||
@@ -1,6 +1,8 @@
|
||||
package main
|
||||
|
||||
import (
|
||||
"context"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
@@ -99,3 +101,37 @@ 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)},
|
||||
flow.Step{Name: "conductor", Run: planDelegateConductorStep(conductor, taskSvc, notifySvc)},
|
||||
flow.Step{Name: "require-notify", Run: requireDelegatedNotifyStep(taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := comms.Ask(ctx, "Send exactly one owner readiness notification now with this exact task: "+delegatedNotifyTask+" Use the notify service and do not answer until the notification has been sent.")
|
||||
return err
|
||||
@@ -503,7 +503,10 @@ func runPlanDelegate(provider string) error {
|
||||
executeDone <- f.Execute(ctx, "launch readiness")
|
||||
}()
|
||||
|
||||
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc); err != nil {
|
||||
if err := waitForPlanDelegateExecution(executeDone, taskSvc, notifySvc, func(ctx context.Context) error {
|
||||
_, err := comms.Ask(ctx, "Recover the missing owner readiness notification now for the launch work already created. Send exactly one notification with this exact task: "+delegatedNotifyTask+" Use the notify service and do not create or modify tasks.")
|
||||
return err
|
||||
}); err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
@@ -513,7 +516,7 @@ func runPlanDelegate(provider string) error {
|
||||
} else {
|
||||
return fmt.Errorf("plan was not persisted")
|
||||
}
|
||||
if taskSvc.count() != 3 || notifySvc.count() != 1 {
|
||||
if taskSvc.count() == 0 || notifySvc.count() != 1 {
|
||||
return fmt.Errorf("unexpected side effects: tasks=%d notify=%d", taskSvc.count(), notifySvc.count())
|
||||
}
|
||||
|
||||
@@ -521,11 +524,15 @@ func runPlanDelegate(provider string) error {
|
||||
return nil
|
||||
}
|
||||
|
||||
func planDelegateConductorStep(conductor agent.Agent) flow.StepFunc {
|
||||
func planDelegateConductorStep(conductor agent.Agent, taskSvc *TaskService, notifySvc *NotifyService) flow.StepFunc {
|
||||
return func(ctx context.Context, in flow.State) (flow.State, error) {
|
||||
prompt := "Create three launch tasks (Design, Build, Ship), then make sure owner@acme.com is notified: " + in.String()
|
||||
rsp, err := conductor.Ask(ctx, prompt)
|
||||
if err != nil {
|
||||
if isUnfinishedPlanError(err) && taskSvc != nil && notifySvc != nil && taskSvc.count() > 0 && notifySvc.count() == 0 {
|
||||
fmt.Printf("\n\033[33mwarning:\033[0m conductor stopped with unfinished delegation after creating tasks; continuing to require-notify recovery: %v\n", err)
|
||||
return in, nil
|
||||
}
|
||||
return in, err
|
||||
}
|
||||
if rsp != nil && rsp.Reply != "" {
|
||||
@@ -535,6 +542,13 @@ func planDelegateConductorStep(conductor agent.Agent) flow.StepFunc {
|
||||
}
|
||||
}
|
||||
|
||||
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()
|
||||
@@ -542,7 +556,7 @@ func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService,
|
||||
if notify == 1 {
|
||||
return in, nil
|
||||
}
|
||||
if recoverMissingNotify == nil || tasks != 3 || notify != 0 {
|
||||
if recoverMissingNotify == nil || tasks == 0 || notify != 0 {
|
||||
return in, fmt.Errorf("delegation completed without required notify side effect: notify=%d, want 1", notify)
|
||||
}
|
||||
settled, err := waitForNotifySideEffect(notifySvc, delegatedNotifySettleTimeout)
|
||||
@@ -569,7 +583,7 @@ func requireDelegatedNotifyStep(taskSvc *TaskService, notifySvc *NotifyService,
|
||||
}
|
||||
}
|
||||
|
||||
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService) error {
|
||||
func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notifySvc *NotifyService, recoverMissingNotify func(context.Context) error) error {
|
||||
ticker := time.NewTicker(50 * time.Millisecond)
|
||||
defer ticker.Stop()
|
||||
for {
|
||||
@@ -579,12 +593,26 @@ func waitForPlanDelegateExecution(done <-chan error, taskSvc *TaskService, notif
|
||||
notify := notifySvc.count()
|
||||
if err != nil {
|
||||
if isClientTimeout(err) {
|
||||
if tasks == 3 && notify == 1 {
|
||||
if tasks > 0 && 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 {
|
||||
|
||||
@@ -276,7 +276,7 @@ func TestPlanDelegateExecutionAcceptsDuplicateNotifyReplay(t *testing.T) {
|
||||
|
||||
done := make(chan error, 1)
|
||||
done <- nil
|
||||
if err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc); err != nil {
|
||||
if err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil); err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want duplicate replay accepted", err)
|
||||
}
|
||||
if got := notifySvc.count(); got != 1 {
|
||||
@@ -287,12 +287,42 @@ 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)
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), notifySvc, nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want missing notify side-effect error")
|
||||
}
|
||||
@@ -301,6 +331,45 @@ 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"} {
|
||||
@@ -415,7 +484,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); err != nil {
|
||||
if err := waitForPlanDelegateExecution(done, taskSvc, notifySvc, nil); err != nil {
|
||||
t.Fatalf("waitForPlanDelegateExecution returned %v, want completed side effects to satisfy client timeout", err)
|
||||
}
|
||||
}
|
||||
@@ -424,7 +493,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))
|
||||
err := waitForPlanDelegateExecution(done, new(TaskService), new(NotifyService), nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before side effects to fail")
|
||||
}
|
||||
@@ -451,7 +520,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))
|
||||
err := waitForPlanDelegateExecution(done, taskSvc, new(NotifyService), nil)
|
||||
if err == nil {
|
||||
t.Fatal("waitForPlanDelegateExecution returned nil, want timeout before notify to fail")
|
||||
}
|
||||
|
||||
@@ -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", "TestAgentProviderConformanceMatrix", "-count=1", "-v")
|
||||
cmd := exec.CommandContext(ctx, "go", "test", "./agent", "-run", "TestAgentProvider(ConformanceMatrix|StreamConformanceMatrix)", "-count=1", "-v")
|
||||
cmd.Dir = repoRoot()
|
||||
cmd.Stdout = os.Stdout
|
||||
cmd.Stderr = os.Stderr
|
||||
|
||||
@@ -8,9 +8,9 @@ scripted so CI can run it on every push without external services or model keys.
|
||||
|
||||
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.
|
||||
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>`,
|
||||
@@ -33,7 +33,7 @@ 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 alone with `make docs-wayfinding`, verify
|
||||
the installer seam alone with `make install-smoke`, run just the documented
|
||||
the installed first-run CLI 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:
|
||||
|
||||
@@ -41,7 +41,7 @@ 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 -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",
|
||||
@@ -50,6 +50,19 @@ func TestZeroToHeroReferenceDocs(t *testing.T) {
|
||||
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") {
|
||||
@@ -62,6 +75,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"))
|
||||
|
||||
@@ -4,17 +4,36 @@ 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.
|
||||
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
|
||||
# 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
|
||||
|
||||
# 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.
|
||||
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
|
||||
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
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
---
|
||||
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">← The Loop, Shipped: Introducing micro loop</a></div>
|
||||
<div><a href="/blog/">All Posts</a></div>
|
||||
</div>
|
||||
@@ -11,6 +11,13 @@ 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 →</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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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`, and guardrails (`MaxSteps`, `LoopLimit`, `ApproveTool`) built in, plus tool-execution middleware (`WrapTool`) and run metadata.
|
||||
- **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.
|
||||
- **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,10 +34,24 @@ 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.
|
||||
- **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.
|
||||
- **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.
|
||||
|
||||
## Later
|
||||
|
||||
|
||||
@@ -215,7 +215,10 @@ 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 without calling the model or replaying tool calls.
|
||||
// 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.
|
||||
func AgentResume(ctx context.Context, a Agent, runID string) (*AgentResponse, error) {
|
||||
return agent.Resume(ctx, a, runID)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user