Compare commits

..

1 Commits

Author SHA1 Message Date
Codex 06d77eb9f7 Improve A2A streaming task lifecycle
Harness (E2E) / Provider harnesses (live LLM conformance) (push) Waiting to run
Harness (E2E) / Harnesses (mock LLM) (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
2026-06-27 22:57:08 +00:00
27 changed files with 193 additions and 1059 deletions
+1 -1
View File
@@ -19,7 +19,7 @@ else is additive. See the [v5 → v6 migration guide](internal/website/docs/guid
- **JWT auth ported in-module.** The external `github.com/micro/plugins/v5/auth/jwt` (pinned to v5) is replaced by `go-micro.dev/v6/auth/jwt/token`, now on the maintained `golang-jwt/jwt/v5`; the deprecated `dgrijalva/jwt-go` dependency is dropped.
### Added
- **A2A protocol — both directions** — `gateway/a2a` exposes registered agents over the open Agent2Agent (A2A) protocol so agents on other frameworks can discover and call them: Agent Cards are generated from registry metadata (the same way the MCP gateway derives tools), and incoming tasks are translated to the agent's existing `Agent.Chat` RPC, with no per-agent code (`micro a2a serve`). The outbound `a2a.Client` calls external A2A agents by URL, wired into `flow.A2A(url)` (a workflow step) and `delegate` to an `http(s)` URL (from inside an agent). An agent can also serve A2A **directly** without a gateway via `AgentA2A(addr)` (`a2a.NewAgentHandler`), handling tasks in-process. The JSON-RPC binding includes `message/send`, `message/stream` (SSE), `tasks/get`, multi-turn continuation by `taskId`/`contextId`, best-effort push notification callbacks, and card discovery. `input-required` and `tasks/resubscribe` remain unsupported. (`gateway/a2a/`, `cmd/micro/a2a/`)
- **A2A protocol — both directions** — `gateway/a2a` exposes registered agents over the open Agent2Agent (A2A) protocol so agents on other frameworks can discover and call them: Agent Cards are generated from registry metadata (the same way the MCP gateway derives tools), and incoming tasks are translated to the agent's existing `Agent.Chat` RPC, with no per-agent code (`micro a2a serve`). The outbound `a2a.Client` calls external A2A agents by URL, wired into `flow.A2A(url)` (a workflow step) and `delegate` to an `http(s)` URL (from inside an agent). An agent can also serve A2A **directly** without a gateway via `AgentA2A(addr)` (`a2a.NewAgentHandler`), handling tasks in-process. v1 is the synchronous JSON-RPC binding (`message/send`, `tasks/get`, card discovery); streaming and push notifications are advertised as unsupported. (`gateway/a2a/`, `cmd/micro/a2a/`)
- **Agents (`micro.NewAgent`)** — an agent is a service with an LLM inside: it discovers its assigned services as tools, runs the model's tool loop, registers a `Chat` RPC endpoint, and is reachable like any service. `Ask` for programmatic use; `micro chat` discovers and routes to agents; `micro agent list`/`describe`. (`agent/`)
- **Plan & delegate** — two built-in agent tools added to every agent: `plan` (an ordered, store-persisted plan surfaced back in the prompt) and `delegate` (hand a self-contained subtask to a registered agent over RPC, otherwise to an ephemeral sub-agent). No harness or graph — they're plain tools. (`agent/builtin.go`, `examples/agent-plan-delegate/`)
- **Agent guardrails** — `MaxSteps` (stop on count), `LoopLimit` (stop repeated no-progress calls; on by default), and `ApproveTool` (human-in-the-loop / policy gate before each action), enforced at the one point every tool call passes through. (`agent/`, guide + blog)
+2 -2
View File
@@ -237,7 +237,7 @@ Just as a service composes pluggable abstractions (registry, broker, store), an
```go
agent := micro.NewAgent("assistant",
micro.AgentProvider("anthropic"), // model — swap the provider
micro.AgentCompactMemory(40, 12), // memory — durable, summarized, recallable
micro.AgentMemory(micro.NewInMemory(50)), // memory — default is store-backed & durable
micro.AgentTool("weather", "Get the weather for a city",
map[string]any{"city": map[string]any{"type": "string"}},
func(ctx context.Context, in map[string]any) (string, error) {
@@ -247,7 +247,7 @@ agent := micro.NewAgent("assistant",
)
```
**Memory** is durable and store-backed by default (Postgres, NATS KV, or file), so an agent picks up where it left off after a restart — or supply your own with `AgentMemory`. Long-running agents can opt into `AgentCompactMemory(maxMessages, keepRecent)`: older turns are collapsed into a deterministic summary, recent turns stay verbatim, and relevant archived turns are recalled on future asks without replaying the whole conversation. **Tools** are your services automatically, plus any function you register with `AgentTool`.
**Memory** is durable and store-backed by default (Postgres, NATS KV, or file), so an agent picks up where it left off after a restart — or supply your own with `AgentMemory`. **Tools** are your services automatically, plus any function you register with `AgentTool`.
### Paid tools (x402)
+4 -5
View File
@@ -15,8 +15,7 @@ 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,
push notifications, and multi-turn continuation), x402 paid tools, secure by
flows, the MCP and A2A gateways (both directions), x402 paid tools, secure by
default.
## Principles
@@ -42,14 +41,14 @@ default.
## 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.
- **Streaming** — `ai.Stream` + A2A `message/stream`, end to end.
- **Agent observability** — `RunInfo` → OpenTelemetry spans.
## Later
- Memory management (summarization, retrieval/RAG); human-in-the-loop pause/resume;
richer A2A live-stream reconnection (`tasks/resubscribe`) and `input-required`
handoffs.
x402 live-facilitator conformance and paid remote tools with spend caps; A2A
streaming, push notifications, and multi-turn tasks.
## Developer experience (ongoing)
+1 -31
View File
@@ -87,11 +87,6 @@ type agentImpl struct {
// 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
}
// New creates a new Agent.
@@ -155,8 +150,6 @@ func (a *agentImpl) setup() {
a.mem = a.opts.Memory
case a.ephemeral:
a.mem = NewInMemory(a.opts.HistoryLimit)
case a.opts.MemoryCompaction.MaxMessages > 0:
a.mem = NewCompactingMemory(a.stateStore(), "history", a.opts.MemoryCompaction.MaxMessages, a.opts.MemoryCompaction.KeepRecent)
default:
a.mem = NewMemory(a.stateStore(), "history", a.opts.HistoryLimit)
}
@@ -243,7 +236,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
a.mem.Add("user", message)
a.steps = 0
a.calls = map[string]int{}
a.pause = nil
// Correlate this run's tool calls and surface lineage to wrappers.
a.runID = runID
@@ -259,21 +251,11 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
ctx, endRun := a.startRun(ctx, message)
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...)...)
}
}
resp, err := ai.GenerateWithRetry(ctx, a.model, &ai.Request{
Prompt: message,
SystemPrompt: a.buildPrompt(),
Tools: toolList,
Messages: messages,
Messages: a.mem.Messages(),
}, ai.GeneratePolicy{
Timeout: a.opts.ModelTimeout,
MaxAttempts: a.opts.ModelMaxAttempts,
@@ -286,18 +268,6 @@ func (a *agentImpl) askLocked(ctx context.Context, runID, message, parentRunID s
_ = 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)
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 resp.Reply != "" {
a.mem.Add("assistant", resp.Reply)
-6
View File
@@ -200,11 +200,6 @@ func (a *agentImpl) loopWrap(next ai.ToolHandler) ai.ToolHandler {
}
// approveWrap gates each action before it runs (ApproveTool).
type approvalPause struct {
Tool string
Message string
}
func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
return func(ctx context.Context, call ai.ToolCall) ai.ToolResult {
if a.opts.Approve != nil {
@@ -213,7 +208,6 @@ func (a *agentImpl) approveWrap(next ai.ToolHandler) ai.ToolHandler {
if reason != "" {
msg += ": " + reason
}
a.pause = &approvalPause{Tool: call.Name, Message: msg}
return refused(call.ID, ai.RefusedApproval, msg)
}
}
+1 -8
View File
@@ -9,10 +9,7 @@ import (
"go-micro.dev/v6/flow"
)
const (
agentAskStep = "ask"
agentApprovalStep = "approval"
)
const agentAskStep = "ask"
func (a *agentImpl) newCheckpointRun(runID, message, parentRunID string, existing *flow.Run) flow.Run {
now := time.Now()
@@ -60,10 +57,6 @@ func (a *agentImpl) resume(ctx context.Context, runID string) (*Response, error)
if !ok {
return nil, fmt.Errorf("agent run %s not found", runID)
}
if run.Status == "paused" {
run.Status = "running"
run.State.Stage = agentAskStep
}
if run.Status == "done" {
var resp Response
if err := json.Unmarshal(run.State.Data, &resp); err != nil {
-68
View File
@@ -64,71 +64,3 @@ func TestPendingReturnsUnfinishedAgentRuns(t *testing.T) {
t.Fatalf("Pending = %#v, want run-1", runs)
}
}
func TestApprovalDenialPausesCheckpointedRunAndResumeContinues(t *testing.T) {
ctx := context.Background()
cp := flow.StoreCheckpoint(store.NewStore(), "approval-agent")
calls := 0
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if opts.ToolHandler != nil {
opts.ToolHandler(ctx, ai.ToolCall{ID: "call-1", Name: "external.approve", Input: map[string]any{"id": "42"}})
}
return &ai.Response{Reply: "model saw approval result"}, nil
}
defer func() { fakeGen = nil }()
approved := false
a := newTestAgent(Name("approval-agent"), WithCheckpoint(cp),
WithTool("external.approve", "guarded external action", nil, func(context.Context, map[string]any) (string, error) { return "ok", nil }),
ApproveTool(func(tool string, input map[string]any) (bool, string) {
return approved, "waiting for operator"
}))
_, err := a.Ask(ctx, "send the guarded update")
if err == nil {
t.Fatal("Ask succeeded, want paused approval error")
}
runs, err := Pending(ctx, a)
if err != nil {
t.Fatalf("Pending: %v", err)
}
if len(runs) != 1 {
t.Fatalf("Pending returned %d runs, want 1: %#v", len(runs), runs)
}
if runs[0].Status != "paused" || runs[0].State.Stage != agentApprovalStep {
t.Fatalf("run status/stage = %s/%s, want paused/%s", runs[0].Status, runs[0].State.Stage, agentApprovalStep)
}
if got := string(runs[0].State.Data); got != "send the guarded update" {
t.Fatalf("paused run data = %q", got)
}
approved = true
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
calls++
if opts.ToolHandler != nil {
res := opts.ToolHandler(ctx, ai.ToolCall{ID: "call-2", Name: "external.approve", Input: map[string]any{"id": "42"}})
if res.Refused != "" {
t.Fatalf("resumed call was refused: %#v", res)
}
}
return &ai.Response{Reply: "done after approval"}, nil
}
resp, err := Resume(ctx, a, runs[0].ID)
if err != nil {
t.Fatalf("Resume: %v", err)
}
if resp.Reply != "done after approval" {
t.Fatalf("Resume reply = %q", resp.Reply)
}
loaded, ok, err := cp.Load(ctx, runs[0].ID)
if err != nil || !ok {
t.Fatalf("Load resumed run ok=%v err=%v", ok, err)
}
if loaded.Status != "done" {
t.Fatalf("resumed run status = %q, want done", loaded.Status)
}
if calls != 2 {
t.Fatalf("model calls = %d, want 2", calls)
}
}
-43
View File
@@ -179,46 +179,3 @@ func TestDelegateRequiresTask(t *testing.T) {
t.Errorf("delegate with no task should error; got %q", content)
}
}
func TestCompactingMemorySummarizesAndRecallsArchivedContext(t *testing.T) {
var sawSummary, sawRecall bool
fakeGen = func(ctx context.Context, opts ai.Options, req *ai.Request) (*ai.Response, error) {
for _, msg := range req.Messages {
text := msg.Content.(string)
if strings.Contains(text, "Conversation memory summary") && strings.Contains(text, "alpha project") {
sawSummary = true
}
if strings.Contains(text, "alpha project budget is 42") {
sawRecall = true
}
}
return &ai.Response{Reply: "ok"}, nil
}
defer func() { fakeGen = nil }()
a := newTestAgent(Name("memory"), CompactMemory(4, 2), MemoryRecallLimit(3))
turns := []string{
"alpha project budget is 42",
"beta project owner is sam",
"gamma project deadline is monday",
"delta project status is green",
"epsilon project risk is low",
}
for _, turn := range turns {
if _, err := a.Ask(context.Background(), turn); err != nil {
t.Fatalf("Ask(%q): %v", turn, err)
}
}
if got := len(a.mem.Messages()); got > 4 {
t.Fatalf("compacted memory retained %d messages, want <= 4", got)
}
if _, err := a.Ask(context.Background(), "what was the alpha budget?"); err != nil {
t.Fatalf("Ask recall: %v", err)
}
if !sawSummary {
t.Error("model request did not include a deterministic compacted summary")
}
if !sawRecall {
t.Error("model request did not recall archived matching context")
}
}
+9 -159
View File
@@ -2,8 +2,6 @@ package agent
import (
"encoding/json"
"fmt"
"strings"
"sync"
"go-micro.dev/v6/ai"
@@ -23,21 +21,6 @@ type Memory interface {
Clear()
}
// MemoryCompaction configures deterministic, store-backed context compaction
// for the default memory implementation. When the retained conversation grows
// past MaxMessages, older turns are collapsed into a summary message while the
// newest KeepRecent turns stay verbatim for provider-neutral continuity.
type MemoryCompaction struct {
MaxMessages int
KeepRecent int
}
// MemoryRecall is implemented by memory backends that can retrieve durable
// prior context relevant to a new turn without replaying every stored message.
type MemoryRecall interface {
Recall(query string, limit int) []ai.Message
}
// NewMemory returns the default store-backed memory: an in-process
// conversation buffer (truncated to limit) that persists to the store
// under key, so an agent picks up where it left off after a restart.
@@ -48,33 +31,6 @@ func NewMemory(s store.Store, key string, limit int) Memory {
return m
}
// NewCompactingMemory returns store-backed memory with explicit compaction and
// retrieval controls. It keeps all messages in the backing store, compacts older
// turns into a deterministic summary when the conversation exceeds maxMessages,
// and lets callers recall relevant prior turns with Recall.
func NewCompactingMemory(s store.Store, key string, maxMessages, keepRecent int) Memory {
if keepRecent <= 0 {
keepRecent = maxMessages / 2
}
if keepRecent < 1 {
keepRecent = 1
}
m := &storeMemory{
store: s,
key: key,
// Use an unlimited buffer here; compaction, not truncation, decides
// what remains in active context so a summary can preserve older turns.
hist: ai.NewHistory(0),
compaction: MemoryCompaction{
MaxMessages: maxMessages,
KeepRecent: keepRecent,
},
}
m.load()
m.compact()
return m
}
// NewInMemory returns conversation memory that is not persisted.
func NewInMemory(limit int) Memory {
return &storeMemory{hist: ai.NewHistory(limit)}
@@ -83,19 +39,16 @@ func NewInMemory(limit int) Memory {
// storeMemory is the default Memory: an ai.History buffer optionally
// persisted to a store.
type storeMemory struct {
mu sync.Mutex
store store.Store
key string
hist *ai.History
compaction MemoryCompaction
archive []ai.Message
mu sync.Mutex
store store.Store
key string
hist *ai.History
}
func (m *storeMemory) Add(role, content string) {
m.mu.Lock()
m.hist.Add(role, content)
m.mu.Unlock()
m.compact()
m.save()
}
@@ -108,35 +61,10 @@ func (m *storeMemory) Messages() []ai.Message {
func (m *storeMemory) Clear() {
m.mu.Lock()
m.hist.Reset()
m.archive = nil
m.mu.Unlock()
m.save()
}
// Recall returns archived messages whose content contains words from query.
// It is deterministic and provider-neutral: no embeddings or model calls are
// required, but semantic/vector stores can replace Memory for richer retrieval.
func (m *storeMemory) Recall(query string, limit int) []ai.Message {
m.mu.Lock()
defer m.mu.Unlock()
if limit <= 0 {
limit = 5
}
terms := recallTerms(query)
var out []ai.Message
for i := len(m.archive) - 1; i >= 0 && len(out) < limit; i-- {
msg := m.archive[i]
text := strings.ToLower(fmt.Sprint(msg.Content))
for _, term := range terms {
if strings.Contains(text, term) {
out = append(out, msg)
break
}
}
}
return out
}
func (m *storeMemory) load() {
if m.store == nil || m.key == "" {
return
@@ -145,17 +73,12 @@ func (m *storeMemory) load() {
if err != nil || len(recs) == 0 {
return
}
var state memoryState
if err := json.Unmarshal(recs[0].Value, &state); err != nil {
var msgs []ai.Message
if err := json.Unmarshal(recs[0].Value, &msgs); err != nil {
return
}
state.Messages = msgs
var msgs []ai.Message
if err := json.Unmarshal(recs[0].Value, &msgs); err != nil {
return
}
m.mu.Lock()
m.archive = state.Archive
for _, msg := range state.Messages {
for _, msg := range msgs {
m.hist.Add(msg.Role, msg.Content)
}
m.mu.Unlock()
@@ -166,83 +89,10 @@ func (m *storeMemory) save() {
return
}
m.mu.Lock()
data, err := json.Marshal(memoryState{
Messages: m.hist.Messages(),
Archive: m.archive,
})
data, err := json.Marshal(m.hist.Messages())
m.mu.Unlock()
if err != nil {
return
}
_ = m.store.Write(&store.Record{Key: m.key, Value: data})
}
func (m *storeMemory) compact() {
if m.compaction.MaxMessages <= 0 {
return
}
m.mu.Lock()
defer m.mu.Unlock()
msgs := m.hist.Messages()
if len(msgs) <= m.compaction.MaxMessages {
return
}
keep := m.compaction.KeepRecent
if keep <= 0 || keep >= m.compaction.MaxMessages {
keep = m.compaction.MaxMessages - 1
}
if keep < 1 {
keep = 1
}
cut := len(msgs) - keep
older := msgs[:cut]
recent := msgs[cut:]
m.archive = append(m.archive, older...)
summary := ai.Message{
Role: "system",
Content: fmt.Sprintf("Conversation memory summary: %s", summarizeMessages(older)),
}
m.hist.Reset()
m.hist.Add(summary.Role, summary.Content)
for _, msg := range recent {
m.hist.Add(msg.Role, msg.Content)
}
}
func summarizeMessages(msgs []ai.Message) string {
var b strings.Builder
for i, msg := range msgs {
if i > 0 {
b.WriteString(" | ")
}
fmt.Fprintf(&b, "%s: %s", msg.Role, compactText(fmt.Sprint(msg.Content), 120))
}
return b.String()
}
func compactText(s string, max int) string {
s = strings.Join(strings.Fields(s), " ")
if max > 0 && len(s) > max {
return s[:max] + "…"
}
return s
}
func recallTerms(query string) []string {
seen := map[string]bool{}
var terms []string
for _, term := range strings.Fields(strings.ToLower(query)) {
term = strings.Trim(term, ".,!?;:\"'()[]{}")
if len(term) < 3 || seen[term] {
continue
}
seen[term] = true
terms = append(terms, term)
}
return terms
}
type memoryState struct {
Messages []ai.Message `json:"messages"`
Archive []ai.Message `json:"archive,omitempty"`
}
-26
View File
@@ -60,13 +60,6 @@ type Options struct {
// Memory is the agent's conversation memory. Nil = the default
// store-backed memory (durable across restarts).
Memory Memory
// MemoryCompaction enables deterministic compaction/retrieval on the
// default store-backed memory. Custom Memory implementations can expose
// retrieval by implementing MemoryRecall.
MemoryCompaction MemoryCompaction
// MemoryRecallLimit bounds recalled archived turns injected into a model
// request (0 disables recall injection).
MemoryRecallLimit int
// Checkpoint persists agent Ask runs so callers can resume by run id
// after a restart without replaying a run that already completed.
Checkpoint flow.Checkpoint
@@ -222,25 +215,6 @@ func WithMemory(m Memory) Option {
return func(o *Options) { o.Memory = m }
}
// CompactMemory enables deterministic, store-backed memory compaction for the
// default agent memory. Older turns are summarized once active context exceeds
// maxMessages, keepRecent newest turns remain verbatim, and recalled archived
// turns are injected into matching future asks.
func CompactMemory(maxMessages, keepRecent int) Option {
return func(o *Options) {
o.MemoryCompaction = MemoryCompaction{MaxMessages: maxMessages, KeepRecent: keepRecent}
if o.MemoryRecallLimit == 0 {
o.MemoryRecallLimit = 5
}
}
}
// MemoryRecallLimit sets how many archived turns a memory backend may inject
// into a model request for the current Ask. Use 0 to disable retrieval.
func MemoryRecallLimit(n int) Option {
return func(o *Options) { o.MemoryRecallLimit = n }
}
// WithCheckpoint sets the durability backend for agent Ask runs. The
// Checkpoint interface is shared with flow so services, agents, and workflows
// can use one execution history backend. When set, each Ask is saved as a
+7 -11
View File
@@ -116,18 +116,14 @@ const (
// the agent package. Flows also attach their name and current step so
// tools and agents called from a workflow can be tied back to the
// services → agents → workflows lifecycle that invoked them. Per-call
// detail (tool name, id) is on the ToolCall. Attempt and MaxAttempts are
// set while a model Generate call is in progress, so tools and wrappers can
// tell which provider attempt produced the call and whether it is part of a
// retry budget. They are zero when no model-attempt context is known.
// detail (tool name, id) is on the ToolCall; attempt counts are naturally
// counted by the wrapper itself.
type RunInfo struct {
RunID string // correlation id for this agent or flow run
ParentID string // the run that delegated to this one, if any
Agent string // the agent's name
Flow string // the flow's name, when the call is part of a workflow
Step string // the flow step currently executing, when known
Attempt int // current model Generate attempt, starting at 1 when known
MaxAttempts int // configured model Generate attempt budget when known
RunID string // correlation id for this agent or flow run
ParentID string // the run that delegated to this one, if any
Agent string // the agent's name
Flow string // the flow's name, when the call is part of a workflow
Step string // the flow step currently executing, when known
}
type runInfoKey struct{}
-5
View File
@@ -60,11 +60,6 @@ func GenerateWithRetry(ctx context.Context, m Model, req *Request, policy Genera
if policy.Timeout > 0 {
callCtx, cancel = context.WithTimeout(ctx, policy.Timeout)
}
if info, ok := RunInfoFrom(callCtx); ok {
info.Attempt = attempt
info.MaxAttempts = policy.MaxAttempts
callCtx = WithRunInfo(callCtx, info)
}
resp, err := m.Generate(callCtx, req, opts...)
cancel()
if err == nil {
-39
View File
@@ -94,42 +94,3 @@ func TestGenerateWithRetryHonorsPerAttemptTimeout(t *testing.T) {
t.Fatalf("attempts = %d, want 2", attempts)
}
}
func TestGenerateWithRetryAddsAttemptMetadataToRunInfo(t *testing.T) {
var got []RunInfo
model := retryModel{generate: func(ctx context.Context, _ *Request, _ ...GenerateOption) (*Response, error) {
info, ok := RunInfoFrom(ctx)
if !ok {
t.Fatal("RunInfo missing from attempt context")
}
got = append(got, info)
if info.Attempt == 1 {
return nil, errors.New("temporary provider outage")
}
return &Response{Reply: "ok"}, nil
}}
ctx := WithRunInfo(context.Background(), RunInfo{RunID: "run-1", Agent: "worker"})
_, err := GenerateWithRetry(ctx, model, &Request{Prompt: "hi"}, GeneratePolicy{
MaxAttempts: 2,
Backoff: time.Millisecond,
})
if err != nil {
t.Fatalf("GenerateWithRetry returned error: %v", err)
}
if len(got) != 2 {
t.Fatalf("attempt contexts = %d, want 2", len(got))
}
for i, info := range got {
wantAttempt := i + 1
if info.Attempt != wantAttempt {
t.Fatalf("attempt %d RunInfo.Attempt = %d, want %d", i, info.Attempt, wantAttempt)
}
if info.MaxAttempts != 2 {
t.Fatalf("attempt %d RunInfo.MaxAttempts = %d, want 2", i, info.MaxAttempts)
}
if info.RunID != "run-1" || info.Agent != "worker" {
t.Fatalf("attempt %d RunInfo identity = (%q, %q), want (run-1, worker)", i, info.RunID, info.Agent)
}
}
}
+13 -122
View File
@@ -20,9 +20,9 @@
//
// Scope of this version: the JSON-RPC binding — `message/send`
// (returns a completed Task), `message/stream` (SSE with the completed
// Task event), `tasks/get`, multi-turn task continuation, push
// notification delivery, and Agent Card discovery. `input-required` and
// `tasks/resubscribe` are advertised as unsupported and are follow-ups.
// Task event), `tasks/get`, and Agent Card discovery. Multi-turn
// `input-required`, `tasks/resubscribe`, and push notifications are
// advertised as unsupported and are follow-ups.
package a2a
import (
@@ -225,15 +225,6 @@ type Task struct {
Kind string `json:"kind"` // "task"
}
// PushNotificationConfig tells the gateway where to POST task updates for a
// task. The gateway stores one config per task and delivers best-effort JSON
// task snapshots whenever that task changes.
type PushNotificationConfig struct {
URL string `json:"url"`
Token string `json:"token,omitempty"`
Authentication map[string]string `json:"authentication,omitempty"`
}
// Task states (JSON-RPC binding wire values).
const (
stateCompleted = "completed"
@@ -343,7 +334,7 @@ func Card(name, url, description string, services []string) AgentCard {
URL: url,
Version: "1.0.0",
ProtocolVersion: protocolVersion,
Capabilities: Capabilities{Streaming: true, PushNotifications: true},
Capabilities: Capabilities{Streaming: true, PushNotifications: false},
// The agent converses over a single Chat endpoint; advertise that
// as one skill, tagged with the services it manages.
DefaultInputModes: []string{"text/plain"},
@@ -424,15 +415,12 @@ func (g *Gateway) handleRPC(w http.ResponseWriter, r *http.Request) {
// retains recent tasks for tasks/get. It is shared by the gateway (one
// per registry) and embedded agents (one per agent).
type dispatcher struct {
mu sync.Mutex
tasks map[string]*Task
pushConfigs map[string]PushNotificationConfig
order []string // task ids in insertion order, for bounded eviction
mu sync.Mutex
tasks map[string]*Task
order []string // task ids in insertion order, for bounded eviction
}
func newDispatcher() *dispatcher {
return &dispatcher{tasks: map[string]*Task{}, pushConfigs: map[string]PushNotificationConfig{}}
}
func newDispatcher() *dispatcher { return &dispatcher{tasks: map[string]*Task{}} }
func (d *dispatcher) serve(w http.ResponseWriter, r *http.Request, invoke Invoke) {
d.serveWithStream(w, r, invoke, nil)
@@ -460,10 +448,6 @@ func (d *dispatcher) serveWithStream(w http.ResponseWriter, r *http.Request, inv
d.stream(requestContext(r.Context()), w, req, invoke)
case "tasks/get":
d.get(w, req)
case "tasks/pushNotificationConfig/set":
d.setPushConfig(w, req)
case "tasks/pushNotificationConfig/get":
d.getPushConfig(w, req)
case "tasks/cancel":
// v1 tasks complete synchronously, so they're already terminal.
writeRPC(w, req.ID, nil, &rpcError{Code: errNotCancelable, Message: "task is not cancelable"})
@@ -578,7 +562,7 @@ func (d *dispatcher) run(ctx context.Context, params json.RawMessage, invoke Inv
reply = "error: " + err.Error()
state = stateFailed
}
task := d.taskFromReply(p.Message, reply, state)
task := taskFromReply(p.Message, reply, state)
d.store(task)
return task, nil
}
@@ -603,47 +587,6 @@ func (d *dispatcher) get(w http.ResponseWriter, req rpcRequest) {
writeRPC(w, req.ID, task, nil)
}
type pushConfigParams struct {
ID string `json:"id"`
PushNotificationConfig PushNotificationConfig `json:"pushNotificationConfig"`
}
func (d *dispatcher) setPushConfig(w http.ResponseWriter, req rpcRequest) {
var p pushConfigParams
if err := json.Unmarshal(req.Params, &p); err != nil || p.ID == "" || p.PushNotificationConfig.URL == "" {
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
return
}
d.mu.Lock()
task := d.tasks[p.ID]
if task != nil {
d.pushConfigs[p.ID] = p.PushNotificationConfig
}
d.mu.Unlock()
if task == nil {
writeRPC(w, req.ID, nil, &rpcError{Code: errTaskNotFound, Message: "task not found"})
return
}
writeRPC(w, req.ID, map[string]any{"id": p.ID, "pushNotificationConfig": p.PushNotificationConfig}, nil)
go d.deliverPush(p.ID, task)
}
func (d *dispatcher) getPushConfig(w http.ResponseWriter, req rpcRequest) {
var p getParams
if err := json.Unmarshal(req.Params, &p); err != nil || p.ID == "" {
writeRPC(w, req.ID, nil, &rpcError{Code: errInvalidParams, Message: "invalid params"})
return
}
d.mu.Lock()
cfg, ok := d.pushConfigs[p.ID]
d.mu.Unlock()
if !ok {
writeRPC(w, req.ID, nil, &rpcError{Code: errTaskNotFound, Message: "push notification config not found"})
return
}
writeRPC(w, req.ID, map[string]any{"id": p.ID, "pushNotificationConfig": cfg}, nil)
}
// ---------------------------------------------------------------------------
// agent RPC
// ---------------------------------------------------------------------------
@@ -672,55 +615,30 @@ func (g *Gateway) callAgent(ctx context.Context, name, message string) (string,
func (d *dispatcher) store(t *Task) {
d.mu.Lock()
defer d.mu.Unlock()
d.tasks[t.ID] = t
d.order = append(d.order, t.ID)
for len(d.order) > maxTasks {
oldest := d.order[0]
d.order = d.order[1:]
delete(d.tasks, oldest)
delete(d.pushConfigs, oldest)
}
d.mu.Unlock()
go d.deliverPush(t.ID, t)
}
func (d *dispatcher) taskFromReply(input Message, reply, state string) *Task {
func taskFromReply(input Message, reply, state string) *Task {
contextID := input.ContextID
taskID := input.TaskID
var history []Message
if taskID != "" {
d.mu.Lock()
prev := d.tasks[taskID]
if prev != nil {
contextID = prev.ContextID
history = append(history, prev.History...)
}
d.mu.Unlock()
}
if taskID == "" {
taskID = uuid.New().String()
}
if contextID == "" {
contextID = uuid.New().String()
}
return taskFromReplyWithIDsAndHistory(input, reply, state, taskID, contextID, history)
return taskFromReplyWithIDs(input, reply, state, uuid.New().String(), contextID)
}
func taskFromReplyWithIDs(input Message, reply, state, taskID, contextID string) *Task {
return taskFromReplyWithIDsAndHistory(input, reply, state, taskID, contextID, nil)
}
func taskFromReplyWithIDsAndHistory(input Message, reply, state, taskID, contextID string, history []Message) *Task {
input.TaskID = taskID
input.ContextID = contextID
if input.Kind == "" {
input.Kind = "message"
}
task := &Task{
ID: taskID,
ContextID: contextID,
Kind: "task",
History: append(append([]Message{}, history...), input),
History: []Message{input},
Status: TaskStatus{State: state, Timestamp: time.Now().UTC().Format(time.RFC3339)},
Artifacts: []Artifact{textArtifact(reply)},
}
@@ -735,33 +653,6 @@ func taskFromReplyWithIDsAndHistory(input Message, reply, state, taskID, context
return task
}
func (d *dispatcher) deliverPush(taskID string, task *Task) {
d.mu.Lock()
cfg, ok := d.pushConfigs[taskID]
d.mu.Unlock()
if !ok || cfg.URL == "" || task == nil {
return
}
body, err := json.Marshal(task)
if err != nil {
return
}
ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
req, err := http.NewRequestWithContext(ctx, http.MethodPost, cfg.URL, strings.NewReader(string(body)))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
if cfg.Token != "" {
req.Header.Set("Authorization", "Bearer "+cfg.Token)
}
resp, err := http.DefaultClient.Do(req)
if err == nil && resp.Body != nil {
_ = resp.Body.Close()
}
}
func textOf(parts []Part) string {
var b strings.Builder
for _, p := range parts {
-106
View File
@@ -120,89 +120,6 @@ func TestMessageSendAndGet(t *testing.T) {
}
}
func TestMessageSendContinuesExistingTask(t *testing.T) {
d := newDispatcher()
first := rpcTaskFromBody(t, d, `{
"jsonrpc":"2.0","id":1,"method":"message/send",
"params":{"message":{"role":"user","kind":"message","messageId":"m1",
"parts":[{"kind":"text","text":"first"}]}}}`, func(_ context.Context, text string) (string, error) {
return "reply to " + text, nil
})
secondBody := fmt.Sprintf(`{
"jsonrpc":"2.0","id":2,"method":"message/send",
"params":{"message":{"role":"user","kind":"message","messageId":"m2","taskId":"%s","contextId":"%s",
"parts":[{"kind":"text","text":"second"}]}}}`, first.ID, first.ContextID)
second := rpcTaskFromBody(t, d, secondBody, func(_ context.Context, text string) (string, error) {
return "reply to " + text, nil
})
if second.ID != first.ID || second.ContextID != first.ContextID {
t.Fatalf("continued task identity = %s/%s, want %s/%s", second.ID, second.ContextID, first.ID, first.ContextID)
}
if len(second.History) != 4 {
t.Fatalf("continued history len = %d, want 4: %+v", len(second.History), second.History)
}
if textOf(second.History[0].Parts) != "first" || textOf(second.History[2].Parts) != "second" {
t.Fatalf("continued history did not preserve turns: %+v", second.History)
}
got := rpcTaskFromDispatcher(t, d, first.ID)
if got.ID != first.ID || len(got.History) != 4 {
t.Fatalf("stored continued task = %+v", got)
}
}
func TestPushNotificationConfigDeliversTaskUpdates(t *testing.T) {
d := newDispatcher()
updates := make(chan Task, 2)
push := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
if got := r.Header.Get("Authorization"); got != "Bearer secret" {
t.Errorf("authorization = %q, want bearer token", got)
}
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
t.Errorf("decode push task: %v", err)
return
}
updates <- task
w.WriteHeader(http.StatusAccepted)
}))
defer push.Close()
task := rpcTaskFromBody(t, d, `{
"jsonrpc":"2.0","id":1,"method":"message/send",
"params":{"message":{"role":"user","kind":"message","messageId":"m1",
"parts":[{"kind":"text","text":"ping"}]}}}`, func(_ context.Context, text string) (string, error) {
return "pong", nil
})
body := fmt.Sprintf(`{"jsonrpc":"2.0","id":2,"method":"tasks/pushNotificationConfig/set","params":{"id":"%s","pushNotificationConfig":{"url":"%s","token":"secret"}}}`, task.ID, push.URL)
var setResp struct {
Result struct {
ID string `json:"id"`
PushNotificationConfig PushNotificationConfig `json:"pushNotificationConfig"`
} `json:"result"`
Error *rpcError `json:"error"`
}
rpcDispatcher(t, d, body, nil, &setResp)
if setResp.Error != nil {
t.Fatalf("set push config error: %+v", setResp.Error)
}
if setResp.Result.ID != task.ID || setResp.Result.PushNotificationConfig.URL != push.URL {
t.Fatalf("set push config result = %+v", setResp.Result)
}
select {
case got := <-updates:
if got.ID != task.ID || got.Status.State != stateCompleted || textOf(got.Artifacts[0].Parts) != "pong" {
t.Fatalf("push update = %+v, want completed task", got)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for push update")
}
}
func TestMessageSendUsesRequestContext(t *testing.T) {
d := newDispatcher()
ctx, cancel := context.WithCancel(context.Background())
@@ -378,29 +295,6 @@ func rpcTaskFromDispatcher(t *testing.T, d *dispatcher, id string) Task {
return resp.Result
}
func rpcTaskFromBody(t *testing.T, d *dispatcher, body string, invoke Invoke) Task {
t.Helper()
var resp struct {
Result Task `json:"result"`
Error *rpcError `json:"error"`
}
rpcDispatcher(t, d, body, invoke, &resp)
if resp.Error != nil {
t.Fatalf("rpc error: %+v", resp.Error)
}
return resp.Result
}
func rpcDispatcher(t *testing.T, d *dispatcher, body string, invoke Invoke, v any) {
t.Helper()
req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body))
rr := httptest.NewRecorder()
d.serve(rr, req, invoke)
if err := json.NewDecoder(rr.Result().Body).Decode(v); err != nil {
t.Fatalf("decode dispatcher response: %v", err)
}
}
func TestUnknownMethod(t *testing.T) {
ts, cleanup := newGatewayWithAgent(t)
defer cleanup()
+11 -62
View File
@@ -60,38 +60,15 @@ func (c *Client) Card(ctx context.Context) (*AgentCard, error) {
// If the agent returns a task that isn't yet terminal, Send polls
// tasks/get until it completes or ctx is done.
func (c *Client) Send(ctx context.Context, text string) (string, error) {
task, err := c.SendMessage(ctx, Message{
res, err := c.call(ctx, "message/send", sendParams{Message: Message{
Role: "user",
Kind: "message",
MessageID: uuid.New().String(),
Parts: []Part{{Kind: "text", Text: text}},
})
}})
if err != nil {
return "", err
}
if task.Status.State != stateCompleted {
return "", fmt.Errorf("remote task %s ended in state %q", task.ID, task.Status.State)
}
return artifactsText(task.Artifacts), nil
}
// SendMessage sends an A2A message and returns the resulting terminal task.
// To continue a multi-turn task, pass a Message with TaskID and ContextID set
// to a prior task's id and context id.
func (c *Client) SendMessage(ctx context.Context, message Message) (*Task, error) {
if message.MessageID == "" {
message.MessageID = uuid.New().String()
}
if message.Kind == "" {
message.Kind = "message"
}
if message.Role == "" {
message.Role = "user"
}
res, err := c.call(ctx, "message/send", sendParams{Message: message})
if err != nil {
return nil, err
}
// The result is a Message or a Task; the "kind" field disambiguates.
var probe struct {
@@ -102,61 +79,33 @@ func (c *Client) SendMessage(ctx context.Context, message Message) (*Task, error
if probe.Kind == "message" {
var m Message
if err := json.Unmarshal(res, &m); err != nil {
return nil, err
return "", err
}
return &Task{
ID: m.TaskID,
ContextID: m.ContextID,
Kind: "task",
Status: TaskStatus{State: stateCompleted, Timestamp: time.Now().UTC().Format(time.RFC3339)},
Artifacts: []Artifact{textArtifact(textOf(m.Parts))},
History: []Message{m},
}, nil
return textOf(m.Parts), nil
}
var task Task
if err := json.Unmarshal(res, &task); err != nil {
return nil, err
return "", err
}
for !terminal(task.Status.State) {
select {
case <-ctx.Done():
return nil, ctx.Err()
return "", ctx.Err()
case <-time.After(300 * time.Millisecond):
}
got, err := c.call(ctx, "tasks/get", getParams{ID: task.ID})
if err != nil {
return nil, err
return "", err
}
if err := json.Unmarshal(got, &task); err != nil {
return nil, err
return "", err
}
}
return &task, nil
}
// SetPushNotificationConfig asks the remote agent to POST updates for taskID to cfg.URL.
func (c *Client) SetPushNotificationConfig(ctx context.Context, taskID string, cfg PushNotificationConfig) error {
_, err := c.call(ctx, "tasks/pushNotificationConfig/set", pushConfigParams{
ID: taskID,
PushNotificationConfig: cfg,
})
return err
}
// PushNotificationConfig returns the remote push notification config for taskID.
func (c *Client) PushNotificationConfig(ctx context.Context, taskID string) (PushNotificationConfig, error) {
res, err := c.call(ctx, "tasks/pushNotificationConfig/get", getParams{ID: taskID})
if err != nil {
return PushNotificationConfig{}, err
if task.Status.State != stateCompleted {
return "", fmt.Errorf("remote task %s ended in state %q", task.ID, task.Status.State)
}
var out struct {
PushNotificationConfig PushNotificationConfig `json:"pushNotificationConfig"`
}
if err := json.Unmarshal(res, &out); err != nil {
return PushNotificationConfig{}, err
}
return out.PushNotificationConfig, nil
return artifactsText(task.Artifacts), nil
}
// call performs one JSON-RPC request and returns the raw result.
-62
View File
@@ -2,11 +2,8 @@ package a2a
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"testing"
"time"
)
// An agent that embeds NewAgentHandler is directly A2A-queryable — no
@@ -51,62 +48,3 @@ func TestClientSendAndCard(t *testing.T) {
t.Errorf("Send reply = %q, want pong", reply)
}
}
func TestClientContinuesTaskAndConfiguresPush(t *testing.T) {
card := Card("solo", "http://localhost:4000", "", []string{"task"})
h := NewAgentHandler(card, func(_ context.Context, text string) (string, error) {
return "echo:" + text, nil
})
ts := httptest.NewServer(h)
defer ts.Close()
updates := make(chan Task, 1)
push := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
var task Task
if err := json.NewDecoder(r.Body).Decode(&task); err != nil {
t.Errorf("decode push task: %v", err)
return
}
updates <- task
w.WriteHeader(http.StatusAccepted)
}))
defer push.Close()
cl := NewClient(ts.URL)
first, err := cl.SendMessage(context.Background(), Message{
Parts: []Part{{Kind: "text", Text: "one"}},
})
if err != nil {
t.Fatalf("first SendMessage: %v", err)
}
second, err := cl.SendMessage(context.Background(), Message{
TaskID: first.ID,
ContextID: first.ContextID,
Parts: []Part{{Kind: "text", Text: "two"}},
})
if err != nil {
t.Fatalf("second SendMessage: %v", err)
}
if second.ID != first.ID || second.ContextID != first.ContextID || len(second.History) != 4 {
t.Fatalf("continued task = %+v, first %+v", second, first)
}
cfg := PushNotificationConfig{URL: push.URL}
if err := cl.SetPushNotificationConfig(context.Background(), second.ID, cfg); err != nil {
t.Fatalf("SetPushNotificationConfig: %v", err)
}
got, err := cl.PushNotificationConfig(context.Background(), second.ID)
if err != nil {
t.Fatalf("PushNotificationConfig: %v", err)
}
if got.URL != push.URL {
t.Fatalf("PushNotificationConfig URL = %q, want %q", got.URL, push.URL)
}
select {
case update := <-updates:
if update.ID != second.ID {
t.Fatalf("push update ID = %q, want %q", update.ID, second.ID)
}
case <-time.After(2 * time.Second):
t.Fatal("timed out waiting for push update")
}
}
+15 -4
View File
@@ -19,11 +19,22 @@ redirect the loop; direction always wins.
items the loop can auto-merge): brand/positioning copy, breaking public-API
changes, architectural rewrites. Those go to the human.
## Later (ranked)
## Now (ranked)
No open queued items. The previous top item, **A2A push notifications and
multi-turn task support** (#3212), is closed; the next architecture-review pass
should seed a fresh issue-backed item from the roadmap and improvement radar.
1. **End-to-end agent streaming** (#3200) — complete the roadmap streaming slice:
usable `ai.Stream` beyond the current OpenAI path, A2A `message/stream` chunk
delivery, cancellation/error semantics, and docs so chat and long-task UX are
real across the harness boundary. Roadmap → *Next* streaming, but it now ranks
first because durable resume, provider conformance, registry readiness, 0→hero,
and agent OpenTelemetry have shipped; streaming is the largest remaining seam
in the services → agents → workflows interaction loop.
2. **Execution lifecycle hooks & metadata** (#2980) — before/after-tool, retry,
and failure hooks; first reconcile the issue with the shipped `AgentWrapTool`,
structured refusal reasons, `RunInfo`, and OpenTelemetry spans, then close it or
scope only a CI-verifiable gap that wrappers plus tracing cannot express.
Roadmap → resilience/operability, but ranked after streaming because most of
the originally requested lifecycle metadata now exists and the remaining value
is validation/scoping rather than a missing harness primitive.
_Seeded by Claude Code from the roadmap + open issues; thereafter maintained by the
architecture-review pass._
+65 -71
View File
@@ -2,64 +2,58 @@
## Overview
Go Micro v6 verifies TLS certificates by default. This guide is for teams
upgrading from v5, where TLS verification was disabled by default for backward
compatibility.
This document provides guidance for migrating to secure TLS certificate verification in go-micro v5.
## Current Status (v6)
## Current Status (v5)
**Default Behavior**: TLS certificate verification is **enabled** by default
(`InsecureSkipVerify: false`).
**Default Behavior**: TLS certificate verification is **disabled** by default (`InsecureSkipVerify: true`)
**What changed from v5**: v5 allowed `MICRO_TLS_SECURE=true` to opt into
certificate verification. In v6, secure verification is the default and
`MICRO_TLS_SECURE` is no longer used.
**Reason**: Backward compatibility with existing deployments to avoid breaking production systems during routine upgrades.
**Development escape hatch**: for local self-signed certificates only, set
`MICRO_TLS_INSECURE=true` or provide an explicit insecure TLS config.
**Security Risk**: The default behavior is vulnerable to man-in-the-middle (MITM) attacks.
## Migration Path from v5
## Migration Path
### 1. Remove the old opt-in flag
### Option 1: Enable Secure Mode (RECOMMENDED)
Delete any use of the v5-only environment variable:
Set the environment variable to enable certificate verification:
```bash
unset MICRO_TLS_SECURE
export MICRO_TLS_SECURE=true
```
No replacement is required for production: verification is already on in v6.
This enables proper TLS certificate verification while maintaining compatibility with v5.
### 2. Use the default secure config
### Option 2: Use SecureConfig Directly
Most services need no TLS-specific code. If you configure TLS explicitly, use a standard `crypto/tls` config with verification enabled:
In your code, explicitly use the secure configuration:
```go
import (
"crypto/tls"
"go-micro.dev/v6/broker"
mls "go-micro.dev/v6/util/tls"
)
// Create broker with certificate verification enabled.
// Create broker with secure TLS config
b := broker.NewHttpBroker(
broker.TLSConfig(&tls.Config{MinVersion: tls.VersionTLS12}),
broker.TLSConfig(mls.SecureConfig()),
)
```
### 3. Provide a custom trust root when needed
### Option 3: Provide Custom TLS Configuration
For private CAs, provide your own TLS configuration:
For fine-grained control, provide your own TLS configuration:
```go
import (
"crypto/tls"
"crypto/x509"
"go-micro.dev/v6/broker"
"os"
"io/ioutil"
)
// Load CA certificates
caCert, err := os.ReadFile("/path/to/ca-cert.pem")
caCert, err := ioutil.ReadFile("/path/to/ca-cert.pem")
if err != nil {
log.Fatal(err)
}
@@ -79,61 +73,52 @@ b := broker.NewHttpBroker(
)
```
### 4. Use insecure mode only for local development
If a development environment still uses self-signed certificates that are not in
your trust store, opt out explicitly:
```bash
export MICRO_TLS_INSECURE=true
```
or in code:
```go
broker.TLSConfig(&tls.Config{InsecureSkipVerify: true, MinVersion: tls.VersionTLS12})
```
Do not use insecure mode in production.
## Production Deployment Strategy
### Rolling Upgrade Considerations
The default changed at the v6 major-version boundary. Before rolling v6 into a
fleet that uses TLS, verify that:
The current implementation maintains backward compatibility, allowing safe rolling upgrades:
1. All services present certificates trusted by their peers.
2. Private or self-signed CAs are installed consistently on every host.
3. Certificates include the DNS names or IP subject alternative names used by
clients.
4. Any deliberate development-only insecure settings are excluded from
production manifests.
1. **Mixed Version Deployments**: v5 instances can communicate regardless of TLS security settings
2. **No Immediate Breaking Changes**: Systems continue working with existing behavior
3. **Gradual Migration**: Enable security incrementally across your infrastructure
### Recommended Approach
1. **Test in Staging** with the same certificate chain and service names used in
production.
2. **Remove v5 flags** such as `MICRO_TLS_SECURE`; they no longer control v6.
3. **Monitor for Issues**: watch for TLS handshake failures or certificate
validation errors.
4. **Use explicit insecure mode only in dev** when a short-lived environment
cannot yet provide trusted certificates.
1. **Test in Staging**:
```bash
# In staging environment
export MICRO_TLS_SECURE=true
```
2. **Deploy with Feature Flag**: Use environment-based configuration for gradual rollout
3. **Monitor for Issues**: Watch for TLS handshake failures or certificate validation errors
4. **Full Production Rollout**: Once validated, enable across all services
### Multi-Host/Multi-Process Considerations
**Certificate Trust**: With secure mode as the default, ensure:
**Certificate Trust**: When enabling secure mode, ensure:
1. All hosts trust the same root CAs.
2. Self-signed certificates are properly distributed if used.
3. Certificate validity periods are monitored.
4. Certificate chains are complete.
1. All hosts trust the same root CAs
2. Self-signed certificates are properly distributed if used
3. Certificate validity periods are monitored
4. Certificate chains are complete
**Service Mesh Alternative**: Consider using a service mesh (Istio, Linkerd, etc.) for:
- Automatic mTLS between services
- Certificate management and rotation
- No application code changes required
## Future Changes (v6)
In go-micro v6, the default will change to **secure by default**:
- `InsecureSkipVerify: false` (certificate verification enabled)
- Breaking change requiring major version bump
- Migration completed before v6 release avoids disruption
## Testing Your Migration
### Verify Secure Mode is Active
@@ -142,12 +127,14 @@ fleet that uses TLS, verify that:
package main
import (
"crypto/tls"
"fmt"
mls "go-micro.dev/v6/util/tls"
"os"
)
func main() {
config := &tls.Config{MinVersion: tls.VersionTLS12}
os.Setenv("MICRO_TLS_SECURE", "true")
config := mls.Config()
fmt.Printf("InsecureSkipVerify: %v (should be false)\n", config.InsecureSkipVerify)
}
```
@@ -168,7 +155,7 @@ Create a test service and verify it:
**Solution**:
1. Add the CA certificate to the trusted root CAs
2. Use a properly signed certificate
3. For development only: use `MICRO_TLS_INSECURE=true` or an explicit insecure TLS config
3. For development only: Use `InsecureConfig()` explicitly
### Issue: "x509: certificate has expired"
@@ -179,17 +166,24 @@ Create a test service and verify it:
2. Implement certificate rotation
3. Monitor certificate expiry dates
### Issue: Services can't communicate after upgrading to v6
### Issue: Services can't communicate after enabling secure mode
**Cause**: Certificates that v5 accepted by default are now verified.
**Cause**: Mixed certificate authorities or missing certificates
**Solution**:
1. Ensure all services use certificates from a trusted CA
1. Ensure all services use certificates from the same CA
2. Distribute CA certificates to all nodes
3. Verify certificate SANs match service addresses
4. Use insecure mode only as a temporary local-development workaround
## Questions?
For issues or questions about TLS security migration, open an issue on GitHub or
check the documentation at https://go-micro.dev/docs/.
For issues or questions about TLS security migration, please:
- Open an issue on GitHub
- Check the documentation at https://go-micro.dev/docs/
- Review the security guidelines
## Security Resources
- [OWASP TLS Cheat Sheet](https://cheatsheetseries.owasp.org/cheatsheets/Transport_Layer_Protection_Cheat_Sheet.html)
- [Go TLS Documentation](https://pkg.go.dev/crypto/tls)
- [Certificate Best Practices](https://www.ssl.com/guide/ssl-best-practices/)
+50 -20
View File
@@ -2,36 +2,66 @@
## What Changed
Go Micro v6 verifies TLS certificates by default. This completes the v5 security
migration where verification was opt-in.
The TLS configuration in go-micro now includes a security deprecation warning.
## Current Behavior (v6.x)
## Current Behavior (v5.x)
**Default**: TLS certificate verification is **enabled**.
- `MICRO_TLS_SECURE` was a v5 opt-in flag and is no longer used.
- For local development with untrusted self-signed certificates, opt out
explicitly with `MICRO_TLS_INSECURE=true` or an explicit insecure TLS config.
**Default**: TLS certificate verification is **disabled** for backward compatibility
- This maintains existing behavior to avoid breaking production deployments
- A deprecation warning is logged once per process startup
## Production Recommendation
**Why**: Changing the default to secure would be a **breaking change** that could disrupt:
- Production systems during routine upgrades
- Distributed systems with mixed versions
- Services using self-signed certificates
For production deployments:
1. Use CA-signed certificates or distribute your private CA to every host.
2. Remove old `MICRO_TLS_SECURE` settings from v5-era manifests.
3. Do not set `MICRO_TLS_INSECURE=true` in production.
4. Consider service mesh mTLS (Istio, Linkerd) if certificate lifecycle should be
managed outside the application.
## How to Enable Security (Recommended)
### Option 1: Environment Variable
```bash
export MICRO_TLS_SECURE=true
```
### Option 2: Use SecureConfig
```go
import (
"go-micro.dev/v6/broker"
mls "go-micro.dev/v6/util/tls"
)
broker := broker.NewHttpBroker(
broker.TLSConfig(mls.SecureConfig()),
)
```
## Migration Timeline
- **v5.x**: Insecure by default, opt-in security via `MICRO_TLS_SECURE=true`.
- **v6.x current**: Secure by default; use `MICRO_TLS_INSECURE=true` only for an
explicit development opt-out.
- **v5.x (Current)**: Insecure by default, opt-in security via `MICRO_TLS_SECURE=true`
- **v6.x (Future)**: Secure by default (breaking change with major version bump)
## Why This Approach?
This addresses the concerns raised about:
1. **Major version requirements**: No breaking change in v5, deferred to v6
2. **Cross-host compatibility**: All hosts use same default behavior
3. **Production safety**: Existing deployments continue working during upgrades
4. **Migration path**: Clear opt-in path with documentation
## Documentation
See [SECURITY_MIGRATION.md](SECURITY_MIGRATION.html) for the detailed migration
guide.
See [SECURITY_MIGRATION.md](SECURITY_MIGRATION.html) for detailed migration guide.
## Security Recommendation
For production deployments:
1. Test with `MICRO_TLS_SECURE=true` in staging
2. Use proper CA-signed certificates
3. Consider service mesh (Istio, Linkerd) for automatic mTLS
4. Plan migration before v6 release
## Questions?
Open an issue on GitHub or check the documentation at https://go-micro.dev/docs/.
Open an issue on GitHub or check the documentation at https://go-micro.dev/docs/
+5 -40
View File
@@ -66,7 +66,7 @@ A card looks like:
"url": "https://agents.example.com/agents/task-mgr",
"version": "1.0.0",
"protocolVersion": "0.3.0",
"capabilities": { "streaming": true, "pushNotifications": true },
"capabilities": { "streaming": false, "pushNotifications": false },
"defaultInputModes": ["text/plain"],
"defaultOutputModes": ["text/plain"],
"skills": [{ "id": "chat", "name": "Chat", "tags": ["task", "project"] }]
@@ -100,40 +100,7 @@ curl -s https://agents.example.com/agents/task-mgr \
}
```
Retrieve a task later with `tasks/get` (`params: { "id": "…" }`). To continue
the same piece of work, send another `message/send` with the previous `taskId`
and `contextId`. The gateway preserves the task id, context id, and prior
history, then appends the new user turn and agent reply. That makes a remote
A2A task fit the Go Micro lifecycle: services are still invoked through the
agent's normal tools, the agent keeps task context across turns, and a workflow
can poll one task id as the conversation progresses.
## Push notifications
Operators can register a task callback with
`tasks/pushNotificationConfig/set`:
```bash
curl -s https://agents.example.com/agents/task-mgr \
-H 'content-type: application/json' \
-d '{
"jsonrpc": "2.0", "id": 2,
"method": "tasks/pushNotificationConfig/set",
"params": {
"id": "task-id",
"pushNotificationConfig": {
"url": "https://workflow.example.com/a2a/tasks",
"token": "optional-bearer-token"
}
}
}'
```
The gateway stores one callback per retained task and POSTs the latest task
snapshot to that URL whenever the task changes. Delivery is best effort: failures
do not fail the agent turn, and there is no retry queue in the in-memory gateway.
Use `tasks/get` as the source of truth after a missed callback or receiver
outage. If a token is configured, it is sent as `Authorization: Bearer <token>`.
Retrieve a task later with `tasks/get` (`params: { "id": "…" }`).
## Calling out to other agents
@@ -165,13 +132,11 @@ yet terminal, it polls `tasks/get` until it completes.
## Scope
This is the JSON-RPC binding for task execution:
This is the JSON-RPC binding for completed-task execution:
- **`message/send`** runs the agent and returns a completed `Task`.
- **`message/stream`** streams the completed `Task` as an SSE `data:` event, giving A2A clients a streaming-compatible path while the underlying agent call remains synchronous.
- **`tasks/get`** returns a recent task by id.
- **Multi-turn continuation** keeps task state when a new message includes the previous `taskId`.
- **`tasks/pushNotificationConfig/set` / `get`** stores and reads a task callback for best-effort update delivery.
- **Agent Card** discovery, generated from the registry.
Both directions work: the gateway exposes your agents, and `a2a.Client` (via `flow.A2A` or `delegate` to a URL) calls external ones.
@@ -180,9 +145,9 @@ Not yet supported (advertised as such on the card, so clients negotiate correctl
- **`tasks/resubscribe`** for reconnecting to a live stream.
- Multi-turn `input-required` tasks.
- Push notifications.
These are the natural follow-ups; the task binding is what makes a Go Micro
agent both reachable from, and able to reach, the A2A ecosystem today.
These are the natural follow-ups; the completed-task binding is what makes a Go Micro agent both reachable from, and able to reach, the A2A ecosystem today.
## See also
+1 -16
View File
@@ -100,22 +100,7 @@ c := &x402.Client{
resp, err := c.Do(req) // a 402 is paid and retried; over-budget calls error instead
```
`Payer` is an interface (`Pay(ctx, Requirements) (payment string, error)`) — the consumer counterpart to `Facilitator`. The budget accumulates across calls, so a long-running agent can be handed a fixed allowance for a task. Budget is reserved before payment is created, which means parallel paid calls cannot race past the cap; if payment creation or verification fails, the reservation is released. (The agent-level `AgentMaxSpend` option, wiring this into the agent loop next to `MaxSteps`/`ApproveTool`, is the next step.)
### Live facilitator conformance
The regular test suite uses in-process facilitators and does not need network credentials. To smoke-test a hosted facilitator, run the opt-in live conformance test with a real payment payload and matching requirements:
```sh
GO_MICRO_X402_LIVE_FACILITATOR_URL=https://facilitator.example \
GO_MICRO_X402_LIVE_PAYMENT='...' \
GO_MICRO_X402_LIVE_PAY_TO=0xYourAddress \
GO_MICRO_X402_LIVE_NETWORK=base \
GO_MICRO_X402_LIVE_AMOUNT=1 \
go test ./wrapper/x402 -run TestLiveFacilitatorConformance -count=1
```
Leave those variables unset in normal CI; the live test skips unless the facilitator URL, payment payload, and pay-to address are all provided.
`Payer` is an interface (`Pay(ctx, Requirements) (payment string, error)`) — the consumer counterpart to `Facilitator`. The budget accumulates across calls, so a long-running agent can be handed a fixed allowance for a task. (The agent-level `AgentMaxSpend` option, wiring this into the agent loop next to `MaxSteps`/`ApproveTool`, is the next step.)
## Notes
+4 -3
View File
@@ -13,7 +13,7 @@ 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.
- **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.
- **Interop** — the MCP gateway (services as tools) and the A2A gateway (agents as agents, both directions), both generated from the registry; x402 for paid tools.
- **Secure by default** — TLS verification on, state scoped per component.
## Principles
@@ -37,14 +37,15 @@ The priority is that what exists works everywhere, under real conditions.
## Next — agentic depth
- **Durable agent loop.** Flows resume; the agent's own loop does not yet. Reuse `Checkpoint` so a long-running agent survives a restart and continues.
- **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.
- **Streaming.** `ai.Stream` is stubbed across providers; real chat and long-task UX need it, end to end through A2A `message/stream`.
- **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.
## Later
- **Memory management** — summarization and retrieval (RAG) beyond a fixed buffer.
- **Human-in-the-loop** — pause and resume mid-run (`input-required`), beyond the binary `ApproveTool` gate.
- **A2A** — richer live-stream reconnection (`tasks/resubscribe`) and `input-required` handoffs.
- **x402** — conformance against a live facilitator; paid remote tools as agent tools with spend caps.
- **A2A** — streaming, push notifications, multi-turn tasks.
## Developer experience (ongoing)
-18
View File
@@ -110,36 +110,18 @@ func AgentApproveTool(fn ApproveFunc) AgentOption { return agent.ApproveTool(fn)
// Memory is an agent's pluggable conversation memory.
type Memory = agent.Memory
// MemoryRecall is implemented by memory backends that retrieve prior context.
type MemoryRecall = agent.MemoryRecall
// ToolFunc handles a custom agent tool call.
type ToolFunc = agent.ToolFunc
// NewMemory returns the default store-backed agent memory.
func NewMemory(s store.Store, key string, limit int) Memory { return agent.NewMemory(s, key, limit) }
// NewCompactingMemory returns store-backed memory with deterministic
// summarization and retrieval controls.
func NewCompactingMemory(s store.Store, key string, maxMessages, keepRecent int) Memory {
return agent.NewCompactingMemory(s, key, maxMessages, keepRecent)
}
// NewInMemory returns non-persistent agent memory.
func NewInMemory(limit int) Memory { return agent.NewInMemory(limit) }
// AgentMemory sets the agent's conversation memory (default: store-backed).
func AgentMemory(m Memory) AgentOption { return agent.WithMemory(m) }
// AgentCompactMemory enables deterministic default-memory compaction and
// retrieval for long-running agents.
func AgentCompactMemory(maxMessages, keepRecent int) AgentOption {
return agent.CompactMemory(maxMessages, keepRecent)
}
// AgentMemoryRecallLimit bounds recalled archived turns injected per Ask.
func AgentMemoryRecallLimit(n int) AgentOption { return agent.MemoryRecallLimit(n) }
// AgentTool registers a custom tool the agent can call, beyond its services.
func AgentTool(name, description string, properties map[string]any, handler ToolFunc) AgentOption {
return agent.WithTool(name, description, properties, handler)
+4 -20
View File
@@ -83,9 +83,7 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
reqd := ch.Accepts[0]
amount, _ := strconv.ParseInt(reqd.MaxAmountRequired, 10, 64)
// Spend cap: reserve before paying so concurrent calls cannot all pass
// the check and overspend the caller's allowance. Roll the reservation
// back if payment construction, replay, or verification fails.
// Spend cap: refuse before paying if this would exceed the budget.
c.mu.Lock()
if c.Budget > 0 && c.spent+amount > c.Budget {
spent := c.spent
@@ -93,26 +91,13 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
return nil, fmt.Errorf("x402: paying %d for %s would exceed budget (spent %d of %d)",
amount, reqd.Resource, spent, c.Budget)
}
c.spent += amount
c.mu.Unlock()
reserved := true
rollback := func() {
if !reserved {
return
}
c.mu.Lock()
c.spent -= amount
c.mu.Unlock()
reserved = false
}
if c.Payer == nil {
rollback()
return nil, fmt.Errorf("x402: payment required for %s but no Payer configured", reqd.Resource)
}
payment, err := c.Payer.Pay(req.Context(), reqd)
if err != nil {
rollback()
return nil, fmt.Errorf("x402: pay: %w", err)
}
@@ -123,7 +108,6 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
}
retry, err := http.NewRequestWithContext(req.Context(), req.Method, req.URL.String(), rbody)
if err != nil {
rollback()
return nil, err
}
for k, v := range req.Header {
@@ -133,14 +117,14 @@ func (c *Client) Do(req *http.Request) (*http.Response, error) {
resp2, err := c.httpClient().Do(retry)
if err != nil {
rollback()
return nil, err
}
if resp2.StatusCode == http.StatusPaymentRequired {
rollback()
return resp2, fmt.Errorf("x402: payment for %s was rejected", reqd.Resource)
}
reserved = false
c.mu.Lock()
c.spent += amount
c.mu.Unlock()
return resp2, nil
}
-59
View File
@@ -118,62 +118,3 @@ func TestClientFreeEndpoint(t *testing.T) {
t.Errorf("free call should spend nothing, got %d", c.Spent())
}
}
// Concurrent callers reserve budget before paying, so a spend cap cannot be
// overshot by parallel tool calls that all observe the same pre-spend balance.
func TestClientBudgetReservedConcurrently(t *testing.T) {
srv := paidServer("10000")
defer srv.Close()
c := &Client{Payer: &mockPayer{}, Budget: 10000}
errCh := make(chan error, 2)
for i := 0; i < 2; i++ {
go func() {
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
resp, err := c.Do(req)
if resp != nil {
resp.Body.Close()
}
errCh <- err
}()
}
var paid, refused int
for i := 0; i < 2; i++ {
if err := <-errCh; err != nil {
refused++
} else {
paid++
}
}
if paid != 1 || refused != 1 {
t.Fatalf("paid=%d refused=%d, want one paid and one refused", paid, refused)
}
if c.Spent() != 10000 {
t.Errorf("spent = %d, want 10000", c.Spent())
}
}
// A reserved budget slot is released when payment cannot be produced, so a
// transient payer failure does not permanently consume an agent's allowance.
func TestClientBudgetReservationRollsBackOnPayError(t *testing.T) {
srv := paidServer("10000")
defer srv.Close()
c := &Client{Payer: payerFunc(func(context.Context, Requirements) (string, error) {
return "", context.Canceled
}), Budget: 10000}
req, _ := http.NewRequest(http.MethodGet, srv.URL, nil)
if _, err := c.Do(req); err == nil {
t.Fatal("expected pay error")
}
if c.Spent() != 0 {
t.Errorf("failed payment should roll back spend, got %d", c.Spent())
}
}
type payerFunc func(context.Context, Requirements) (string, error)
func (f payerFunc) Pay(ctx context.Context, req Requirements) (string, error) {
return f(ctx, req)
}
-52
View File
@@ -1,52 +0,0 @@
package x402
import (
"context"
"os"
"testing"
"time"
)
// TestLiveFacilitatorConformance is an opt-in smoke test for hosted x402
// facilitators. It is skipped by default so local and CI runs never need live
// credentials, but operators can run it against Coinbase, Alchemy, or a
// self-hosted facilitator by providing a real payment payload and matching
// requirements.
func TestLiveFacilitatorConformance(t *testing.T) {
url := os.Getenv("GO_MICRO_X402_LIVE_FACILITATOR_URL")
payment := os.Getenv("GO_MICRO_X402_LIVE_PAYMENT")
payTo := os.Getenv("GO_MICRO_X402_LIVE_PAY_TO")
if url == "" || payment == "" || payTo == "" {
t.Skip("set GO_MICRO_X402_LIVE_FACILITATOR_URL, GO_MICRO_X402_LIVE_PAYMENT, and GO_MICRO_X402_LIVE_PAY_TO to run live x402 facilitator conformance")
}
network := getenv("GO_MICRO_X402_LIVE_NETWORK", "base")
amount := getenv("GO_MICRO_X402_LIVE_AMOUNT", "1")
asset := os.Getenv("GO_MICRO_X402_LIVE_ASSET")
resource := getenv("GO_MICRO_X402_LIVE_RESOURCE", "go-micro-x402-live-conformance")
ctx, cancel := context.WithTimeout(context.Background(), 30*time.Second)
defer cancel()
res, err := (&HTTPFacilitator{URL: url}).Verify(ctx, payment, Requirements{
Scheme: "exact",
Network: network,
MaxAmountRequired: amount,
Resource: resource,
PayTo: payTo,
Asset: asset,
MaxTimeoutSeconds: 60,
})
if err != nil {
t.Fatalf("live facilitator verify: %v", err)
}
if !res.Valid {
t.Fatalf("live facilitator rejected payment: %s", res.Reason)
}
}
func getenv(key, fallback string) string {
if v := os.Getenv(key); v != "" {
return v
}
return fallback
}