Compare commits
1 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 06d77eb9f7 |
+1
-1
@@ -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)
|
||||
|
||||
@@ -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
@@ -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
@@ -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)
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
@@ -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"`
|
||||
}
|
||||
|
||||
@@ -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
@@ -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{}
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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 {
|
||||
|
||||
@@ -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
@@ -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.
|
||||
|
||||
@@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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._
|
||||
|
||||
@@ -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/)
|
||||
|
||||
@@ -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/
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
|
||||
@@ -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
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user