e071084ebe
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
468 lines
14 KiB
Go
468 lines
14 KiB
Go
// 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
|
|
}
|