chore: import upstream snapshot with attribution
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
govulncheck / govulncheck (push) Has been cancelled
Lint / golangci-lint (push) Has been cancelled
Run Tests / Unit Tests (push) Has been cancelled
Run Tests / Etcd Integration Tests (push) Has been cancelled
Harness (E2E) / Harnesses (mock LLM) (push) Has been cancelled
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Has been cancelled
This commit is contained in:
@@ -0,0 +1,467 @@
|
||||
// Package agent registers the 'micro agent' CLI commands.
|
||||
package agent
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"os"
|
||||
"time"
|
||||
|
||||
"github.com/urfave/cli/v2"
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/cmd"
|
||||
aiflow "go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
const firstAgentQuickChecksHelp = `First-agent failure-mode quick checks
|
||||
|
||||
Use this when scaffold -> run -> chat -> inspect stalls and you want the
|
||||
smallest provider-free recovery loop before reading the full docs.
|
||||
|
||||
1. Confirm prerequisites before starting the gateway:
|
||||
micro agent preflight
|
||||
|
||||
2. Start the project and keep it running in a separate terminal:
|
||||
micro run
|
||||
|
||||
3. Check the agent is registered and the chat gateway is reachable:
|
||||
micro agent doctor
|
||||
|
||||
4. If chat returns an answer or an error, inspect the latest run state:
|
||||
micro inspect agent <name>
|
||||
micro runs <name>
|
||||
|
||||
5. If provider chat is not configured yet, prove the no-secret path still works:
|
||||
micro agent demo
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1
|
||||
|
||||
Recovery docs:
|
||||
https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
https://go-micro.dev/docs/guides/no-secret-first-agent.html`
|
||||
|
||||
const noSecretDemoHelp = `No-secret first-agent demo
|
||||
|
||||
Use this when you want the fastest provider-free agent success path before
|
||||
configuring API keys. It runs the maintained support/first-agent transcript with
|
||||
the deterministic mock model used by CI:
|
||||
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1
|
||||
|
||||
What this proves:
|
||||
- service tools can be called by an agent
|
||||
- chat behavior is exercised without contacting a live provider
|
||||
- run history can be inspected after the prompt
|
||||
- the debug smoke seeds a stalled-first-agent recovery transcript
|
||||
|
||||
After it passes:
|
||||
- Build your own service-backed agent: https://go-micro.dev/docs/guides/your-first-agent.html
|
||||
- Diagnose provider-backed chat: https://go-micro.dev/docs/guides/debugging-agents.html
|
||||
- Walk the full 0→hero lifecycle: https://go-micro.dev/docs/guides/zero-to-hero.html
|
||||
|
||||
Use live-provider chat when you are ready for real model behavior:
|
||||
micro agent preflight # before micro run: prerequisites
|
||||
micro run
|
||||
micro chat
|
||||
micro agent doctor # after micro run: chat/gateway/inspect recovery
|
||||
micro inspect agent <name>
|
||||
|
||||
Debug transcript smoke:
|
||||
go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1`
|
||||
|
||||
func init() {
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "runs",
|
||||
Usage: "Show recorded agent runs",
|
||||
ArgsUsage: "[agent] [run-id]",
|
||||
Flags: runFlags(),
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
return fmt.Errorf("usage: micro runs [agent] [run-id]")
|
||||
}
|
||||
if runID := c.Args().Get(1); runID != "" {
|
||||
return printRunHistory(name, runID, c.Bool("json"))
|
||||
}
|
||||
return printRunIndex(name, runOptions(c), c.Bool("json"))
|
||||
},
|
||||
})
|
||||
|
||||
cmd.Register(&cli.Command{
|
||||
Name: "agent",
|
||||
Usage: "Manage AI agents (try: micro agent demo)",
|
||||
Subcommands: []*cli.Command{
|
||||
{
|
||||
Name: "demo",
|
||||
Usage: "Show the no-secret first-agent demo command",
|
||||
Description: `Print the provider-free first-agent path for new developers:
|
||||
the deterministic mock-model transcript, when to use it, and where to go next
|
||||
for live-provider chat and inspect/debugging.`,
|
||||
Action: func(c *cli.Context) error {
|
||||
fmt.Fprintln(c.App.Writer, noSecretDemoHelp)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "quickcheck",
|
||||
Aliases: []string{"debug"},
|
||||
Usage: "Print first-agent failure-mode quick checks",
|
||||
Description: `Print provider-free recovery breadcrumbs for the scaffold -> run ->
|
||||
chat -> inspect loop, including exact commands for registration, gateway, run
|
||||
history, and no-secret fallback checks.`,
|
||||
Action: func(c *cli.Context) error {
|
||||
fmt.Fprintln(c.App.Writer, firstAgentQuickChecksHelp)
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "preflight",
|
||||
Usage: "Check local prerequisites before the first provider-backed agent",
|
||||
Action: func(c *cli.Context) error {
|
||||
return runAgentPreflight(os.Stdout, defaultPreflightDeps())
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "doctor",
|
||||
Usage: "Diagnose chat, gateway, registration, provider, and inspect recovery after micro run",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "gateway", Value: "http://localhost:8080", Usage: "Gateway URL started by micro run"},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
return runAgentDoctor(os.Stdout, defaultDoctorDeps(), c.String("gateway"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "list",
|
||||
Usage: "List registered agents",
|
||||
Action: func(c *cli.Context) error {
|
||||
svcs, err := registry.ListServices()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
found := false
|
||||
for _, svc := range svcs {
|
||||
records, err := registry.GetService(svc.Name)
|
||||
if err != nil || len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
meta := records[0].Metadata
|
||||
if meta == nil || meta["type"] != "agent" {
|
||||
if len(records[0].Nodes) > 0 {
|
||||
meta = records[0].Nodes[0].Metadata
|
||||
}
|
||||
if meta == nil || meta["type"] != "agent" {
|
||||
continue
|
||||
}
|
||||
}
|
||||
found = true
|
||||
services := meta["services"]
|
||||
if services == "" {
|
||||
services = "(all)"
|
||||
}
|
||||
fmt.Printf(" \033[35m◆\033[0m %-20s manages: %s\n", svc.Name, services)
|
||||
}
|
||||
if !found {
|
||||
fmt.Println(" No agents registered.")
|
||||
fmt.Println()
|
||||
fmt.Println(" Start an agent with:")
|
||||
fmt.Println(" micro run (if agents are part of your project)")
|
||||
}
|
||||
return nil
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "describe",
|
||||
Usage: "Describe an agent",
|
||||
ArgsUsage: "[name]",
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
return fmt.Errorf("usage: micro agent describe [name]")
|
||||
}
|
||||
records, err := registry.GetService(name)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if len(records) == 0 {
|
||||
return fmt.Errorf("agent %s not found", name)
|
||||
}
|
||||
b, _ := json.MarshalIndent(records[0], "", " ")
|
||||
fmt.Println(string(b))
|
||||
return nil
|
||||
},
|
||||
},
|
||||
|
||||
{
|
||||
Name: "resume-input",
|
||||
Usage: "Continue an input-required agent run with human input",
|
||||
ArgsUsage: "[name] [run-id]",
|
||||
Flags: []cli.Flag{
|
||||
&cli.StringFlag{Name: "input", Usage: "Human input to provide to the paused run", Required: true},
|
||||
},
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
runID := c.Args().Get(1)
|
||||
if name == "" || runID == "" {
|
||||
return fmt.Errorf("usage: micro agent resume-input [name] [run-id] --input <text>")
|
||||
}
|
||||
return resumeInputRun(context.Background(), c.App.Writer, name, runID, c.String("input"))
|
||||
},
|
||||
},
|
||||
{
|
||||
Name: "history",
|
||||
Usage: "Show an agent's stored conversation and run history",
|
||||
ArgsUsage: "[name] [run-id]",
|
||||
Flags: runFlags(),
|
||||
Action: func(c *cli.Context) error {
|
||||
name := c.Args().First()
|
||||
if name == "" {
|
||||
return fmt.Errorf("usage: micro agent history [name] [run-id]")
|
||||
}
|
||||
if runID := c.Args().Get(1); runID != "" {
|
||||
return printRunHistory(name, runID, c.Bool("json"))
|
||||
}
|
||||
if c.Bool("json") {
|
||||
return printRunIndex(name, runOptions(c), true)
|
||||
}
|
||||
// Read from the agent's scoped state store (database
|
||||
// "agent", table = name) — available whether or not the
|
||||
// agent is currently running.
|
||||
mem := goagent.NewMemory(store.Scope(store.DefaultStore, "agent", name), "history", 1000)
|
||||
msgs := mem.Messages()
|
||||
if len(msgs) == 0 {
|
||||
fmt.Printf(" No history for agent %q.\n", name)
|
||||
} else {
|
||||
for _, m := range msgs {
|
||||
fmt.Printf(" \033[2m%s:\033[0m %v\n", m.Role, m.Content)
|
||||
}
|
||||
}
|
||||
return printRunIndex(name, runOptions(c), c.Bool("json"))
|
||||
},
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
func runFlags() []cli.Flag {
|
||||
return []cli.Flag{
|
||||
&cli.BoolFlag{Name: "json", Usage: "Print run data as JSON for automation"},
|
||||
&cli.StringFlag{Name: "status", Usage: "Only show runs with this status (running, done, error, refused)"},
|
||||
&cli.StringFlag{Name: "trace", Usage: "Only show runs whose trace id matches this full id or prefix"},
|
||||
&cli.IntFlag{Name: "limit", Usage: "Show the most recently updated N runs"},
|
||||
}
|
||||
}
|
||||
|
||||
func runOptions(c *cli.Context) goagent.RunListOptions {
|
||||
return goagent.RunListOptions{Status: c.String("status"), TraceID: c.String("trace"), Limit: c.Int("limit")}
|
||||
}
|
||||
|
||||
func printRunIndex(name string, opts goagent.RunListOptions, asJSON bool) error {
|
||||
runs, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, name, opts)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRunIndex(os.Stdout, name, runs, asJSON)
|
||||
}
|
||||
|
||||
func writeRunIndex(w io.Writer, name string, runs []goagent.RunSummary, asJSON bool) error {
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(runs)
|
||||
}
|
||||
if len(runs) == 0 {
|
||||
fmt.Fprintf(w, " No runs recorded for agent %q.\n", name)
|
||||
return nil
|
||||
}
|
||||
fmt.Fprintln(w, " Runs:")
|
||||
for _, run := range runs {
|
||||
line := fmt.Sprintf(" %s status=%s events=%d duration=%s last=%s updated=%s", run.RunID, run.Status, run.Events, formatDurationMS(run.DurationMS), run.LastKind, run.UpdatedAt.Format("2006-01-02 15:04:05"))
|
||||
if run.ParentID != "" {
|
||||
line += " parent=" + run.ParentID
|
||||
}
|
||||
if run.TraceID != "" {
|
||||
line += " trace=" + shortTraceID(run.TraceID)
|
||||
}
|
||||
if run.Checkpoint != "" {
|
||||
line += " checkpoint=" + run.Checkpoint
|
||||
}
|
||||
if run.Stage != "" {
|
||||
line += " stage=" + run.Stage
|
||||
}
|
||||
if run.LastError != "" {
|
||||
line += " error=" + run.LastError
|
||||
}
|
||||
fmt.Fprintln(w, line)
|
||||
writeRunIndexBreadcrumbs(w, name, run)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func writeRunIndexBreadcrumbs(w io.Writer, name string, run goagent.RunSummary) {
|
||||
if run.Stage == "input-required" {
|
||||
fmt.Fprintf(w, " inspect: micro agent history %s %s\n", name, run.RunID)
|
||||
fmt.Fprintf(w, " input: micro agent resume-input %s %s --input <text>\n", name, run.RunID)
|
||||
return
|
||||
}
|
||||
if !isResumableRunSummary(run) {
|
||||
return
|
||||
}
|
||||
fmt.Fprintf(w, " inspect: micro agent history %s %s\n", name, run.RunID)
|
||||
fmt.Fprintf(w, " resume: call micro.AgentResume(ctx, agent, %q) after recreating the agent with the same checkpoint store\n", run.RunID)
|
||||
fmt.Fprintf(w, " stream: call micro.ResumeStreamAsk(ctx, agent, %q) to resume with streaming events\n", run.RunID)
|
||||
}
|
||||
|
||||
func isResumableRunSummary(run goagent.RunSummary) bool {
|
||||
switch run.Status {
|
||||
case "running", "error", "failed", "refused":
|
||||
return run.Checkpoint != "done" || run.Stage != ""
|
||||
default:
|
||||
return false
|
||||
}
|
||||
}
|
||||
|
||||
func printRunHistory(name, runID string, asJSON bool) error {
|
||||
events, err := goagent.LoadRunEvents(store.DefaultStore, name, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return writeRunHistory(os.Stdout, name, runID, events, asJSON)
|
||||
}
|
||||
|
||||
func writeRunHistory(w io.Writer, name, runID string, events []goagent.RunEvent, asJSON bool) error {
|
||||
if asJSON {
|
||||
enc := json.NewEncoder(w)
|
||||
enc.SetIndent("", " ")
|
||||
return enc.Encode(events)
|
||||
}
|
||||
if len(events) == 0 {
|
||||
fmt.Fprintf(w, " No run %q for agent %q.\n", runID, name)
|
||||
return nil
|
||||
}
|
||||
for _, e := range events {
|
||||
line := fmt.Sprintf(" %s %-5s", e.Time.Format("15:04:05.000"), e.Kind)
|
||||
if e.Name != "" {
|
||||
line += " " + e.Name
|
||||
}
|
||||
if e.Provider != "" || e.Model != "" {
|
||||
line += fmt.Sprintf(" %s/%s", e.Provider, e.Model)
|
||||
}
|
||||
if e.LatencyMS > 0 {
|
||||
line += fmt.Sprintf(" %dms", e.LatencyMS)
|
||||
}
|
||||
if e.Tokens.TotalTokens > 0 {
|
||||
line += fmt.Sprintf(" tokens=%d", e.Tokens.TotalTokens)
|
||||
}
|
||||
if e.ParentID != "" {
|
||||
line += " parent=" + e.ParentID
|
||||
}
|
||||
if e.TraceID != "" {
|
||||
line += " trace=" + shortTraceID(e.TraceID)
|
||||
}
|
||||
if e.Refused != "" {
|
||||
line += " refused=" + e.Refused
|
||||
}
|
||||
if e.Error != "" {
|
||||
line += " error=" + e.Error
|
||||
}
|
||||
fmt.Fprintln(w, line)
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func formatDurationMS(ms int64) string {
|
||||
if ms <= 0 {
|
||||
return "0ms"
|
||||
}
|
||||
if ms < 1000 {
|
||||
return fmt.Sprintf("%dms", ms)
|
||||
}
|
||||
return fmt.Sprintf("%.1fs", float64(ms)/1000)
|
||||
}
|
||||
|
||||
func shortTraceID(id string) string {
|
||||
if len(id) <= 12 {
|
||||
return id
|
||||
}
|
||||
return id[:12]
|
||||
}
|
||||
|
||||
type cliInputPause struct {
|
||||
OriginalMessage string `json:"original_message"`
|
||||
Prompt string `json:"prompt"`
|
||||
}
|
||||
|
||||
func resumeInputRun(ctx context.Context, w io.Writer, name, runID, input string) error {
|
||||
if input == "" {
|
||||
return fmt.Errorf("input required: pass --input <text>")
|
||||
}
|
||||
cp := aiflow.StoreCheckpoint(store.DefaultStore, name)
|
||||
run, ok, err := cp.Load(ctx, runID)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if !ok {
|
||||
return fmt.Errorf("agent run %s not found for %q", runID, name)
|
||||
}
|
||||
if run.Status != "paused" || run.State.Stage != "input-required" {
|
||||
return fmt.Errorf("agent run %s is not waiting for human input", runID)
|
||||
}
|
||||
var pause cliInputPause
|
||||
_ = run.State.Scan(&pause)
|
||||
reply := "Human input recorded; recreate the agent with the same checkpoint store and call micro.AgentResumeInput to continue model execution."
|
||||
resp := goagent.Response{Reply: reply, Agent: name, RunID: runID, ParentID: run.ParentID}
|
||||
data, err := json.Marshal(resp)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
run.Status = "done"
|
||||
run.State.Stage = "done"
|
||||
run.State.Data = data
|
||||
for i := range run.Steps {
|
||||
if run.Steps[i].Status == "paused" || run.Steps[i].Name == "ask" {
|
||||
run.Steps[i].Status = "done"
|
||||
run.Steps[i].Error = ""
|
||||
run.Steps[i].Result = "human input: " + input
|
||||
}
|
||||
}
|
||||
if len(run.Steps) == 0 {
|
||||
run.Steps = []aiflow.StepRecord{{Name: "ask", Status: "done", Result: "human input: " + input}}
|
||||
}
|
||||
if err := cp.Save(ctx, run); err != nil {
|
||||
return err
|
||||
}
|
||||
if err := recordCLIResumeEvents(name, runID, run.ParentID); err != nil {
|
||||
return err
|
||||
}
|
||||
if pause.Prompt != "" {
|
||||
fmt.Fprintf(w, " Prompt: %s\n", pause.Prompt)
|
||||
}
|
||||
fmt.Fprintf(w, " Recorded input for agent %q run %s.\n", name, runID)
|
||||
fmt.Fprintf(w, " Inspect: micro inspect agent %s --limit 1\n", name)
|
||||
return nil
|
||||
}
|
||||
|
||||
func recordCLIResumeEvents(name, runID, parentID string) error {
|
||||
now := time.Now()
|
||||
scoped := store.Scope(store.DefaultStore, "agent", name)
|
||||
events := []goagent.RunEvent{
|
||||
{Time: now, RunID: runID, ParentID: parentID, Agent: name, Kind: "checkpoint", Name: "done", Status: "done"},
|
||||
{Time: now.Add(time.Nanosecond), RunID: runID, ParentID: parentID, Agent: name, Kind: "done"},
|
||||
}
|
||||
for _, e := range events {
|
||||
b, err := json.Marshal(e)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
key := fmt.Sprintf("runs/%s/%020d-%s", runID, e.Time.UnixNano(), e.Kind)
|
||||
if err := scoped.Write(&store.Record{Key: key, Value: b}); err != nil {
|
||||
return err
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
@@ -0,0 +1,180 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"strings"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/ai"
|
||||
aiflow "go-micro.dev/v6/flow"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
func TestWriteRunIndexJSON(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{
|
||||
RunID: "run-1",
|
||||
Agent: "runner",
|
||||
StartedAt: time.Unix(0, 1),
|
||||
UpdatedAt: time.Unix(0, 2),
|
||||
DurationMS: 1234,
|
||||
Events: 2,
|
||||
Status: "done",
|
||||
LastKind: "tool",
|
||||
TraceID: "1234567890abcdef",
|
||||
}}
|
||||
var out bytes.Buffer
|
||||
if err := writeRunIndex(&out, "runner", runs, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []goagent.RunSummary
|
||||
if err := json.Unmarshal(out.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, out.String())
|
||||
}
|
||||
if len(got) != 1 || got[0].RunID != "run-1" || got[0].LastKind != "tool" {
|
||||
t.Fatalf("decoded summaries = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRunIndexHumanIncludesStatusAndDuration(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{
|
||||
RunID: "run-1",
|
||||
Agent: "runner",
|
||||
UpdatedAt: time.Date(2026, 6, 25, 12, 34, 56, 0, time.UTC),
|
||||
DurationMS: 1234,
|
||||
Events: 2,
|
||||
Status: "done",
|
||||
LastKind: "tool",
|
||||
ParentID: "parent-run",
|
||||
}}
|
||||
var out bytes.Buffer
|
||||
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := out.String()
|
||||
for _, want := range []string{"run-1", "status=done", "events=2", "duration=1.2s", "last=tool", "parent=parent-run"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("human output %q missing %q", line, want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRunIndexIncludesResumeBreadcrumbs(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{
|
||||
RunID: "run-failed",
|
||||
Agent: "runner",
|
||||
UpdatedAt: time.Date(2026, 6, 25, 12, 34, 56, 0, time.UTC),
|
||||
Events: 3,
|
||||
Status: "error",
|
||||
LastKind: "tool",
|
||||
Checkpoint: "failed",
|
||||
Stage: "ask",
|
||||
}}
|
||||
var out bytes.Buffer
|
||||
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"checkpoint=failed", "stage=ask", `micro agent history runner run-failed`, `micro.AgentResume(ctx, agent, "run-failed")`, `micro.ResumeStreamAsk(ctx, agent, "run-failed")`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRunIndexInputRequiredUsesResumeInput(t *testing.T) {
|
||||
runs := []goagent.RunSummary{{RunID: "run-input", Agent: "runner", Status: "running", LastKind: "checkpoint", Checkpoint: "paused", Stage: "input-required"}}
|
||||
var out bytes.Buffer
|
||||
if err := writeRunIndex(&out, "runner", runs, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{`micro agent history runner run-input`, `micro agent resume-input runner run-input --input <text>`} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
if strings.Contains(got, `micro.AgentResume(ctx, agent, "run-input")`) || strings.Contains(got, "ResumeStreamAsk") {
|
||||
t.Fatalf("input-required run should point at ResumeInput only, got:\n%s", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestWriteRunHistoryHumanAndJSON(t *testing.T) {
|
||||
events := []goagent.RunEvent{{
|
||||
Time: time.Date(2026, 6, 25, 12, 34, 56, 7_000_000, time.UTC),
|
||||
RunID: "run-1",
|
||||
Agent: "runner",
|
||||
Kind: "tool",
|
||||
Name: "probe",
|
||||
Provider: "oteltest",
|
||||
Model: "unit-model",
|
||||
LatencyMS: 42,
|
||||
Tokens: ai.Usage{TotalTokens: 5},
|
||||
TraceID: "1234567890abcdef",
|
||||
ParentID: "parent-run",
|
||||
}}
|
||||
|
||||
var human bytes.Buffer
|
||||
if err := writeRunHistory(&human, "runner", "run-1", events, false); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
line := human.String()
|
||||
for _, want := range []string{"12:34:56.007 tool", "probe", "oteltest/unit-model", "42ms", "tokens=5", "parent=parent-run", "trace=1234567890ab"} {
|
||||
if !strings.Contains(line, want) {
|
||||
t.Fatalf("human output %q missing %q", line, want)
|
||||
}
|
||||
}
|
||||
|
||||
var js bytes.Buffer
|
||||
if err := writeRunHistory(&js, "runner", "run-1", events, true); err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
var got []goagent.RunEvent
|
||||
if err := json.Unmarshal(js.Bytes(), &got); err != nil {
|
||||
t.Fatalf("invalid JSON: %v\n%s", err, js.String())
|
||||
}
|
||||
if len(got) != 1 || got[0].Name != "probe" || got[0].Tokens.TotalTokens != 5 {
|
||||
t.Fatalf("decoded events = %#v", got)
|
||||
}
|
||||
}
|
||||
|
||||
func TestResumeInputRunCompletesCheckpointAndInspectSummary(t *testing.T) {
|
||||
oldStore := store.DefaultStore
|
||||
store.DefaultStore = store.NewMemoryStore()
|
||||
t.Cleanup(func() { store.DefaultStore = oldStore })
|
||||
|
||||
ctx := context.Background()
|
||||
cp := aiflow.StoreCheckpoint(store.DefaultStore, "runner")
|
||||
run := aiflow.Run{ID: "run-input", Flow: "runner", Status: "paused", State: aiflow.State{Stage: "input-required"}, Steps: []aiflow.StepRecord{{Name: "ask", Status: "paused", Error: "Which region?"}}}
|
||||
if err := run.State.Set(cliInputPause{OriginalMessage: "deploy", Prompt: "Which region?"}); err != nil {
|
||||
t.Fatalf("set pause: %v", err)
|
||||
}
|
||||
if err := cp.Save(ctx, run); err != nil {
|
||||
t.Fatalf("save checkpoint: %v", err)
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := resumeInputRun(ctx, &out, "runner", "run-input", "us-east-1"); err != nil {
|
||||
t.Fatalf("resumeInputRun: %v", err)
|
||||
}
|
||||
if got := out.String(); !strings.Contains(got, "Recorded input") || !strings.Contains(got, "micro inspect agent runner --limit 1") {
|
||||
t.Fatalf("output missing continuation hints:\n%s", got)
|
||||
}
|
||||
loaded, ok, err := cp.Load(ctx, "run-input")
|
||||
if err != nil || !ok {
|
||||
t.Fatalf("load checkpoint ok=%v err=%v", ok, err)
|
||||
}
|
||||
if loaded.Status != "done" || loaded.State.Stage != "done" {
|
||||
t.Fatalf("loaded run status/stage = %s/%s, want done/done", loaded.Status, loaded.State.Stage)
|
||||
}
|
||||
summaries, err := goagent.ListRunSummariesWithOptions(store.DefaultStore, "runner", goagent.RunListOptions{Status: "done"})
|
||||
if err != nil {
|
||||
t.Fatalf("summaries: %v", err)
|
||||
}
|
||||
if len(summaries) != 1 || summaries[0].RunID != "run-input" || summaries[0].Status != "done" {
|
||||
t.Fatalf("summaries = %#v, want completed run-input", summaries)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,179 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/registry"
|
||||
"go-micro.dev/v6/store"
|
||||
)
|
||||
|
||||
type doctorDeps struct {
|
||||
getenv func(string) string
|
||||
httpGet func(string) (*http.Response, error)
|
||||
listServices func() ([]*registry.Service, error)
|
||||
getService func(string) ([]*registry.Service, error)
|
||||
listRuns func(string) ([]goagent.RunSummary, error)
|
||||
}
|
||||
|
||||
func defaultDoctorDeps() doctorDeps {
|
||||
client := &http.Client{Timeout: 2 * time.Second}
|
||||
return doctorDeps{
|
||||
getenv: defaultPreflightDeps().getenv,
|
||||
httpGet: client.Get,
|
||||
listServices: registry.ListServices,
|
||||
getService: registry.GetService,
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) {
|
||||
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
func runAgentDoctor(w io.Writer, deps doctorDeps, gateway string) error {
|
||||
if gateway == "" {
|
||||
gateway = "http://localhost:8080"
|
||||
}
|
||||
gateway = strings.TrimRight(gateway, "/")
|
||||
checks := agentDoctorChecks(deps, gateway)
|
||||
failures := 0
|
||||
fmt.Fprintln(w, "First-agent recovery doctor")
|
||||
for _, check := range checks {
|
||||
mark := "✓"
|
||||
if !check.OK {
|
||||
mark = "✗"
|
||||
failures++
|
||||
}
|
||||
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
|
||||
if !check.OK && check.Fix != "" {
|
||||
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
|
||||
}
|
||||
if !check.OK && check.Next != "" {
|
||||
fmt.Fprintf(w, " Next: %s\n", check.Next)
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
return fmt.Errorf("first-agent doctor found %d recovery boundary issue(s)", failures)
|
||||
}
|
||||
fmt.Fprintln(w, "\nReady: gateway, agent registration, chat settings, and inspect history are reachable.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func agentDoctorChecks(deps doctorDeps, gateway string) []preflightCheck {
|
||||
if deps.getenv == nil {
|
||||
deps.getenv = defaultPreflightDeps().getenv
|
||||
}
|
||||
if deps.httpGet == nil {
|
||||
deps.httpGet = http.Get
|
||||
}
|
||||
if deps.listServices == nil {
|
||||
deps.listServices = registry.ListServices
|
||||
}
|
||||
if deps.getService == nil {
|
||||
deps.getService = registry.GetService
|
||||
}
|
||||
if deps.listRuns == nil {
|
||||
deps.listRuns = func(name string) ([]goagent.RunSummary, error) {
|
||||
return goagent.ListRunSummariesWithOptions(store.DefaultStore, name, goagent.RunListOptions{Limit: 1})
|
||||
}
|
||||
}
|
||||
|
||||
checks := []preflightCheck{checkGateway(deps, gateway), checkChatSettings(deps, gateway)}
|
||||
agents, regCheck := checkAgentRegistration(deps)
|
||||
checks = append(checks, regCheck)
|
||||
checks = append(checks, checkRunHistory(deps, agents))
|
||||
checks = append(checks, checkProviderConfig(deps))
|
||||
return checks
|
||||
}
|
||||
|
||||
func checkGateway(deps doctorDeps, gateway string) preflightCheck {
|
||||
resp, err := deps.httpGet(gateway + "/agent")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "gateway /agent", Detail: err.Error(), Fix: "Start the local gateway with `micro run`, or pass the matching URL with `micro agent doctor --gateway http://localhost:<port>`.", Next: "Then open " + gateway + "/agent or retry `micro chat`."}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return preflightCheck{Name: "gateway /agent", Detail: fmt.Sprintf("%s returned %s", gateway+"/agent", resp.Status), Fix: "Confirm `micro run` is serving the web gateway and that auth/proxy settings are not blocking /agent.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
|
||||
}
|
||||
return preflightCheck{Name: "gateway /agent", OK: true, Detail: gateway + "/agent is reachable"}
|
||||
}
|
||||
|
||||
func checkChatSettings(deps doctorDeps, gateway string) preflightCheck {
|
||||
resp, err := deps.httpGet(gateway + "/api/agent/settings")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "chat settings endpoint", Detail: err.Error(), Fix: "Keep `micro run` running and retry; the playground uses /api/agent/settings before chat prompts.", Next: "See docs/guides/debugging-agents.html#chat-and-gateway-failures."}
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode >= 400 {
|
||||
return preflightCheck{Name: "chat settings endpoint", Detail: fmt.Sprintf("returned %s", resp.Status), Fix: "Check gateway auth/proxy configuration or use the Agent settings page to confirm chat settings load.", Next: "See docs/guides/debugging-agents.html#provider-failures."}
|
||||
}
|
||||
var settings map[string]string
|
||||
_ = json.NewDecoder(resp.Body).Decode(&settings)
|
||||
if settings["provider"] != "" || settings["model"] != "" || settings["api_key"] != "" {
|
||||
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable with saved provider settings"}
|
||||
}
|
||||
return preflightCheck{Name: "chat settings endpoint", OK: true, Detail: "reachable; no saved provider settings"}
|
||||
}
|
||||
|
||||
func checkAgentRegistration(deps doctorDeps) ([]string, preflightCheck) {
|
||||
services, err := deps.listServices()
|
||||
if err != nil {
|
||||
return nil, preflightCheck{Name: "agent registration", Detail: err.Error(), Fix: "Keep the scaffolded agent process running under `micro run` and retry `micro agent list`.", Next: "See docs/guides/your-first-agent.html#run-your-agent."}
|
||||
}
|
||||
var agents []string
|
||||
for _, svc := range services {
|
||||
records, err := deps.getService(svc.Name)
|
||||
if err != nil || len(records) == 0 {
|
||||
continue
|
||||
}
|
||||
if serviceIsAgent(records[0]) {
|
||||
agents = append(agents, svc.Name)
|
||||
}
|
||||
}
|
||||
if len(agents) == 0 {
|
||||
return nil, preflightCheck{Name: "agent registration", Detail: "no registered agent services found", Fix: "Start an agent project with `micro run` and confirm `micro agent list` shows it.", Next: "Use docs/guides/no-secret-first-agent.html for a deterministic no-provider agent."}
|
||||
}
|
||||
return agents, preflightCheck{Name: "agent registration", OK: true, Detail: "found " + strings.Join(agents, ", ")}
|
||||
}
|
||||
|
||||
func serviceIsAgent(svc *registry.Service) bool {
|
||||
if svc.Metadata != nil && svc.Metadata["type"] == "agent" {
|
||||
return true
|
||||
}
|
||||
for _, node := range svc.Nodes {
|
||||
if node.Metadata != nil && node.Metadata["type"] == "agent" {
|
||||
return true
|
||||
}
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func checkRunHistory(deps doctorDeps, agents []string) preflightCheck {
|
||||
if len(agents) == 0 {
|
||||
return preflightCheck{Name: "inspect run history", Detail: "skipped because no agent is registered", Fix: "Fix agent registration first, then chat once and run `micro inspect agent <name>`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
|
||||
}
|
||||
for _, name := range agents {
|
||||
runs, err := deps.listRuns(name)
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "inspect run history", Detail: err.Error(), Fix: "Ensure the local store is writable and retry `micro inspect agent " + name + "`.", Next: "See docs/guides/debugging-agents.html#inspect-run-history."}
|
||||
}
|
||||
if len(runs) > 0 {
|
||||
return preflightCheck{Name: "inspect run history", OK: true, Detail: "recent runs available for " + name}
|
||||
}
|
||||
}
|
||||
return preflightCheck{Name: "inspect run history", Detail: "no recorded agent runs yet", Fix: "Send one prompt with `micro chat` or the /agent playground, then run `micro inspect agent " + agents[0] + "`.", Next: "See docs/guides/your-first-agent.html#inspect-what-happened."}
|
||||
}
|
||||
|
||||
func checkProviderConfig(deps doctorDeps) preflightCheck {
|
||||
check := checkProviderKey(preflightDeps{getenv: deps.getenv})
|
||||
check.Name = "provider configuration"
|
||||
if !check.OK {
|
||||
check.Detail = "no provider key found for live LLM chat"
|
||||
check.Fix = "For provider-backed chat, export MICRO_AI_API_KEY or a provider-specific key; for no-secret recovery, use the mock-model walkthrough."
|
||||
}
|
||||
return check
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"io"
|
||||
"net/http"
|
||||
"strings"
|
||||
"testing"
|
||||
|
||||
goagent "go-micro.dev/v6/agent"
|
||||
"go-micro.dev/v6/registry"
|
||||
)
|
||||
|
||||
func doctorHTTP(status int, body string) func(string) (*http.Response, error) {
|
||||
return func(string) (*http.Response, error) {
|
||||
return &http.Response{StatusCode: status, Status: "200 OK", Body: io.NopCloser(strings.NewReader(body))}, nil
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentDoctorPassesWhenRecoveryBoundariesReachable(t *testing.T) {
|
||||
deps := doctorDeps{
|
||||
getenv: func(key string) string {
|
||||
if key == "MICRO_AI_API_KEY" {
|
||||
return "set"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
httpGet: doctorHTTP(200, `{"provider":"anthropic","model":"claude"}`),
|
||||
listServices: func() ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: "assistant"}}, nil
|
||||
},
|
||||
getService: func(name string) ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: name, Metadata: map[string]string{"type": "agent"}}}, nil
|
||||
},
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) {
|
||||
return []goagent.RunSummary{{RunID: "run-1", Status: "done"}}, nil
|
||||
},
|
||||
}
|
||||
var out bytes.Buffer
|
||||
if err := runAgentDoctor(&out, deps, "http://example.test"); err != nil {
|
||||
t.Fatalf("runAgentDoctor() error = %v\n%s", err, out.String())
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"First-agent recovery doctor", "✓ gateway /agent", "✓ chat settings endpoint", "✓ agent registration", "✓ inspect run history", "✓ provider configuration", "Ready:"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentDoctorReportsActionableRecoveryFailures(t *testing.T) {
|
||||
deps := doctorDeps{
|
||||
getenv: func(string) string { return "" },
|
||||
httpGet: func(string) (*http.Response, error) { return nil, errors.New("connection refused") },
|
||||
listServices: func() ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: "greeter"}}, nil
|
||||
},
|
||||
getService: func(name string) ([]*registry.Service, error) {
|
||||
return []*registry.Service{{Name: name}}, nil
|
||||
},
|
||||
listRuns: func(name string) ([]goagent.RunSummary, error) { return nil, nil },
|
||||
}
|
||||
var out bytes.Buffer
|
||||
err := runAgentDoctor(&out, deps, "http://localhost:8080")
|
||||
if err == nil {
|
||||
t.Fatal("runAgentDoctor() error = nil")
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"✗ gateway /agent", "micro run", "✗ chat settings endpoint", "✗ agent registration", "micro agent list", "✗ inspect run history", "micro inspect agent <name>", "✗ provider configuration", "docs/guides/no-secret-first-agent.html"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestAgentQuickcheckPrintsProviderFreeFailureModeBreadcrumbs(t *testing.T) {
|
||||
got := firstAgentQuickChecksHelp
|
||||
for _, want := range []string{
|
||||
"First-agent failure-mode quick checks",
|
||||
"scaffold -> run -> chat -> inspect",
|
||||
"micro agent preflight",
|
||||
"micro run",
|
||||
"micro agent doctor",
|
||||
"micro inspect agent <name>",
|
||||
"micro runs <name>",
|
||||
"micro agent demo",
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentTranscript -count=1",
|
||||
"go test ./internal/harness/zero-to-hero-ci -run TestNoSecretFirstAgentDebuggingSmoke -count=1",
|
||||
"debugging-agents.html",
|
||||
"no-secret-first-agent.html",
|
||||
} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("quickcheck output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,163 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"strings"
|
||||
|
||||
"go-micro.dev/v6/cmd"
|
||||
)
|
||||
|
||||
type preflightCheck struct {
|
||||
Name string
|
||||
OK bool
|
||||
Detail string
|
||||
Fix string
|
||||
Next string
|
||||
}
|
||||
|
||||
type preflightDeps struct {
|
||||
lookPath func(string) (string, error)
|
||||
commandOutput func(string, ...string) ([]byte, error)
|
||||
executable func() (string, error)
|
||||
version func() string
|
||||
getenv func(string) string
|
||||
listen func(string, string) (net.Listener, error)
|
||||
}
|
||||
|
||||
func defaultPreflightDeps() preflightDeps {
|
||||
return preflightDeps{
|
||||
lookPath: exec.LookPath,
|
||||
commandOutput: func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() },
|
||||
executable: os.Executable,
|
||||
version: func() string { return cmd.App().Version },
|
||||
getenv: os.Getenv,
|
||||
listen: net.Listen,
|
||||
}
|
||||
}
|
||||
|
||||
func runAgentPreflight(w io.Writer, deps preflightDeps) error {
|
||||
checks := agentPreflightChecks(deps)
|
||||
failures := 0
|
||||
fmt.Fprintln(w, "First-agent preflight")
|
||||
for _, check := range checks {
|
||||
mark := "✓"
|
||||
if !check.OK {
|
||||
mark = "✗"
|
||||
failures++
|
||||
}
|
||||
fmt.Fprintf(w, " %s %s — %s\n", mark, check.Name, check.Detail)
|
||||
if !check.OK && check.Fix != "" {
|
||||
fmt.Fprintf(w, " Fix: %s\n", check.Fix)
|
||||
}
|
||||
if !check.OK && check.Next != "" {
|
||||
fmt.Fprintf(w, " Next: %s\n", check.Next)
|
||||
}
|
||||
}
|
||||
if failures > 0 {
|
||||
return fmt.Errorf("first-agent preflight failed: %d check(s) need attention", failures)
|
||||
}
|
||||
fmt.Fprintln(w, "\nReady for the first-agent walkthrough: micro run, then open http://localhost:8080/agent or use micro chat.")
|
||||
return nil
|
||||
}
|
||||
|
||||
func agentPreflightChecks(deps preflightDeps) []preflightCheck {
|
||||
if deps.lookPath == nil {
|
||||
deps.lookPath = exec.LookPath
|
||||
}
|
||||
if deps.commandOutput == nil {
|
||||
deps.commandOutput = func(name string, args ...string) ([]byte, error) { return exec.Command(name, args...).CombinedOutput() }
|
||||
}
|
||||
if deps.executable == nil {
|
||||
deps.executable = os.Executable
|
||||
}
|
||||
if deps.version == nil {
|
||||
deps.version = func() string { return cmd.App().Version }
|
||||
}
|
||||
if deps.getenv == nil {
|
||||
deps.getenv = os.Getenv
|
||||
}
|
||||
if deps.listen == nil {
|
||||
deps.listen = net.Listen
|
||||
}
|
||||
|
||||
checks := []preflightCheck{checkGoToolchain(deps), checkMicroBinary(deps), checkProviderKey(deps), checkPortAvailable(deps, ":8080", "micro run gateway and /agent playground")}
|
||||
return checks
|
||||
}
|
||||
|
||||
func checkGoToolchain(deps preflightDeps) preflightCheck {
|
||||
path, err := deps.lookPath("go")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "Go toolchain", Detail: "go was not found on PATH", Fix: "Install Go 1.24 or newer from https://go.dev/doc/install and ensure go is on PATH.", Next: "After installing Go, rerun micro agent preflight, then continue with docs/guides/your-first-agent.html."}
|
||||
}
|
||||
out, err := deps.commandOutput("go", "version")
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "Go toolchain", Detail: strings.TrimSpace(string(out)), Fix: "Ensure the go command runs successfully (try `go version`) before starting the agent walkthrough.", Next: "Use docs/guides/debugging-agents.html after the toolchain check passes if an agent run still fails."}
|
||||
}
|
||||
version := firstLine(out)
|
||||
if !goVersionAtLeast(version, 1, 24) {
|
||||
return preflightCheck{Name: "Go toolchain", Detail: fmt.Sprintf("%s (%s)", version, path), Fix: "Upgrade to Go 1.24 or newer before running generated services.", Next: "Rerun micro agent preflight, then continue with docs/guides/your-first-agent.html."}
|
||||
}
|
||||
return preflightCheck{Name: "Go toolchain", OK: true, Detail: fmt.Sprintf("%s (%s)", version, path)}
|
||||
}
|
||||
|
||||
func checkMicroBinary(deps preflightDeps) preflightCheck {
|
||||
exe, err := deps.executable()
|
||||
if err != nil || exe == "" {
|
||||
return preflightCheck{Name: "micro binary", Detail: "micro executable path is unavailable", Fix: "Install the micro CLI or run this check through `go run ./cmd/micro agent preflight` from the repository.", Next: "Then follow docs/getting-started.html for the scaffold -> run path."}
|
||||
}
|
||||
version := deps.version()
|
||||
if version == "" {
|
||||
version = "version unavailable"
|
||||
}
|
||||
return preflightCheck{Name: "micro binary", OK: true, Detail: fmt.Sprintf("%s (%s)", version, exe)}
|
||||
}
|
||||
|
||||
func checkProviderKey(deps preflightDeps) preflightCheck {
|
||||
keys := []string{"MICRO_AI_API_KEY", "ANTHROPIC_API_KEY", "OPENAI_API_KEY", "GEMINI_API_KEY", "GROQ_API_KEY", "MISTRAL_API_KEY", "TOGETHER_API_KEY", "ATLASCLOUD_API_KEY"}
|
||||
var found []string
|
||||
for _, k := range keys {
|
||||
if deps.getenv(k) != "" {
|
||||
found = append(found, k)
|
||||
}
|
||||
}
|
||||
if len(found) == 0 {
|
||||
return preflightCheck{Name: "provider API key", Detail: "no supported provider key found", Fix: "Export MICRO_AI_API_KEY or a provider key such as ANTHROPIC_API_KEY before running provider-backed agents.", Next: "For a no-secret path, run the mock-model walkthrough in docs/guides/no-secret-first-agent.html; for real providers, see docs/guides/debugging-agents.html#provider-failures."}
|
||||
}
|
||||
return preflightCheck{Name: "provider API key", OK: true, Detail: "found " + strings.Join(found, ", ")}
|
||||
}
|
||||
|
||||
func checkPortAvailable(deps preflightDeps, addr, use string) preflightCheck {
|
||||
ln, err := deps.listen("tcp", addr)
|
||||
if err != nil {
|
||||
return preflightCheck{Name: "local port " + addr, Detail: "busy or unavailable for " + use, Fix: "Stop the process using " + addr + " (for example, `lsof -i :8080`) or run `micro run --address` with a free port.", Next: "Once the gateway starts, open http://localhost:8080/agent or continue with docs/guides/your-first-agent.html#chat-with-your-agent."}
|
||||
}
|
||||
_ = ln.Close()
|
||||
return preflightCheck{Name: "local port " + addr, OK: true, Detail: "available for " + use}
|
||||
}
|
||||
|
||||
func firstLine(b []byte) string {
|
||||
s := strings.TrimSpace(string(b))
|
||||
if i := strings.IndexByte(s, '\n'); i >= 0 {
|
||||
return s[:i]
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func goVersionAtLeast(line string, wantMajor, wantMinor int) bool {
|
||||
idx := strings.Index(line, "go1.")
|
||||
if idx < 0 {
|
||||
return false
|
||||
}
|
||||
var major, minor int
|
||||
if _, err := fmt.Sscanf(line[idx:], "go%d.%d", &major, &minor); err != nil {
|
||||
return false
|
||||
}
|
||||
if major != wantMajor {
|
||||
return major > wantMajor
|
||||
}
|
||||
return minor >= wantMinor
|
||||
}
|
||||
@@ -0,0 +1,124 @@
|
||||
package agent
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"errors"
|
||||
"net"
|
||||
"strings"
|
||||
"testing"
|
||||
)
|
||||
|
||||
type stubListener struct{}
|
||||
|
||||
func (stubListener) Accept() (net.Conn, error) { return nil, errors.New("closed") }
|
||||
func (stubListener) Close() error { return nil }
|
||||
func (stubListener) Addr() net.Addr { return stubAddr(":8080") }
|
||||
|
||||
type stubAddr string
|
||||
|
||||
func (a stubAddr) Network() string { return "tcp" }
|
||||
func (a stubAddr) String() string { return string(a) }
|
||||
|
||||
func TestRunAgentPreflightPassesWithKeyAndFreePort(t *testing.T) {
|
||||
deps := preflightDeps{
|
||||
lookPath: func(name string) (string, error) { return "/usr/bin/" + name, nil },
|
||||
commandOutput: func(name string, args ...string) ([]byte, error) {
|
||||
return []byte("go version go1.24.0 linux/amd64\n"), nil
|
||||
},
|
||||
executable: func() (string, error) { return "/usr/local/bin/micro", nil },
|
||||
getenv: func(key string) string {
|
||||
if key == "ANTHROPIC_API_KEY" {
|
||||
return "set"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
listen: func(network, address string) (net.Listener, error) { return stubListener{}, nil },
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
if err := runAgentPreflight(&out, deps); err != nil {
|
||||
t.Fatalf("runAgentPreflight() error = %v", err)
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"First-agent preflight", "✓ Go toolchain", "✓ micro binary", "✓ provider API key", "✓ local port :8080", "Ready for the first-agent walkthrough"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentPreflightReportsActionableFailures(t *testing.T) {
|
||||
deps := preflightDeps{
|
||||
lookPath: func(name string) (string, error) { return "", errors.New("not found") },
|
||||
executable: func() (string, error) { return "", errors.New("unknown") },
|
||||
getenv: func(key string) string { return "" },
|
||||
listen: func(network, address string) (net.Listener, error) { return nil, errors.New("in use") },
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := runAgentPreflight(&out, deps)
|
||||
if err == nil {
|
||||
t.Fatal("runAgentPreflight() error = nil")
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"✗ Go toolchain", "go was not found on PATH", "https://go.dev/doc/install", "docs/guides/your-first-agent.html", "✗ micro binary", "go run ./cmd/micro agent preflight", "✗ provider API key", "docs/guides/no-secret-first-agent.html", "docs/guides/debugging-agents.html#provider-failures", "✗ local port :8080", "lsof -i :8080", "micro run --address"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestRunAgentPreflightReportsOldGoVersion(t *testing.T) {
|
||||
deps := preflightDeps{
|
||||
lookPath: func(name string) (string, error) { return "/usr/bin/" + name, nil },
|
||||
commandOutput: func(name string, args ...string) ([]byte, error) {
|
||||
return []byte("go version go1.23.9 linux/amd64\n"), nil
|
||||
},
|
||||
executable: func() (string, error) { return "/usr/local/bin/micro", nil },
|
||||
getenv: func(key string) string {
|
||||
if key == "ANTHROPIC_API_KEY" {
|
||||
return "set"
|
||||
}
|
||||
return ""
|
||||
},
|
||||
listen: func(network, address string) (net.Listener, error) { return stubListener{}, nil },
|
||||
}
|
||||
|
||||
var out bytes.Buffer
|
||||
err := runAgentPreflight(&out, deps)
|
||||
if err == nil {
|
||||
t.Fatal("runAgentPreflight() error = nil")
|
||||
}
|
||||
got := out.String()
|
||||
for _, want := range []string{"✗ Go toolchain", "go1.23.9", "Upgrade to Go 1.24 or newer", "Rerun micro agent preflight"} {
|
||||
if !strings.Contains(got, want) {
|
||||
t.Fatalf("output missing %q:\n%s", want, got)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestGoVersionAtLeast(t *testing.T) {
|
||||
tests := []struct {
|
||||
line string
|
||||
want bool
|
||||
}{
|
||||
{line: "go version go1.24.0 linux/amd64", want: true},
|
||||
{line: "go version go1.25.1 linux/amd64", want: true},
|
||||
{line: "go version go1.23.9 linux/amd64", want: false},
|
||||
{line: "unexpected", want: false},
|
||||
}
|
||||
for _, tt := range tests {
|
||||
if got := goVersionAtLeast(tt.line, 1, 24); got != tt.want {
|
||||
t.Fatalf("goVersionAtLeast(%q) = %v, want %v", tt.line, got, tt.want)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func TestFirstLine(t *testing.T) {
|
||||
if got := firstLine([]byte("one\ntwo")); got != "one" {
|
||||
t.Fatalf("firstLine() = %q", got)
|
||||
}
|
||||
if got := firstLine([]byte(" single ")); got != "single" {
|
||||
t.Fatalf("firstLine() = %q", got)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user