Files
micro--go-micro/agent/agent.go
T
wehub-resource-sync e071084ebe
govulncheck / govulncheck (push) Waiting to run
Harness (E2E) / Harnesses (mock LLM) (push) Waiting to run
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Lint / golangci-lint (push) Waiting to run
Run Tests / Unit Tests (push) Waiting to run
Run Tests / Etcd Integration Tests (push) Waiting to run
chore: import upstream snapshot with attribution
2026-07-13 12:40:33 +08:00

700 lines
21 KiB
Go

// Package agent provides the Agent abstraction for Go Micro.
//
// An Agent is a service with an LLM inside it. It registers a Chat
// RPC endpoint, discovers its assigned services' tools, and
// orchestrates them intelligently.
//
// agent := micro.NewAgent("task-mgr",
// micro.AgentServices("task"),
// micro.AgentPrompt("You manage tasks."),
// micro.AgentProvider("anthropic"),
// )
// agent.Run()
package agent
import (
"context"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
"strings"
"sync"
"time"
"github.com/google/uuid"
pb "go-micro.dev/v6/agent/proto"
"go-micro.dev/v6/ai"
"go-micro.dev/v6/flow"
"go-micro.dev/v6/gateway/a2a"
"go-micro.dev/v6/server"
"go-micro.dev/v6/store"
_ "go-micro.dev/v6/ai/anthropic"
_ "go-micro.dev/v6/ai/atlascloud"
_ "go-micro.dev/v6/ai/gemini"
_ "go-micro.dev/v6/ai/groq"
_ "go-micro.dev/v6/ai/minimax"
_ "go-micro.dev/v6/ai/mistral"
_ "go-micro.dev/v6/ai/ollama"
_ "go-micro.dev/v6/ai/openai"
_ "go-micro.dev/v6/ai/together"
)
// Agent is the interface for an AI agent that manages services.
type Agent interface {
Name() string
Init(...Option)
Options() Options
Ask(ctx context.Context, message string) (*Response, error)
Stream(ctx context.Context, message string) (ai.Stream, error)
Run() error
Stop() error
String() string
}
// Response is what an agent returns from Chat.
type Response struct {
Reply string
ToolCalls []ai.ToolCall
Agent string
// RunID correlates this Ask with tool calls, trace spans, and the
// persisted run timeline. ParentID is set when this response belongs
// to a delegated sub-agent run.
RunID string
ParentID string
}
type agentImpl struct {
opts Options
model ai.Model
tools *ai.Tools
mem Memory
server server.Server
mu sync.Mutex
// ephemeral marks a short-lived sub-agent created by delegation.
// Ephemeral agents run with an isolated context: they load and
// persist no history, and have no built-in tools (so they cannot
// plan or re-delegate).
ephemeral bool
// steps counts tool executions in the current Ask, for MaxSteps.
steps int
// spend counts reserved paid-tool spend in the current Ask, for MaxSpend.
spend int64
// calls counts identical tool calls (name+args) in the current Ask,
// for LoopLimit.
calls map[string]int
// runID correlates the tool calls of the current Ask; parentRunID is
// the run that delegated to this one (set on ephemeral sub-agents).
// Both are surfaced to tool wrappers via ai.RunInfo on the context.
runID string
parentRunID string
// pause records a guardrail approval pause raised during the current
// Ask. The model provider only sees a refused tool result; the agent
// converts it into a durable paused run instead of completing the run.
pause *approvalPause
// currentRun points at the checkpoint record for the Ask currently
// holding mu. Tool execution updates it so resumed runs can reuse
// completed tool results without replaying side effects.
currentRun *flow.Run
// delegateCalls collapses concurrent equivalent delegate tool calls so a
// provider replay cannot fan out duplicate delegated side effects before the
// durable delegate-result cache is written.
delegateMu sync.Mutex
delegateCalls map[string]*delegateCall
// stopCh lets Stop unblock Run. Without this, tests and harnesses that
// start agents in goroutines can leave Run parked forever after the RPC
// server has been stopped.
stopCh chan struct{}
}
// New creates a new Agent.
func New(opts ...Option) Agent {
return &agentImpl{
opts: newOptions(opts...),
}
}
// newEphemeral creates a short-lived sub-agent for a delegated subtask.
// It shares the parent's provider, model, and infrastructure but runs
// with an isolated context: it loads and persists no history and has no
// built-in tools (so it can neither plan nor re-delegate). Returns the
// concrete type because ephemeral is an internal construction detail,
// not a public option.
func newEphemeral(opts ...Option) *agentImpl {
return &agentImpl{
opts: newOptions(opts...),
ephemeral: true,
}
}
func (a *agentImpl) Name() string {
return a.opts.Name
}
func (a *agentImpl) Init(opts ...Option) {
for _, o := range opts {
o(&a.opts)
}
a.setup()
}
func (a *agentImpl) Options() Options {
return a.opts
}
func (a *agentImpl) String() string {
return "agent"
}
func (a *agentImpl) setup() {
a.setupWithToolHandler(nil)
}
func (a *agentImpl) setupWithToolHandler(handler ai.ToolHandler) {
var modelOpts []ai.Option
modelOpts = append(modelOpts, ai.WithAPIKey(a.opts.APIKey))
if a.opts.Model != "" {
modelOpts = append(modelOpts, ai.WithModel(a.opts.Model))
}
if a.opts.BaseURL != "" {
modelOpts = append(modelOpts, ai.WithBaseURL(a.opts.BaseURL))
}
// Reuse the existing tools instance: its name map is populated by
// discoverTools, and rebuilding it here would orphan a base handler that
// already captured the old instance (breaking StreamAsk tool resolution).
if a.tools == nil {
a.tools = ai.NewTools(a.opts.Registry, ai.ToolClient(a.opts.Client))
}
if handler == nil {
handler = a.toolHandler()
}
modelOpts = append(modelOpts, ai.WithToolHandler(handler))
a.model = ai.New(a.opts.Provider, modelOpts...)
if a.model != nil {
a.model = a.tracedModel(a.model)
}
if a.mem != nil {
return
}
// Memory is pluggable. Use the configured one, otherwise the default
// store-backed memory — except ephemeral sub-agents, which keep an
// isolated, non-persistent context.
switch {
case a.opts.Memory != nil:
a.mem = a.opts.Memory
case a.ephemeral:
a.mem = NewInMemory(a.opts.HistoryLimit)
case a.opts.MemoryCompaction.MaxMessages > 0:
a.mem = NewCompactingMemoryWithOptions(a.stateStore(), "history", a.opts.MemoryCompaction)
case a.opts.MemoryRetrievalLimit > 0:
a.mem = NewRetrievalMemory(a.stateStore(), "history", a.opts.MemoryRetrievalLimit)
default:
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
}
}
// stateStore returns the agent's own state store, scoped to its name so
// memory and plan live in their own table ("agent/{name}") rather than a
// shared global one. The scoped handle injects the database/table per
// operation without mutating the underlying store.
func (a *agentImpl) stateStore() store.Store {
s := a.opts.Store
if s == nil {
s = store.DefaultStore
}
return store.Scope(s, "agent", a.opts.Name)
}
// Ask sends a message and returns the agent's response.
// This is the programmatic API for direct use.
func (a *agentImpl) Ask(ctx context.Context, message string) (*Response, error) {
return a.ask(ctx, message, a.parentRunID)
}
// Stream sends a message and returns a streaming model response. Tool-calling
// agent runs still use Ask; Stream is for chat turns where immediate token
// delivery is more important than tool orchestration.
func (a *agentImpl) Stream(ctx context.Context, message string) (ai.Stream, error) {
a.mu.Lock()
defer a.mu.Unlock()
if err := ctx.Err(); err != nil {
return nil, err
}
if a.model == nil {
a.setup()
}
toolList, err := a.discoverTools()
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
runID := uuid.New().String()
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
RunID: runID,
ParentID: a.parentRunID,
Agent: a.opts.Name,
})
messages := append([]ai.Message(nil), a.mem.Messages()...)
messages = append(messages, ai.Message{Role: "user", Content: message})
stream, err := a.model.Stream(ctx, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: messages,
})
if err != nil {
return nil, err
}
if err := ctx.Err(); err != nil {
_ = stream.Close()
return nil, err
}
a.mem.Add("user", message)
return &memoryRecordingStream{stream: stream, memory: a.mem}, nil
}
// StreamChat serves the Agent.StreamChat RPC endpoint by forwarding stream-capable
// remote clients to the agent streaming path. If the model cannot stream, the
// underlying error is returned so callers can fall back to Agent.Chat.
func (a *agentImpl) StreamChat(ctx context.Context, stream pb.Agent_StreamChatStream) error {
req, err := stream.Recv()
if err != nil {
return err
}
aiStream, err := a.streamAskAI(ctx, req.Message)
if err != nil {
return err
}
defer aiStream.Close()
for {
chunk, err := aiStream.Recv()
if errors.Is(err, io.EOF) {
return nil
}
if err != nil {
return err
}
if chunk == nil || chunk.Reply == "" {
continue
}
if err := stream.Send(&pb.ChatResponse{Reply: chunk.Reply, Agent: a.opts.Name}); err != nil {
return err
}
}
}
// Pending returns checkpointed agent runs that have not completed. It mirrors
// flow.Pending for startup recovery loops that drain durable agent work.
func Pending(ctx context.Context, ag Agent) ([]flow.Run, error) {
a, ok := ag.(*agentImpl)
if !ok {
return nil, fmt.Errorf("agent pending: unsupported agent implementation %T", ag)
}
return a.pending(ctx)
}
// ResumePending resumes every checkpointed agent run that has not completed
// yet, in the same oldest-first order returned by Pending.
//
// It is a convenience for service startup and recovery loops: after recreating
// an agent with the same checkpoint store, call ResumePending to drain the
// durable backlog without listing and resuming each run manually. If any run
// fails again, ResumePending stops and returns that run id with the error so
// callers can log, alert, or retry later without hiding the failing run.
func ResumePending(ctx context.Context, ag Agent) (string, error) {
a, ok := ag.(*agentImpl)
if !ok {
return "", fmt.Errorf("agent resume pending: unsupported agent implementation %T", ag)
}
runs, err := a.pending(ctx)
if err != nil {
return "", err
}
for _, run := range runs {
if _, err := a.resume(ctx, run.ID); err != nil {
return run.ID, err
}
}
return "", nil
}
func (a *agentImpl) ask(ctx context.Context, message, parentRunID string) (*Response, error) {
a.mu.Lock()
defer a.mu.Unlock()
if a.model == nil {
a.setup()
}
return a.askLocked(ctx, uuid.New().String(), message, parentRunID, nil, true)
}
func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID string, existing *flow.Run, addUserMessage bool) (*Response, error) {
toolList, err := a.discoverTools()
if err != nil {
return nil, fmt.Errorf("discover tools: %w", err)
}
if addUserMessage {
a.mem.Add("user", message)
}
a.steps = 0
a.spend = 0
a.calls = map[string]int{}
a.pause = nil
// Correlate this run's tool calls and surface lineage to wrappers.
a.runID = runID
ctx = ai.WithRunInfo(ctx, ai.RunInfo{
RunID: a.runID,
ParentID: parentRunID,
Agent: a.opts.Name,
})
run := a.newCheckpointRun(runID, message, parentRunID, existing)
a.currentRun = &run
defer func() { a.currentRun = nil }()
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
ctx, endRun := a.startRun(ctx, message)
if existing != nil {
a.recordTimelineEvent(ctx, RunEvent{Time: time.Now(), RunID: runID, ParentID: parentRunID, Agent: a.opts.Name, Kind: "resume", Name: run.State.Stage})
}
defer func() { endRun(err) }()
messages := a.mem.Messages()
if recall, ok := a.mem.(MemoryRecall); ok && a.opts.MemoryRecallLimit > 0 {
if recalled := recall.Recall(message, a.opts.MemoryRecallLimit); len(recalled) > 0 {
messages = append([]ai.Message{{
Role: "system",
Content: "Relevant recalled memory follows; use it as durable prior context without assuming the whole conversation was replayed.",
}}, append(recalled, messages...)...)
}
}
// Some providers satisfy a saved plan one outstanding item per turn,
// especially when the final item delegates to another agent. Allow enough
// continuations for the services → agents → workflows harness to complete
// every planned side effect without weakening the final unfinished-plan guard.
const maxPlanCompletionTurns = 6
var resp *ai.Response
for planCompletionTurn := 0; ; planCompletionTurn++ {
resp, err = ai.GenerateWithRetry(ctx, a.model, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: messages,
}, ai.GeneratePolicy{
Timeout: a.opts.ModelTimeout,
MaxAttempts: a.opts.ModelMaxAttempts,
Backoff: a.opts.ModelRetryBackoff,
Jitter: a.opts.ModelRetryJitter,
})
if err != nil {
run.Status = agentRunFailureStatus(err)
failureKind := ai.ClassifyError(err)
attempts := agentRunFailureAttempts(err)
err = agentOperationalError(err)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = run.Status
run.Steps[0].Attempts = attempts
run.Steps[0].Error = err.Error()
run.Steps[0].ErrorKind = string(failureKind)
_ = a.saveRun(ctx, run)
return nil, err
}
if a.pause != nil && a.opts.Checkpoint != nil {
run.Status = "paused"
run.State.Stage = agentApprovalStep
run.State.Data = []byte(message)
if a.pause.Tool == toolHumanInput {
run.State.Stage = agentInputStep
_ = run.State.Set(inputPause{OriginalMessage: message, Prompt: a.pause.Message})
}
run.Steps[0].Status = "paused"
run.Steps[0].Error = a.pause.Message
run.Steps[0].Result = a.pause.Tool
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
return nil, fmt.Errorf("agent run %s paused for approval: %s", run.ID, a.pause.Message)
}
if len(resp.ToolCalls) == 0 {
if calls, answer, ok := a.executeTextToolCalls(ctx, resp.Reply, toolList); ok {
resp.ToolCalls = calls
if resp.Answer == "" {
resp.Answer = answer
}
trimmedReply := strings.TrimSpace(resp.Reply)
if strings.HasPrefix(trimmedReply, "{") || strings.HasPrefix(trimmedReply, "[") || strings.HasPrefix(trimmedReply, "```") {
resp.Reply = ""
}
}
} else if calls, answer, ok := a.executeAdditionalTextToolCalls(ctx, resp.Reply, toolList, resp.ToolCalls); ok {
resp.ToolCalls = append(resp.ToolCalls, calls...)
if answer != "" {
if resp.Answer == "" {
resp.Answer = answer
} else {
resp.Answer += "\n" + answer
}
}
}
if a.opts.Checkpoint != nil {
if unfinished := a.unfinishedPlanSteps(); len(unfinished) > 0 && planCompletionTurn < maxPlanCompletionTurns {
if resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
}
if resp.Answer != "" {
a.mem.Add("assistant", resp.Answer)
}
message = fmt.Sprintf("Continue the same run by calling the required tool(s) for the unfinished plan steps below. Do not repeat completed work, do not provide a final answer yet, and complete at least one unfinished step this turn if a matching tool is available. Unfinished plan steps: %s", strings.Join(unfinished, ", "))
a.mem.Add("user", message)
messages = a.mem.Messages()
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
}
if resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
}
if resp.Answer != "" {
a.mem.Add("assistant", resp.Answer)
}
reply := resp.Reply
if resp.Answer != "" {
if reply != "" {
reply += "\n\n"
}
reply += resp.Answer
}
completedToolCalls := checkpointToolCalls(run.Steps)
if a.currentRun != nil {
completedToolCalls = checkpointToolCalls(a.currentRun.Steps)
}
res := &Response{
Reply: reply,
ToolCalls: mergeCheckpointToolCalls(completedToolCalls, resp.ToolCalls),
Agent: a.opts.Name,
RunID: a.runID,
ParentID: parentRunID,
}
if a.opts.Checkpoint != nil {
if unfinished := a.unfinishedPlanSteps(); len(unfinished) > 0 {
err = fmt.Errorf("agent run %s has unfinished plan steps: %s", run.ID, strings.Join(unfinished, ", "))
run.Status = "failed"
run.State.Stage = agentAskStep
run.State.Data = []byte(message)
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "failed"
run.Steps[0].Error = err.Error()
_ = a.saveRun(ctx, run)
return nil, err
}
}
run.Status = "done"
run.State.Stage = ""
if b, marshalErr := json.Marshal(res); marshalErr == nil {
run.State.Data = b
}
if a.currentRun != nil {
run.Steps = a.currentRun.Steps
}
if len(run.Steps) == 0 {
run.Steps = []flow.StepRecord{{Name: agentAskStep}}
}
run.Steps[0].Status = "done"
run.Steps[0].Attempts++
run.Steps[0].Result = reply
if err := a.saveRun(ctx, run); err != nil {
return nil, err
}
return res, nil
}
// Chat implements the proto AgentHandler interface for RPC.
// @example {"message": "What tasks are overdue?"}
func (a *agentImpl) Chat(ctx context.Context, req *pb.ChatRequest, rsp *pb.ChatResponse) error {
resp, err := a.ask(ctx, req.Message, req.ParentId)
if err != nil {
return err
}
rsp.Reply = resp.Reply
rsp.Agent = resp.Agent
rsp.RunId = resp.RunID
rsp.ParentId = resp.ParentID
for _, tc := range resp.ToolCalls {
input, _ := json.Marshal(tc.Input)
rsp.ToolCalls = append(rsp.ToolCalls, &pb.ToolCall{
Id: tc.ID,
Name: tc.Name,
Input: string(input),
Result: tc.Result,
})
}
return nil
}
// Run starts the agent as a service with a Chat RPC endpoint.
func (a *agentImpl) Run() error {
if a.model == nil {
a.setup()
}
serverOpts := []server.Option{
server.Name(a.opts.Name),
server.Address(a.opts.Address),
server.Registry(a.opts.Registry),
server.Metadata(map[string]string{
"type": "agent",
"services": strings.Join(a.opts.Services, ","),
}),
}
if a.opts.Broker != nil {
serverOpts = append(serverOpts, server.Broker(a.opts.Broker))
}
a.server = server.NewServer(serverOpts...)
_ = pb.RegisterAgentHandler(a.server, a)
if err := a.server.Start(); err != nil {
return fmt.Errorf("failed to start agent: %w", err)
}
stopCh := make(chan struct{})
a.mu.Lock()
a.stopCh = stopCh
a.mu.Unlock()
fmt.Printf("Agent %s registered (manages: %s)\n", a.opts.Name, strings.Join(a.opts.Services, ", "))
// Optionally serve the agent directly over the A2A protocol, calling
// Ask in-process — no separate gateway needed to be queried by URL.
if a.opts.A2AAddress != "" {
card := a2a.Card(a.opts.Name, "http://localhost"+a.opts.A2AAddress, "", a.opts.Services)
handler := a2a.NewAgentStreamHandler(card, func(ctx context.Context, text string) (string, error) {
resp, err := a.Ask(ctx, text)
if err != nil {
return "", err
}
return resp.Reply, nil
}, a.streamAskAI)
go func() {
if err := http.ListenAndServe(a.opts.A2AAddress, handler); err != nil {
fmt.Printf("agent %s A2A server: %v\n", a.opts.Name, err)
}
}()
fmt.Printf("Agent %s serving A2A on %s\n", a.opts.Name, a.opts.A2AAddress)
}
<-stopCh
return nil
}
func (a *agentImpl) Stop() error {
a.mu.Lock()
if a.stopCh != nil {
close(a.stopCh)
a.stopCh = nil
}
a.mu.Unlock()
if a.server != nil {
return a.server.Stop()
}
return nil
}
func (a *agentImpl) discoverTools() ([]ai.Tool, error) {
all, err := a.tools.Discover()
if err != nil {
return nil, err
}
var scoped []ai.Tool
for _, t := range all {
if strings.HasPrefix(t.OriginalName, a.opts.Name+".") {
continue
}
if len(a.opts.Services) == 0 {
scoped = append(scoped, t)
continue
}
for _, svc := range a.opts.Services {
if strings.HasPrefix(t.OriginalName, svc+".") {
scoped = append(scoped, t)
break
}
}
}
// Developer-registered custom tools (WithTool).
for i := range a.opts.tools {
scoped = append(scoped, a.opts.tools[i].def)
}
// Expose the agent's own capabilities (plan, delegate) as tools.
// Ephemeral sub-agents don't get them.
if !a.ephemeral {
scoped = append(scoped, builtinTools()...)
}
return scoped, nil
}
func (a *agentImpl) buildPrompt() string {
var base string
switch {
case a.opts.Prompt != "":
base = a.opts.Prompt
case len(a.opts.Services) > 0:
base = fmt.Sprintf("You are the %s agent. You manage these services: %s. Use the available tools to fulfill requests.",
a.opts.Name, strings.Join(a.opts.Services, ", "))
default:
base = fmt.Sprintf("You are the %s agent. Use the available tools to fulfill requests.", a.opts.Name)
}
// Keep the agent oriented: surface its saved plan, if any.
if !a.ephemeral {
if plan := a.loadPlan(); plan != "" {
base += "\n\nYour current plan (update it with the plan tool as you make progress):\n" + plan
}
}
return base
}