From 4b376c6f662d22d21d66dc1759c56310ea7788be Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:21:05 -0500 Subject: [PATCH 01/50] refactor(cortex): lift AgentRunner to the root package a2a needs the identical seam orchestration already had, so the interface moves up and orchestration aliases it. Aliases keep every existing caller and the engine adapter compiling untouched. --- orchestration/orchestrator.go | 33 ++++++++++----------------------- runner.go | 32 ++++++++++++++++++++++++++++++++ runner_test.go | 34 ++++++++++++++++++++++++++++++++++ 3 files changed, 76 insertions(+), 23 deletions(-) create mode 100644 runner.go create mode 100644 runner_test.go diff --git a/orchestration/orchestrator.go b/orchestration/orchestrator.go index 0af41aa..0827a43 100644 --- a/orchestration/orchestrator.go +++ b/orchestration/orchestrator.go @@ -10,6 +10,7 @@ import ( "context" "time" + "github.com/xraph/cortex" "github.com/xraph/cortex/id" ) @@ -38,29 +39,15 @@ type Handoff struct { Payload string `json:"payload,omitempty"` } -// RunOpts is the subset of engine run overrides an orchestrator needs when -// invoking an agent. It is mapped to engine.RunOverrides by the host adapter. -type RunOpts struct { - Model string - Temperature *float64 - MaxSteps int - SystemPrompt string -} - -// AgentResult is the strategy-facing view of one completed agent run. -type AgentResult struct { - AgentName string `json:"agent_name"` - RunID id.AgentRunID `json:"run_id,omitempty"` - Output string `json:"output"` - Err error `json:"-"` -} - -// AgentRunner is the single host capability an orchestrator depends on: the -// ability to run one named agent and get its result. The engine satisfies it -// via a thin adapter, avoiding an engine⇄orchestration import cycle. -type AgentRunner interface { - RunAgent(ctx context.Context, agentName, input string, opts *RunOpts) (*AgentResult, error) -} +// RunOpts, AgentResult and AgentRunner moved to the root cortex package +// when a2a came to need the same seam. They are aliased here so every +// existing caller, every stored strategy and the engine's adapter keep +// compiling unchanged. +type ( + RunOpts = cortex.RunOpts + AgentResult = cortex.AgentResult + AgentRunner = cortex.AgentRunner +) // Settings carries every strategy's tunables in one struct. Fields a given // strategy does not use are ignored. diff --git a/runner.go b/runner.go new file mode 100644 index 0000000..0f418ac --- /dev/null +++ b/runner.go @@ -0,0 +1,32 @@ +package cortex + +import ( + "context" + + "github.com/xraph/cortex/id" +) + +// RunOpts is the subset of run overrides a caller needs when invoking an +// agent through AgentRunner. The engine maps it to its own RunOverrides. +type RunOpts struct { + Model string + Temperature *float64 + MaxSteps int + SystemPrompt string +} + +// AgentResult is the caller-facing view of one completed agent run. +type AgentResult struct { + AgentName string `json:"agent_name"` + RunID id.AgentRunID `json:"run_id,omitempty"` + Output string `json:"output"` + Err error `json:"-"` +} + +// AgentRunner is the one host capability the coordination packages depend +// on: run a named agent and hand back its result. The engine satisfies it +// through a thin adapter, which is what keeps orchestration and a2a from +// importing the engine. +type AgentRunner interface { + RunAgent(ctx context.Context, agentName, input string, opts *RunOpts) (*AgentResult, error) +} diff --git a/runner_test.go b/runner_test.go new file mode 100644 index 0000000..6a75fa4 --- /dev/null +++ b/runner_test.go @@ -0,0 +1,34 @@ +package cortex_test + +import ( + "context" + "testing" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/orchestration" +) + +// stubRunner satisfies cortex.AgentRunner. The compile-time assertions below +// are the real test: orchestration.AgentRunner must be the same type, so one +// implementation satisfies both names. +type stubRunner struct{} + +func (stubRunner) RunAgent(context.Context, string, string, *cortex.RunOpts) (*cortex.AgentResult, error) { + return &cortex.AgentResult{AgentName: "a", Output: "ok"}, nil +} + +var ( + _ cortex.AgentRunner = stubRunner{} + _ orchestration.AgentRunner = stubRunner{} +) + +func TestAgentRunnerIsSharedAcrossPackages(t *testing.T) { + var r cortex.AgentRunner = stubRunner{} + got, err := r.RunAgent(context.Background(), "a", "in", nil) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + if got.Output != "ok" { + t.Fatalf("Output = %q, want %q", got.Output, "ok") + } +} From 5913788f24a7c918ba0f12845a02afa9b1492838 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:21:40 -0500 Subject: [PATCH 02/50] feat(a2a): add the FIPA-ACL performatives and routing classes --- a2a/performative.go | 122 +++++++++++++++++++++++++++++++++++++++ a2a/performative_test.go | 85 +++++++++++++++++++++++++++ 2 files changed, 207 insertions(+) create mode 100644 a2a/performative.go create mode 100644 a2a/performative_test.go diff --git a/a2a/performative.go b/a2a/performative.go new file mode 100644 index 0000000..0a14d22 --- /dev/null +++ b/a2a/performative.go @@ -0,0 +1,122 @@ +// Package a2a gives cortex agents direct, addressable communication: +// FIPA-ACL messages, durable conversations, mailboxes, and an ask that +// suspends the sender's run until a peer answers. +// +// It is a leaf package. It depends only on cortex and id, and reaches host +// capability (running an agent, resuming a paused run, persistence, +// delivery) through injected interfaces, never by importing the engine. +package a2a + +// Performative is the speech act a message performs. The 22 constants +// below are the complete FIPA-ACL set. +type Performative string + +// The FIPA-ACL performatives. +const ( + AcceptProposal Performative = "accept-proposal" + Agree Performative = "agree" + Cancel Performative = "cancel" + CFP Performative = "cfp" + Confirm Performative = "confirm" + Disconfirm Performative = "disconfirm" + Failure Performative = "failure" + Inform Performative = "inform" + InformIf Performative = "inform-if" + InformRef Performative = "inform-ref" + NotUnderstood Performative = "not-understood" + Propagate Performative = "propagate" + Propose Performative = "propose" + Proxy Performative = "proxy" + QueryIf Performative = "query-if" + QueryRef Performative = "query-ref" + Refuse Performative = "refuse" + RejectProposal Performative = "reject-proposal" + Request Performative = "request" + RequestWhen Performative = "request-when" + RequestWhenever Performative = "request-whenever" + Subscribe Performative = "subscribe" +) + +// Class is how cortex routes a performative on arrival. +type Class string + +// The three routing classes. +const ( + // ClassDirective starts a run for the recipient; its output is the reply. + ClassDirective Class = "directive" + // ClassInformative lands in the recipient's inbox and starts nothing. + ClassInformative Class = "informative" + // ClassControl is interpreted by the bus itself and reaches no agent. + ClassControl Class = "control" +) + +// classes maps every performative to its routing class. A performative +// missing from this map is not deliverable. +var classes = map[Performative]Class{ + Request: ClassDirective, + RequestWhen: ClassDirective, + RequestWhenever: ClassDirective, + QueryIf: ClassDirective, + QueryRef: ClassDirective, + CFP: ClassDirective, + Propose: ClassDirective, + // accept-proposal is a directive, not an informative: in Contract Net + // it is the message that makes the contractor do the work. + AcceptProposal: ClassDirective, + + Inform: ClassInformative, + InformIf: ClassInformative, + InformRef: ClassInformative, + Confirm: ClassInformative, + Disconfirm: ClassInformative, + Agree: ClassInformative, + Refuse: ClassInformative, + Failure: ClassInformative, + NotUnderstood: ClassInformative, + RejectProposal: ClassInformative, + Subscribe: ClassInformative, + // proxy and propagate are carried and delivered, but cortex does not + // forward them on an agent's behalf. A host that wants forwarding + // builds it over the inbox. + Proxy: ClassInformative, + Propagate: ClassInformative, + + Cancel: ClassControl, +} + +// Class returns the routing class for p, and whether p is a performative +// cortex recognises at all. +func (p Performative) Class() (Class, bool) { + c, ok := classes[p] + return c, ok +} + +// Valid reports whether p is one of the 22 FIPA-ACL performatives. +func (p Performative) Valid() bool { + _, ok := classes[p] + return ok +} + +// ResolvesAsk reports whether a reply carrying p un-pauses a waiting ask. +// +// agree is deliberately excluded. It means the peer accepted the task and +// is still working on it, so an asker that treated it as an answer would +// resume on a message carrying no answer. +func (p Performative) ResolvesAsk() bool { + switch p { + case Inform, InformIf, InformRef, Confirm, Disconfirm, Refuse, Failure, NotUnderstood, RejectProposal: + return true + default: + return false + } +} + +// AllPerformatives returns every recognised performative, for validation +// and for exhaustive tests. +func AllPerformatives() []Performative { + out := make([]Performative, 0, len(classes)) + for p := range classes { + out = append(out, p) + } + return out +} diff --git a/a2a/performative_test.go b/a2a/performative_test.go new file mode 100644 index 0000000..f6788e1 --- /dev/null +++ b/a2a/performative_test.go @@ -0,0 +1,85 @@ +package a2a + +import "testing" + +// The table is exhaustive on purpose. A performative added later without a +// routing class fails here rather than silently defaulting to the inbox. +func TestPerformativeClass(t *testing.T) { + cases := map[Performative]Class{ + Request: ClassDirective, + RequestWhen: ClassDirective, + RequestWhenever: ClassDirective, + QueryIf: ClassDirective, + QueryRef: ClassDirective, + CFP: ClassDirective, + Propose: ClassDirective, + AcceptProposal: ClassDirective, + + Inform: ClassInformative, + InformIf: ClassInformative, + InformRef: ClassInformative, + Confirm: ClassInformative, + Disconfirm: ClassInformative, + Agree: ClassInformative, + Refuse: ClassInformative, + Failure: ClassInformative, + NotUnderstood: ClassInformative, + RejectProposal: ClassInformative, + Subscribe: ClassInformative, + Proxy: ClassInformative, + Propagate: ClassInformative, + + Cancel: ClassControl, + } + + if len(cases) != 22 { + t.Fatalf("table covers %d performatives, FIPA-ACL defines 22", len(cases)) + } + for p, want := range cases { + got, ok := p.Class() + if !ok { + t.Errorf("%s: not classified", p) + continue + } + if got != want { + t.Errorf("%s: class = %s, want %s", p, got, want) + } + } +} + +func TestAllPerformativesAreClassified(t *testing.T) { + all := AllPerformatives() + for _, p := range all { + if _, ok := p.Class(); !ok { + t.Errorf("%s has no routing class", p) + } + } + if len(all) != 22 { + t.Fatalf("AllPerformatives returned %d, want 22", len(all)) + } +} + +func TestUnknownPerformativeIsNotClassified(t *testing.T) { + if _, ok := Performative("shout").Class(); ok { + t.Fatal("an invented performative must not classify") + } + if Performative("shout").Valid() { + t.Fatal("an invented performative must not validate") + } +} + +// ResolvesAsk is the pair most likely to be got backwards: agree means the +// peer took the job and is still working, so it must not un-pause the asker. +func TestResolvesAsk(t *testing.T) { + resolving := []Performative{Inform, InformIf, InformRef, Confirm, Disconfirm, Refuse, Failure, NotUnderstood, RejectProposal} + for _, p := range resolving { + if !p.ResolvesAsk() { + t.Errorf("%s should resolve a waiting ask", p) + } + } + for _, p := range []Performative{Agree, Subscribe, Request, CFP} { + if p.ResolvesAsk() { + t.Errorf("%s must not resolve a waiting ask", p) + } + } +} From 9ff1bf3082cc8904d2879458873fd07ac9338e25 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:22:18 -0500 Subject: [PATCH 03/50] feat(a2a): add the ACL envelope, addresses and validation --- a2a/envelope.go | 101 +++++++++++++++++++++++++++++++++++++++++++ a2a/envelope_test.go | 101 +++++++++++++++++++++++++++++++++++++++++++ id/id.go | 21 +++++++++ 3 files changed, 223 insertions(+) create mode 100644 a2a/envelope.go create mode 100644 a2a/envelope_test.go diff --git a/a2a/envelope.go b/a2a/envelope.go new file mode 100644 index 0000000..ed6017c --- /dev/null +++ b/a2a/envelope.go @@ -0,0 +1,101 @@ +package a2a + +import ( + "errors" + "time" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/id" +) + +// Envelope validation errors. +var ( + // ErrInvalidPerformative means the envelope names a speech act that is + // not one of the 22 FIPA-ACL performatives. + ErrInvalidPerformative = errors.New("cortex: a2a: unknown performative") + // ErrNoReceivers means the envelope addresses nobody, or addresses an + // entry with an empty agent name. + ErrNoReceivers = errors.New("cortex: a2a: envelope has no receivers") + // ErrNoSender means the envelope carries no sender. + ErrNoSender = errors.New("cortex: a2a: envelope has no sender") + // ErrSelfAddressed means the sender is among the receivers. Cortex + // refuses it outright: it is a loop with no use case behind it. + ErrSelfAddressed = errors.New("cortex: a2a: envelope is self-addressed") +) + +// Address identifies one messaging endpoint. Node empty means an agent in +// this engine; a non-empty Node names a remote peer and is the only field +// remote delivery needs. +type Address struct { + Agent string `json:"agent"` + Node string `json:"node,omitempty"` +} + +// IsLocal reports whether the address resolves inside this engine. +func (a Address) IsLocal() bool { return a.Node == "" } + +// IsZero reports whether the address names nothing. +func (a Address) IsZero() bool { return a.Agent == "" && a.Node == "" } + +// Equal reports whether two addresses name the same endpoint. +func (a Address) Equal(other Address) bool { + return a.Agent == other.Agent && a.Node == other.Node +} + +// String renders the address as agent, or agent@node for a remote peer. +func (a Address) String() string { + if a.Node == "" { + return a.Agent + } + return a.Agent + "@" + a.Node +} + +// Envelope is one FIPA-ACL message. The first block is the ACL parameter +// set, verbatim; the second is cortex's own additions, kept apart from it. +type Envelope struct { + cortex.Entity + ID id.MessageID `json:"id"` + Scope cortex.Scope `json:"scope"` + + Performative Performative `json:"performative"` + Sender Address `json:"sender"` + Receivers []Address `json:"receivers"` + ReplyTo []Address `json:"reply_to,omitempty"` + Content string `json:"content"` + Language string `json:"language,omitempty"` + Encoding string `json:"encoding,omitempty"` + Ontology string `json:"ontology,omitempty"` + Protocol string `json:"protocol,omitempty"` + ConversationID id.ConversationID `json:"conversation_id"` + ReplyWith string `json:"reply_with,omitempty"` + InReplyTo string `json:"in_reply_to,omitempty"` + ReplyBy *time.Time `json:"reply_by,omitempty"` + + Hops int `json:"hops"` + OriginRunID id.AgentRunID `json:"origin_run_id,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// Validate checks everything about an envelope that can be decided without +// touching the store: the performative is real, somebody sent it, somebody +// receives it, and the sender is not among the receivers. +func (e *Envelope) Validate() error { + if !e.Performative.Valid() { + return ErrInvalidPerformative + } + if e.Sender.IsZero() { + return ErrNoSender + } + if len(e.Receivers) == 0 { + return ErrNoReceivers + } + for _, r := range e.Receivers { + if r.Agent == "" { + return ErrNoReceivers + } + if r.Equal(e.Sender) { + return ErrSelfAddressed + } + } + return nil +} diff --git a/a2a/envelope_test.go b/a2a/envelope_test.go new file mode 100644 index 0000000..c13092f --- /dev/null +++ b/a2a/envelope_test.go @@ -0,0 +1,101 @@ +package a2a + +import ( + "errors" + "testing" +) + +func TestEnvelopeValidate(t *testing.T) { + base := func() *Envelope { + return &Envelope{ + Performative: Request, + Sender: Address{Agent: "planner"}, + Receivers: []Address{{Agent: "worker"}}, + Content: "do the thing", + } + } + + t.Run("valid", func(t *testing.T) { + if err := base().Validate(); err != nil { + t.Fatalf("Validate: %v", err) + } + }) + + t.Run("unknown performative", func(t *testing.T) { + e := base() + e.Performative = "shout" + if !errors.Is(e.Validate(), ErrInvalidPerformative) { + t.Fatal("want ErrInvalidPerformative") + } + }) + + t.Run("no receivers", func(t *testing.T) { + e := base() + e.Receivers = nil + if !errors.Is(e.Validate(), ErrNoReceivers) { + t.Fatal("want ErrNoReceivers") + } + }) + + t.Run("no sender", func(t *testing.T) { + e := base() + e.Sender = Address{} + if !errors.Is(e.Validate(), ErrNoSender) { + t.Fatal("want ErrNoSender") + } + }) + + // Self-addressing is refused outright. The loop risk is real and the + // use case is not. + t.Run("self addressed", func(t *testing.T) { + e := base() + e.Receivers = []Address{{Agent: "planner"}} + if !errors.Is(e.Validate(), ErrSelfAddressed) { + t.Fatal("want ErrSelfAddressed") + } + }) + + t.Run("self addressed among others", func(t *testing.T) { + e := base() + e.Receivers = []Address{{Agent: "worker"}, {Agent: "planner"}} + if !errors.Is(e.Validate(), ErrSelfAddressed) { + t.Fatal("a broadcast that includes the sender is still self-addressed") + } + }) + + t.Run("receiver with empty agent", func(t *testing.T) { + e := base() + e.Receivers = []Address{{Agent: ""}} + if !errors.Is(e.Validate(), ErrNoReceivers) { + t.Fatal("want ErrNoReceivers") + } + }) +} + +func TestAddressIsLocal(t *testing.T) { + if !(Address{Agent: "a"}).IsLocal() { + t.Fatal("an empty Node means an agent in this engine") + } + if (Address{Agent: "a", Node: "peer.example"}).IsLocal() { + t.Fatal("a Node means a remote peer") + } +} + +func TestAddressEqualComparesTheNodeToo(t *testing.T) { + a := Address{Agent: "x"} + if a.Equal(Address{Agent: "x", Node: "n"}) { + t.Fatal("same agent name on a different node is a different address") + } + if !a.Equal(Address{Agent: "x"}) { + t.Fatal("identical addresses must compare equal") + } +} + +func TestAddressString(t *testing.T) { + if got := (Address{Agent: "x"}).String(); got != "x" { + t.Fatalf("String() = %q, want %q", got, "x") + } + if got := (Address{Agent: "x", Node: "n"}).String(); got != "x@n" { + t.Fatalf("String() = %q, want %q", got, "x@n") + } +} diff --git a/id/id.go b/id/id.go index ad261cd..da00738 100644 --- a/id/id.go +++ b/id/id.go @@ -40,6 +40,9 @@ const ( PrefixSession Prefix = "ses" PrefixSuspension Prefix = "sus" PrefixOverlay Prefix = "ovl" + PrefixMessage Prefix = "msg" + PrefixConversation Prefix = "conv" + PrefixDelivery Prefix = "dlv" ) // ID is the primary identifier type for all Cortex entities. @@ -168,6 +171,15 @@ type SuspensionID = ID // OverlayID is a type-safe identifier for prompt overlays (prefix: "ovl"). type OverlayID = ID +// MessageID is a type-safe identifier for a2a envelopes (prefix: "msg"). +type MessageID = ID + +// ConversationID is a type-safe identifier for a2a conversations (prefix: "conv"). +type ConversationID = ID + +// DeliveryID is a type-safe identifier for a2a deliveries (prefix: "dlv"). +type DeliveryID = ID + // AnyID is a type alias that accepts any valid prefix. type AnyID = ID @@ -196,6 +208,15 @@ func NewMemoryID() ID { return New(PrefixMemory) } // NewCheckpointID generates a new unique checkpoint ID. func NewCheckpointID() ID { return New(PrefixCheckpoint) } +// NewMessageID generates a new unique a2a message ID. +func NewMessageID() ID { return New(PrefixMessage) } + +// NewConversationID generates a new unique a2a conversation ID. +func NewConversationID() ID { return New(PrefixConversation) } + +// NewDeliveryID generates a new unique a2a delivery ID. +func NewDeliveryID() ID { return New(PrefixDelivery) } + // NewOrchestrationID generates a new unique orchestration ID. func NewOrchestrationID() ID { return New(PrefixOrchestration) } From 6e37b47026fe8252729715c2f0fe35912837c6bf Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:24:00 -0500 Subject: [PATCH 04/50] feat(a2a): add conversations, deliveries, pending asks and the store seam --- a2a/conversation.go | 54 ++++++++ a2a/conversation_test.go | 135 ++++++++++++++++++++ a2a/delivery.go | 46 +++++++ a2a/memstore_test.go | 261 +++++++++++++++++++++++++++++++++++++++ a2a/pendingask.go | 35 ++++++ a2a/store.go | 61 +++++++++ 6 files changed, 592 insertions(+) create mode 100644 a2a/conversation.go create mode 100644 a2a/conversation_test.go create mode 100644 a2a/delivery.go create mode 100644 a2a/memstore_test.go create mode 100644 a2a/pendingask.go create mode 100644 a2a/store.go diff --git a/a2a/conversation.go b/a2a/conversation.go new file mode 100644 index 0000000..65c8d9a --- /dev/null +++ b/a2a/conversation.go @@ -0,0 +1,54 @@ +package a2a + +import ( + "time" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/id" +) + +// Conversation statuses. +const ( + // StatusOpen means messages may still be delivered on it. + StatusOpen = "open" + // StatusClosed means a cancel closed it, or it ran to completion. + StatusClosed = "closed" + // StatusExpired means its deadline passed with an ask still waiting. + StatusExpired = "expired" +) + +// Conversation is one thread of messages between agents. It carries the +// containment budget: every derived message increments HopsUsed, and the +// bus refuses delivery once HopsUsed would exceed HopCeiling. +type Conversation struct { + cortex.Entity + ID id.ConversationID `json:"id"` + Scope cortex.Scope `json:"scope"` + Protocol string `json:"protocol,omitempty"` + Initiator Address `json:"initiator"` + Participants []Address `json:"participants,omitempty"` + Status string `json:"status"` + HopCeiling int `json:"hop_ceiling"` + HopsUsed int `json:"hops_used"` + Deadline *time.Time `json:"deadline,omitempty"` +} + +// IsOpen reports whether the conversation still accepts messages. +func (c *Conversation) IsOpen() bool { return c.Status == StatusOpen } + +// HasParticipant reports whether addr already took part. +func (c *Conversation) HasParticipant(addr Address) bool { + for _, p := range c.Participants { + if p.Equal(addr) { + return true + } + } + return false +} + +// AddParticipant records addr as a participant, ignoring duplicates. +func (c *Conversation) AddParticipant(addr Address) { + if !c.HasParticipant(addr) { + c.Participants = append(c.Participants, addr) + } +} diff --git a/a2a/conversation_test.go b/a2a/conversation_test.go new file mode 100644 index 0000000..67d3f33 --- /dev/null +++ b/a2a/conversation_test.go @@ -0,0 +1,135 @@ +package a2a + +import ( + "context" + "testing" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/id" +) + +func testCtx() context.Context { + return cortex.WithScope(context.Background(), cortex.Scope{ + Levels: []cortex.Level{{Key: "tenant", Value: "acme"}}, + }) +} + +func TestMemStoreRoundTripsAConversation(t *testing.T) { + ctx, s := testCtx(), newMemStore() + c := &Conversation{ + ID: id.NewConversationID(), + Status: StatusOpen, + HopCeiling: 8, + Initiator: Address{Agent: "planner"}, + } + if err := s.CreateConversation(ctx, c); err != nil { + t.Fatalf("CreateConversation: %v", err) + } + got, err := s.GetConversation(ctx, c.ID) + if err != nil { + t.Fatalf("GetConversation: %v", err) + } + if got.Status != StatusOpen || got.HopCeiling != 8 { + t.Fatalf("round trip lost fields: %+v", got) + } +} + +func TestConversationParticipants(t *testing.T) { + c := &Conversation{Status: StatusOpen} + c.AddParticipant(Address{Agent: "a"}) + c.AddParticipant(Address{Agent: "a"}) + c.AddParticipant(Address{Agent: "b"}) + if len(c.Participants) != 2 { + t.Fatalf("participants = %+v, want a and b once each", c.Participants) + } + if !c.HasParticipant(Address{Agent: "b"}) { + t.Fatal("b should be a participant") + } + if c.HasParticipant(Address{Agent: "c"}) { + t.Fatal("c never took part") + } +} + +// The claim is the whole point of the pending-ask table. Two callers race +// for one row and exactly one of them may resume the run. +func TestClaimPendingAskSucceedsOnce(t *testing.T) { + ctx, s := testCtx(), newMemStore() + ask := &PendingAsk{ + ReplyWith: "rw-1", + AskerRunID: id.NewAgentRunID(), + ToolCallID: "call-1", + Expected: Address{Agent: "worker"}, + } + if err := s.CreatePendingAsk(ctx, ask); err != nil { + t.Fatalf("CreatePendingAsk: %v", err) + } + + first, err := s.ClaimPendingAsk(ctx, "rw-1") + if err != nil { + t.Fatalf("first claim: %v", err) + } + if first.ToolCallID != "call-1" { + t.Fatalf("claim returned the wrong row: %+v", first) + } + + if _, err := s.ClaimPendingAsk(ctx, "rw-1"); !errorsIs(err, ErrAskAlreadyClaimed) { + t.Fatalf("second claim: err = %v, want ErrAskAlreadyClaimed", err) + } +} + +func TestClaimUnknownAsk(t *testing.T) { + ctx, s := testCtx(), newMemStore() + if _, err := s.ClaimPendingAsk(ctx, "nope"); !errorsIs(err, ErrAskNotFound) { + t.Fatalf("err = %v, want ErrAskNotFound", err) + } +} + +func TestListInboxReturnsUnreadOnly(t *testing.T) { + ctx, s := testCtx(), newMemStore() + d := &Delivery{ + ID: id.NewDeliveryID(), + MessageID: id.NewMessageID(), + Receiver: Address{Agent: "worker"}, + State: DeliveryDelivered, + } + if err := s.CreateDelivery(ctx, d); err != nil { + t.Fatalf("CreateDelivery: %v", err) + } + + got, err := s.ListInbox(ctx, "worker", InboxFilter{UnreadOnly: true}) + if err != nil { + t.Fatalf("ListInbox: %v", err) + } + if len(got) != 1 { + t.Fatalf("got %d deliveries, want 1", len(got)) + } + + if err := s.MarkDeliveryRead(ctx, got[0].ID); err != nil { + t.Fatalf("MarkDeliveryRead: %v", err) + } + got, err = s.ListInbox(ctx, "worker", InboxFilter{UnreadOnly: true}) + if err != nil { + t.Fatalf("ListInbox after read: %v", err) + } + if len(got) != 0 { + t.Fatalf("got %d unread after marking read, want 0", len(got)) + } +} + +func TestListQueuedDeliveriesIsWhatRedriveReadsFrom(t *testing.T) { + ctx, s := testCtx(), newMemStore() + queued := &Delivery{ID: id.NewDeliveryID(), MessageID: id.NewMessageID(), Receiver: Address{Agent: "w1"}, State: DeliveryQueued} + done := &Delivery{ID: id.NewDeliveryID(), MessageID: id.NewMessageID(), Receiver: Address{Agent: "w2"}, State: DeliveryDelivered} + for _, d := range []*Delivery{queued, done} { + if err := s.CreateDelivery(ctx, d); err != nil { + t.Fatalf("CreateDelivery: %v", err) + } + } + got, err := s.ListQueuedDeliveries(ctx, 10) + if err != nil { + t.Fatalf("ListQueuedDeliveries: %v", err) + } + if len(got) != 1 || got[0].Receiver.Agent != "w1" { + t.Fatalf("redrive must see only queued rows, got %+v", got) + } +} diff --git a/a2a/delivery.go b/a2a/delivery.go new file mode 100644 index 0000000..d86c52f --- /dev/null +++ b/a2a/delivery.go @@ -0,0 +1,46 @@ +package a2a + +import ( + "errors" + "time" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/id" +) + +// Delivery states. The state is what the dispatcher redrives from after a +// restart, which is why it is kept apart from the read state below it. +const ( + // DeliveryQueued means the bus accepted it and nobody has carried it yet. + DeliveryQueued = "queued" + // DeliveryDelivering means a worker claimed it and is carrying it now. + DeliveryDelivering = "delivering" + // DeliveryDelivered means it reached the recipient: an inbox row for an + // informative, a started run for a directive. + DeliveryDelivered = "delivered" + // DeliveryFailed means delivery was attempted and could not complete. + DeliveryFailed = "failed" +) + +// ErrDeliveryAlreadyClaimed is the losing side of two workers reaching for +// one row. It is what stops a directive running twice. +var ErrDeliveryAlreadyClaimed = errors.New("cortex: a2a: delivery already claimed") + +// ErrDeliveryNotFound means no delivery row carries that id. +var ErrDeliveryNotFound = errors.New("cortex: a2a: delivery not found") + +// Delivery is one envelope's arrival at one receiver. A message addressed +// to five agents is five deliveries, which is what makes "unread for agent +// B" an answerable question. +type Delivery struct { + cortex.Entity + ID id.DeliveryID `json:"id"` + Scope cortex.Scope `json:"scope"` + MessageID id.MessageID `json:"message_id"` + Receiver Address `json:"receiver"` + State string `json:"state"` + Error string `json:"error,omitempty"` + DeliveredAt *time.Time `json:"delivered_at,omitempty"` + ReadAt *time.Time `json:"read_at,omitempty"` + RunID id.AgentRunID `json:"run_id,omitempty"` // the run a directive started +} diff --git a/a2a/memstore_test.go b/a2a/memstore_test.go new file mode 100644 index 0000000..57a80f5 --- /dev/null +++ b/a2a/memstore_test.go @@ -0,0 +1,261 @@ +package a2a + +import ( + "errors" + "sync" + "time" + + "context" + + "github.com/xraph/cortex/id" +) + +func errorsIs(err, target error) bool { return errors.Is(err, target) } + +// fakeClock is the package's test time source. Nothing in a2a reads the +// wall clock directly, so a deadline test moves this instead of sleeping. +type fakeClock struct { + mu sync.Mutex + now time.Time +} + +func (c *fakeClock) Now() time.Time { + c.mu.Lock() + defer c.mu.Unlock() + return c.now +} + +func (c *fakeClock) advance(d time.Duration) { + c.mu.Lock() + defer c.mu.Unlock() + c.now = c.now.Add(d) +} + +// memStore is an in-memory Store. Insertion order is kept because tests +// assert on it: a conversation reads as a transcript, not as a set. +type memStore struct { + mu sync.Mutex + + messages map[string]*Envelope + messageIDs []string + convs map[string]*Conversation + convIDs []string + deliveries map[string]*Delivery + deliveryIDs []string + asks map[string]*PendingAsk + askKeys []string +} + +func newMemStore() *memStore { + return &memStore{ + messages: map[string]*Envelope{}, + convs: map[string]*Conversation{}, + deliveries: map[string]*Delivery{}, + asks: map[string]*PendingAsk{}, + } +} + +func (s *memStore) CreateMessage(_ context.Context, e *Envelope) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *e + s.messages[e.ID.String()] = &cp + s.messageIDs = append(s.messageIDs, e.ID.String()) + return nil +} + +func (s *memStore) GetMessage(_ context.Context, msgID id.MessageID) (*Envelope, error) { + s.mu.Lock() + defer s.mu.Unlock() + e, ok := s.messages[msgID.String()] + if !ok { + return nil, errors.New("cortex: a2a: message not found") + } + cp := *e + return &cp, nil +} + +func (s *memStore) ListMessages(_ context.Context, f *MessageListFilter) ([]*Envelope, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*Envelope + for _, key := range s.messageIDs { + e := s.messages[key] + if f != nil && !f.ConversationID.IsNil() && e.ConversationID != f.ConversationID { + continue + } + cp := *e + out = append(out, &cp) + } + return out, nil +} + +func (s *memStore) CreateConversation(_ context.Context, c *Conversation) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *c + s.convs[c.ID.String()] = &cp + s.convIDs = append(s.convIDs, c.ID.String()) + return nil +} + +func (s *memStore) GetConversation(_ context.Context, convID id.ConversationID) (*Conversation, error) { + s.mu.Lock() + defer s.mu.Unlock() + c, ok := s.convs[convID.String()] + if !ok { + return nil, errors.New("cortex: a2a: conversation not found") + } + cp := *c + return &cp, nil +} + +func (s *memStore) UpdateConversation(_ context.Context, c *Conversation) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.convs[c.ID.String()]; !ok { + return errors.New("cortex: a2a: conversation not found") + } + cp := *c + s.convs[c.ID.String()] = &cp + return nil +} + +func (s *memStore) ListConversations(_ context.Context, f *ConversationListFilter) ([]*Conversation, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*Conversation + for _, key := range s.convIDs { + c := s.convs[key] + if f != nil && f.Status != "" && c.Status != f.Status { + continue + } + cp := *c + out = append(out, &cp) + } + return out, nil +} + +func (s *memStore) CreateDelivery(_ context.Context, d *Delivery) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *d + s.deliveries[d.ID.String()] = &cp + s.deliveryIDs = append(s.deliveryIDs, d.ID.String()) + return nil +} + +func (s *memStore) UpdateDelivery(_ context.Context, d *Delivery) error { + s.mu.Lock() + defer s.mu.Unlock() + if _, ok := s.deliveries[d.ID.String()]; !ok { + return ErrDeliveryNotFound + } + cp := *d + s.deliveries[d.ID.String()] = &cp + return nil +} + +func (s *memStore) ListInbox(_ context.Context, agentName string, f InboxFilter) ([]*Delivery, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*Delivery + for _, key := range s.deliveryIDs { + d := s.deliveries[key] + if d.Receiver.Agent != agentName || d.State != DeliveryDelivered { + continue + } + if f.UnreadOnly && d.ReadAt != nil { + continue + } + if !f.ConversationID.IsNil() { + e, ok := s.messages[d.MessageID.String()] + if !ok || e.ConversationID != f.ConversationID { + continue + } + } + cp := *d + out = append(out, &cp) + if f.Limit > 0 && len(out) >= f.Limit { + break + } + } + return out, nil +} + +func (s *memStore) ListQueuedDeliveries(_ context.Context, limit int) ([]*Delivery, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*Delivery + for _, key := range s.deliveryIDs { + d := s.deliveries[key] + if d.State != DeliveryQueued { + continue + } + cp := *d + out = append(out, &cp) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +func (s *memStore) MarkDeliveryRead(_ context.Context, deliveryID id.DeliveryID) error { + s.mu.Lock() + defer s.mu.Unlock() + d, ok := s.deliveries[deliveryID.String()] + if !ok { + return ErrDeliveryNotFound + } + now := time.Now().UTC() + d.ReadAt = &now + return nil +} + +func (s *memStore) CreatePendingAsk(_ context.Context, a *PendingAsk) error { + s.mu.Lock() + defer s.mu.Unlock() + cp := *a + s.asks[a.ReplyWith] = &cp + s.askKeys = append(s.askKeys, a.ReplyWith) + return nil +} + +// ClaimPendingAsk is the whole reason this double exists: the claim happens +// under the lock, so a concurrent second claimant loses. +func (s *memStore) ClaimPendingAsk(_ context.Context, replyWith string) (*PendingAsk, error) { + s.mu.Lock() + defer s.mu.Unlock() + a, ok := s.asks[replyWith] + if !ok { + return nil, ErrAskNotFound + } + if a.ClaimedAt != nil { + return nil, ErrAskAlreadyClaimed + } + now := time.Now().UTC() + a.ClaimedAt = &now + cp := *a + return &cp, nil +} + +func (s *memStore) ListExpiredAsks(_ context.Context, now time.Time, limit int) ([]*PendingAsk, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*PendingAsk + for _, key := range s.askKeys { + a := s.asks[key] + if a.ClaimedAt != nil || a.Deadline == nil || a.Deadline.After(now) { + continue + } + cp := *a + out = append(out, &cp) + if limit > 0 && len(out) >= limit { + break + } + } + return out, nil +} + +var _ Store = (*memStore)(nil) diff --git a/a2a/pendingask.go b/a2a/pendingask.go new file mode 100644 index 0000000..7f29739 --- /dev/null +++ b/a2a/pendingask.go @@ -0,0 +1,35 @@ +package a2a + +import ( + "errors" + "time" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/id" +) + +// Pending-ask errors. +var ( + // ErrAskNotFound means no pending ask carries that reply-with token. + ErrAskNotFound = errors.New("cortex: a2a: pending ask not found") + // ErrAskAlreadyClaimed is the losing side of a race between a reply, a + // deadline sweep and a cancel. Exactly one of them may resume the run. + ErrAskAlreadyClaimed = errors.New("cortex: a2a: pending ask already claimed") +) + +// PendingAsk is one suspended run waiting on a peer's answer. It is keyed +// by ReplyWith, which is FIPA's own correlation token, so a reply carrying +// InReplyTo finds exactly one row. +type PendingAsk struct { + cortex.Entity + Scope cortex.Scope `json:"scope"` + ReplyWith string `json:"reply_with"` + ConversationID id.ConversationID `json:"conversation_id"` + MessageID id.MessageID `json:"message_id"` + AskerRunID id.AgentRunID `json:"asker_run_id"` + AskerAgent string `json:"asker_agent"` + ToolCallID string `json:"tool_call_id"` + Expected Address `json:"expected"` + Deadline *time.Time `json:"deadline,omitempty"` + ClaimedAt *time.Time `json:"claimed_at,omitempty"` +} diff --git a/a2a/store.go b/a2a/store.go new file mode 100644 index 0000000..a7e1941 --- /dev/null +++ b/a2a/store.go @@ -0,0 +1,61 @@ +package a2a + +import ( + "context" + "time" + + "github.com/xraph/cortex/id" +) + +// InboxFilter controls an inbox listing. Scope arrives on the context. +type InboxFilter struct { + UnreadOnly bool + ConversationID id.ConversationID + Limit int + Offset int +} + +// MessageListFilter controls a message listing. Scope arrives on the +// context; Exact narrows to rows stored at precisely that depth instead of +// everything beneath it. +type MessageListFilter struct { + Exact bool + ConversationID id.ConversationID + Limit int + Offset int +} + +// ConversationListFilter controls a conversation listing. +type ConversationListFilter struct { + Exact bool + Status string + Limit int + Offset int +} + +// Store is persistence for the messaging subsystem. It folds into the +// composite store.Store the same way orchestration's two interfaces do. +type Store interface { + CreateMessage(ctx context.Context, e *Envelope) error + GetMessage(ctx context.Context, msgID id.MessageID) (*Envelope, error) + ListMessages(ctx context.Context, filter *MessageListFilter) ([]*Envelope, error) + + CreateConversation(ctx context.Context, c *Conversation) error + GetConversation(ctx context.Context, convID id.ConversationID) (*Conversation, error) + UpdateConversation(ctx context.Context, c *Conversation) error + ListConversations(ctx context.Context, filter *ConversationListFilter) ([]*Conversation, error) + + CreateDelivery(ctx context.Context, d *Delivery) error + UpdateDelivery(ctx context.Context, d *Delivery) error + ListInbox(ctx context.Context, agentName string, filter InboxFilter) ([]*Delivery, error) + ListQueuedDeliveries(ctx context.Context, limit int) ([]*Delivery, error) + MarkDeliveryRead(ctx context.Context, deliveryID id.DeliveryID) error + + CreatePendingAsk(ctx context.Context, a *PendingAsk) error + // ClaimPendingAsk takes ownership of the ask carrying replyWith. It + // returns ErrAskNotFound when no such row exists and + // ErrAskAlreadyClaimed when another caller got there first. Claiming + // before resuming is what keeps a run from being resumed twice. + ClaimPendingAsk(ctx context.Context, replyWith string) (*PendingAsk, error) + ListExpiredAsks(ctx context.Context, now time.Time, limit int) ([]*PendingAsk, error) +} From c68b16c4517bb65119b05646714d0afde9bcb8f7 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:24:16 -0500 Subject: [PATCH 05/50] feat(a2a): add options, the injected clock and the remaining seams --- a2a/options.go | 43 ++++++++++++++++++++++++++++++++++++ a2a/options_test.go | 54 +++++++++++++++++++++++++++++++++++++++++++++ a2a/seams.go | 50 +++++++++++++++++++++++++++++++++++++++++ 3 files changed, 147 insertions(+) create mode 100644 a2a/options.go create mode 100644 a2a/options_test.go create mode 100644 a2a/seams.go diff --git a/a2a/options.go b/a2a/options.go new file mode 100644 index 0000000..506e915 --- /dev/null +++ b/a2a/options.go @@ -0,0 +1,43 @@ +package a2a + +import "time" + +// Defaults for Options. The hop ceiling is the containment budget: eight +// derived messages is generous for a real delegation chain and short of +// anything that reads as a runaway. +const ( + DefaultHopCeiling = 8 + DefaultWorkers = 4 + DefaultReplyBy = 5 * time.Minute + DefaultSweepInterval = 30 * time.Second +) + +// Options tunes the messaging subsystem. +type Options struct { + // HopCeiling caps how many messages one conversation may derive. + HopCeiling int + // Workers is the dispatcher's concurrency. It has to cover resumed + // askers as well as recipients, because a resume runs synchronously on + // the worker that delivered the reply. + Workers int + // DefaultReplyBy is the deadline stamped on an ask that names none. + DefaultReplyBy time.Duration + // SweepInterval is how often overdue asks are resolved into failures. + SweepInterval time.Duration +} + +func (o Options) withDefaults() Options { + if o.HopCeiling <= 0 { + o.HopCeiling = DefaultHopCeiling + } + if o.Workers <= 0 { + o.Workers = DefaultWorkers + } + if o.DefaultReplyBy <= 0 { + o.DefaultReplyBy = DefaultReplyBy + } + if o.SweepInterval <= 0 { + o.SweepInterval = DefaultSweepInterval + } + return o +} diff --git a/a2a/options_test.go b/a2a/options_test.go new file mode 100644 index 0000000..aaa524a --- /dev/null +++ b/a2a/options_test.go @@ -0,0 +1,54 @@ +package a2a + +import ( + "testing" + "time" +) + +func TestOptionsDefaults(t *testing.T) { + got := Options{}.withDefaults() + if got.HopCeiling != DefaultHopCeiling { + t.Errorf("HopCeiling = %d, want %d", got.HopCeiling, DefaultHopCeiling) + } + if got.Workers != DefaultWorkers { + t.Errorf("Workers = %d, want %d", got.Workers, DefaultWorkers) + } + if got.DefaultReplyBy != DefaultReplyBy { + t.Errorf("DefaultReplyBy = %s, want %s", got.DefaultReplyBy, DefaultReplyBy) + } + if got.SweepInterval != DefaultSweepInterval { + t.Errorf("SweepInterval = %s, want %s", got.SweepInterval, DefaultSweepInterval) + } +} + +func TestOptionsKeepExplicitValues(t *testing.T) { + in := Options{HopCeiling: 2, Workers: 1, DefaultReplyBy: time.Second, SweepInterval: time.Minute} + if got := in.withDefaults(); got != in { + t.Fatalf("withDefaults changed explicit values: %+v", got) + } +} + +func TestOptionsRejectNegatives(t *testing.T) { + got := Options{HopCeiling: -3, Workers: -1}.withDefaults() + if got.HopCeiling != DefaultHopCeiling || got.Workers != DefaultWorkers { + t.Fatalf("negatives must fall back to defaults, got %+v", got) + } +} + +func TestFakeClockDoesNotMoveOnItsOwn(t *testing.T) { + c := &fakeClock{now: time.Unix(1000, 0).UTC()} + first := c.Now() + if !c.Now().Equal(first) { + t.Fatal("the fake clock must not advance on its own") + } + c.advance(time.Minute) + if !c.Now().Equal(first.Add(time.Minute)) { + t.Fatal("advance must move the fake clock by exactly the delta") + } +} + +func TestSystemClockIsUTC(t *testing.T) { + if got := (systemClock{}).Now(); got.Location() != time.UTC { + t.Fatalf("Now() location = %s, want UTC", got.Location()) + } +} diff --git a/a2a/seams.go b/a2a/seams.go new file mode 100644 index 0000000..e6b9bfc --- /dev/null +++ b/a2a/seams.go @@ -0,0 +1,50 @@ +package a2a + +import ( + "context" + "time" + + "github.com/xraph/cortex/id" +) + +// Clock is the package's only source of time. Everything reads it, so a +// test can move a deadline without sleeping. +type Clock interface { + Now() time.Time +} + +type systemClock struct{} + +func (systemClock) Now() time.Time { return time.Now().UTC() } + +// Resumer un-pauses a run that stopped on an agent_ask. It wraps the +// engine's internal resume path, not the public Resume, because a host +// must not be able to forge a peer's reply. +type Resumer interface { + ResumeAgentReply(ctx context.Context, runID id.AgentRunID, callID, result string) error +} + +// Transport delivers an envelope to one receiver. The in-process +// implementation resolves agents in this engine; a remote implementation +// resolves a Node. +type Transport interface { + // Deliver hands the envelope to the receiver. Returning an error marks + // the delivery failed; it does not fail the sender's run. + Deliver(ctx context.Context, e *Envelope, receiver Address) error + // Handles reports whether this transport can reach the address. + Handles(addr Address) bool +} + +// HookEmitter receives messaging lifecycle events. The engine adapts +// plugin.Registry to it; tests pass a recorder. +type HookEmitter interface { + MessageSent(ctx context.Context, msgID id.MessageID, from, to, performative string) + MessageDelivered(ctx context.Context, msgID id.MessageID, to string) + MessageRefused(ctx context.Context, msgID id.MessageID, to, reason string) +} + +type noopHooks struct{} + +func (noopHooks) MessageSent(context.Context, id.MessageID, string, string, string) {} +func (noopHooks) MessageDelivered(context.Context, id.MessageID, string) {} +func (noopHooks) MessageRefused(context.Context, id.MessageID, string, string) {} From d7d05dd23d3d07aa6a0eef443b57cd9cceca750f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:26:00 -0500 Subject: [PATCH 06/50] feat(a2a): add the bus and the send path --- a2a/bus.go | 258 +++++++++++++++++++++++++++++++++++++++++++ a2a/bus_send_test.go | 165 +++++++++++++++++++++++++++ a2a/dispatcher.go | 16 +++ a2a/fakes_test.go | 141 +++++++++++++++++++++++ a2a/transport.go | 14 +++ 5 files changed, 594 insertions(+) create mode 100644 a2a/bus.go create mode 100644 a2a/bus_send_test.go create mode 100644 a2a/dispatcher.go create mode 100644 a2a/fakes_test.go create mode 100644 a2a/transport.go diff --git a/a2a/bus.go b/a2a/bus.go new file mode 100644 index 0000000..3aa98f8 --- /dev/null +++ b/a2a/bus.go @@ -0,0 +1,258 @@ +package a2a + +import ( + "context" + "errors" + "fmt" + "strings" + "time" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/id" +) + +// Bus errors. +var ( + // ErrNoStore means NewBus was called without persistence. + ErrNoStore = errors.New("cortex: a2a: no store configured") + // ErrNoRunner means NewBus was called without an agent runner. + ErrNoRunner = errors.New("cortex: a2a: no agent runner configured") + // ErrConversationClosed means the conversation no longer accepts messages. + ErrConversationClosed = errors.New("cortex: a2a: conversation is closed") + // ErrHopCeiling means the conversation used up its containment budget. + ErrHopCeiling = errors.New("cortex: a2a: conversation hop ceiling exceeded") + // ErrUnroutable means no transport handles the receiver's address. + ErrUnroutable = errors.New("cortex: a2a: no transport handles that address") +) + +// BusConfig builds a Bus. Store and Runner are required; everything else +// has a working default. +type BusConfig struct { + Store Store + Runner cortex.AgentRunner + Resumer Resumer + Hooks HookEmitter + Clock Clock + Transports []Transport + Options Options + + // Synchronous makes the dispatcher deliver only when Drain is called. + // Tests set it so an assertion can never observe a run mid-flight. + Synchronous bool +} + +// Bus routes envelopes between agents. +type Bus struct { + store Store + runner cortex.AgentRunner + resumer Resumer + hooks HookEmitter + clock Clock + transports []Transport + opts Options + dispatch *dispatcher +} + +// NewBus builds a Bus from cfg. +func NewBus(cfg BusConfig) (*Bus, error) { + if cfg.Store == nil { + return nil, ErrNoStore + } + if cfg.Runner == nil { + return nil, ErrNoRunner + } + if cfg.Hooks == nil { + cfg.Hooks = noopHooks{} + } + if cfg.Clock == nil { + cfg.Clock = systemClock{} + } + b := &Bus{ + store: cfg.Store, + runner: cfg.Runner, + resumer: cfg.Resumer, + hooks: cfg.Hooks, + clock: cfg.Clock, + transports: cfg.Transports, + opts: cfg.Options.withDefaults(), + } + if len(b.transports) == 0 { + b.transports = []Transport{inProcess{}} + } + b.dispatch = newDispatcher(b, cfg.Synchronous) + return b, nil +} + +// SendParams is one outbound message. +type SendParams struct { + Sender Address + Receivers []Address + Performative Performative + Content string + ConversationID id.ConversationID + ReplyTo []Address + Language string + Encoding string + Ontology string + Protocol string + ReplyWith string + InReplyTo string + ReplyBy *time.Time + OriginRunID id.AgentRunID + Metadata map[string]any +} + +// DeliveryOutcome is one receiver's result from a send. A broadcast reports +// per receiver, because failing the whole send over one bad name throws +// away the deliveries that were fine. +type DeliveryOutcome struct { + Receiver Address `json:"receiver"` + Status string `json:"status"` + Error string `json:"error,omitempty"` +} + +// SendResult is what a send produced. +type SendResult struct { + MessageID id.MessageID `json:"message_id"` + ConversationID id.ConversationID `json:"conversation_id"` + Deliveries []DeliveryOutcome `json:"deliveries"` +} + +// Send validates, persists and queues one message. It returns as soon as +// the deliveries are queued; nothing waits for the recipients. +func (b *Bus) Send(ctx context.Context, p SendParams) (*SendResult, error) { + e, conv, err := b.prepare(ctx, p) + if err != nil { + return nil, err + } + return b.submit(ctx, e, conv) +} + +// prepare builds and validates the envelope and resolves its conversation. +// Everything that can refuse a send happens here, before submit writes the +// message: a half-written send must never become a suspension nothing can +// resume. +func (b *Bus) prepare(ctx context.Context, p SendParams) (*Envelope, *Conversation, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, nil, cortex.ErrNoScope + } + + e := &Envelope{ + Entity: cortex.NewEntity(), + ID: id.NewMessageID(), + Scope: scope, + Performative: p.Performative, + Sender: p.Sender, + Receivers: p.Receivers, + ReplyTo: p.ReplyTo, + Content: p.Content, + Language: p.Language, + Encoding: p.Encoding, + Ontology: p.Ontology, + Protocol: p.Protocol, + ReplyWith: p.ReplyWith, + InReplyTo: p.InReplyTo, + ReplyBy: p.ReplyBy, + OriginRunID: p.OriginRunID, + Metadata: p.Metadata, + } + if err := e.Validate(); err != nil { + return nil, nil, err + } + for _, r := range e.Receivers { + if !b.routable(r) { + return nil, nil, fmt.Errorf("%w: %s", ErrUnroutable, r) + } + } + + conv, err := b.resolveConversation(ctx, p, scope) + if err != nil { + return nil, nil, err + } + if !conv.IsOpen() { + return nil, nil, ErrConversationClosed + } + if conv.HopsUsed+1 > conv.HopCeiling { + return nil, nil, fmt.Errorf("%w: used %d of %d", ErrHopCeiling, conv.HopsUsed, conv.HopCeiling) + } + + e.ConversationID = conv.ID + e.Hops = conv.HopsUsed + 1 + return e, conv, nil +} + +func (b *Bus) resolveConversation(ctx context.Context, p SendParams, scope cortex.Scope) (*Conversation, error) { + if !p.ConversationID.IsNil() { + return b.store.GetConversation(ctx, p.ConversationID) + } + conv := &Conversation{ + Entity: cortex.NewEntity(), + ID: id.NewConversationID(), + Scope: scope, + Protocol: p.Protocol, + Initiator: p.Sender, + Status: StatusOpen, + HopCeiling: b.opts.HopCeiling, + } + if err := b.store.CreateConversation(ctx, conv); err != nil { + return nil, err + } + return conv, nil +} + +// submit writes the envelope, bumps the conversation, queues one delivery +// per receiver and fires MessageSent. Ordering matters: the envelope lands +// first, so a delivery can never point at a message that is not there. +func (b *Bus) submit(ctx context.Context, e *Envelope, conv *Conversation) (*SendResult, error) { + if err := b.store.CreateMessage(ctx, e); err != nil { + return nil, err + } + + conv.HopsUsed = e.Hops + conv.AddParticipant(e.Sender) + for _, r := range e.Receivers { + conv.AddParticipant(r) + } + if err := b.store.UpdateConversation(ctx, conv); err != nil { + return nil, err + } + + res := &SendResult{MessageID: e.ID, ConversationID: e.ConversationID} + for _, r := range e.Receivers { + d := &Delivery{ + Entity: cortex.NewEntity(), + ID: id.NewDeliveryID(), + Scope: e.Scope, + MessageID: e.ID, + Receiver: r, + State: DeliveryQueued, + } + if err := b.store.CreateDelivery(ctx, d); err != nil { + res.Deliveries = append(res.Deliveries, DeliveryOutcome{Receiver: r, Status: DeliveryFailed, Error: err.Error()}) + continue + } + res.Deliveries = append(res.Deliveries, DeliveryOutcome{Receiver: r, Status: DeliveryQueued}) + b.dispatch.enqueue(d.ID) + } + + b.hooks.MessageSent(ctx, e.ID, e.Sender.String(), addressList(e.Receivers), string(e.Performative)) + return res, nil +} + +func (b *Bus) routable(addr Address) bool { + for _, t := range b.transports { + if t.Handles(addr) { + return true + } + } + return false +} + +func addressList(addrs []Address) string { + out := make([]string, len(addrs)) + for i, a := range addrs { + out[i] = a.String() + } + return strings.Join(out, ",") +} diff --git a/a2a/bus_send_test.go b/a2a/bus_send_test.go new file mode 100644 index 0000000..fe919d8 --- /dev/null +++ b/a2a/bus_send_test.go @@ -0,0 +1,165 @@ +package a2a + +import ( + "testing" +) + +func newTestBus(t *testing.T) (*Bus, *memStore, *fakeRunner, *fakeResumer, *recordingHooks, *fakeClock) { + t.Helper() + st, runner, res := newMemStore(), newFakeRunner(), newFakeResumer() + hooks, clk := &recordingHooks{}, &fakeClock{now: testNow} + b, err := NewBus(BusConfig{ + Store: st, + Runner: runner, + Resumer: res, + Hooks: hooks, + Clock: clk, + Synchronous: true, + Options: Options{HopCeiling: 3, Workers: 1}, + }) + if err != nil { + t.Fatalf("NewBus: %v", err) + } + return b, st, runner, res, hooks, clk +} + +func TestNewBusRequiresStoreAndRunner(t *testing.T) { + if _, err := NewBus(BusConfig{Runner: newFakeRunner()}); !errorsIs(err, ErrNoStore) { + t.Fatalf("err = %v, want ErrNoStore", err) + } + if _, err := NewBus(BusConfig{Store: newMemStore()}); !errorsIs(err, ErrNoRunner) { + t.Fatalf("err = %v, want ErrNoRunner", err) + } +} + +func TestSendInformativeQueuesOneDeliveryPerReceiver(t *testing.T) { + b, st, _, _, hooks, _ := newTestBus(t) + ctx := testCtx() + + res, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, + Receivers: []Address{{Agent: "w1"}, {Agent: "w2"}}, + Performative: Inform, + Content: "the build is green", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + if res.MessageID.IsNil() || res.ConversationID.IsNil() { + t.Fatalf("Send returned empty ids: %+v", res) + } + if len(res.Deliveries) != 2 { + t.Fatalf("got %d delivery outcomes, want 2", len(res.Deliveries)) + } + for _, d := range res.Deliveries { + if d.Status != DeliveryQueued { + t.Errorf("%s: status = %s, want queued", d.Receiver.Agent, d.Status) + } + } + + queued, err := st.ListQueuedDeliveries(ctx, 10) + if err != nil { + t.Fatalf("ListQueuedDeliveries: %v", err) + } + if len(queued) != 2 { + t.Fatalf("store holds %d queued deliveries, want 2", len(queued)) + } + if got := hooks.sent(); got != 1 { + t.Fatalf("MessageSent fired %d times, want 1", got) + } +} + +func TestSendPersistsTheEnvelopeAndOpensAConversation(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + res, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, + Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, + Content: "hello", + Protocol: "fipa-request", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + + msg, err := st.GetMessage(ctx, res.MessageID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg.Content != "hello" || msg.Performative != Inform { + t.Fatalf("stored envelope is wrong: %+v", msg) + } + if msg.Hops != 1 { + t.Fatalf("Hops = %d, want 1 for the first message in a conversation", msg.Hops) + } + if msg.Scope.IsZero() { + t.Fatal("the envelope must carry the sender's scope") + } + + conv, err := st.GetConversation(ctx, res.ConversationID) + if err != nil { + t.Fatalf("GetConversation: %v", err) + } + if !conv.IsOpen() || conv.Protocol != "fipa-request" || conv.HopCeiling != 3 { + t.Fatalf("conversation is wrong: %+v", conv) + } + if !conv.HasParticipant(Address{Agent: "planner"}) || !conv.HasParticipant(Address{Agent: "w1"}) { + t.Fatalf("both ends must be recorded as participants: %+v", conv.Participants) + } +} + +func TestSendJoinsAnExistingConversation(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + first, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "one", + }) + if err != nil { + t.Fatalf("first Send: %v", err) + } + second, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "two", ConversationID: first.ConversationID, + }) + if err != nil { + t.Fatalf("second Send: %v", err) + } + if second.ConversationID != first.ConversationID { + t.Fatal("an explicit conversation id must be joined, not replaced") + } + + msg, err := st.GetMessage(ctx, second.MessageID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg.Hops != 2 { + t.Fatalf("Hops = %d, want 2 for the second message", msg.Hops) + } +} + +func TestSendRejectsAnInvalidEnvelopeBeforeWritingAnything(t *testing.T) { + b, st, _, _, hooks, _ := newTestBus(t) + ctx := testCtx() + + _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "planner"}}, + Performative: Inform, Content: "talking to myself", + }) + if !errorsIs(err, ErrSelfAddressed) { + t.Fatalf("err = %v, want ErrSelfAddressed", err) + } + msgs, listErr := st.ListMessages(ctx, &MessageListFilter{Limit: 10}) + if listErr != nil { + t.Fatalf("ListMessages: %v", listErr) + } + if len(msgs) != 0 { + t.Fatalf("a refused send wrote %d messages, want 0", len(msgs)) + } + if hooks.sent() != 0 { + t.Fatal("a refused send must not fire MessageSent") + } +} diff --git a/a2a/dispatcher.go b/a2a/dispatcher.go new file mode 100644 index 0000000..c2584f0 --- /dev/null +++ b/a2a/dispatcher.go @@ -0,0 +1,16 @@ +package a2a + +import "github.com/xraph/cortex/id" + +// dispatcher carries queued deliveries to the bus. Task 13 gives it real +// workers; for now it only records the synchronous mode. +type dispatcher struct { + bus *Bus + synchronous bool +} + +func newDispatcher(b *Bus, synchronous bool) *dispatcher { + return &dispatcher{bus: b, synchronous: synchronous} +} + +func (d *dispatcher) enqueue(id.DeliveryID) {} diff --git a/a2a/fakes_test.go b/a2a/fakes_test.go new file mode 100644 index 0000000..ebc8a4f --- /dev/null +++ b/a2a/fakes_test.go @@ -0,0 +1,141 @@ +package a2a + +import ( + "context" + "sync" + "time" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/id" +) + +var testNow = time.Date(2026, 8, 26, 12, 0, 0, 0, time.UTC) + +// fakeRunner answers RunAgent from a per-agent canned output, defaulting to +// an echo. respond overrides both when set. +type fakeRunner struct { + mu sync.Mutex + calls []fakeCall + outputs map[string]string + err error + respond func(agentName, input string) string +} + +type fakeCall struct{ AgentName, Input string } + +func newFakeRunner() *fakeRunner { return &fakeRunner{outputs: map[string]string{}} } + +func (f *fakeRunner) RunAgent(_ context.Context, name, input string, _ *cortex.RunOpts) (*cortex.AgentResult, error) { + f.mu.Lock() + f.calls = append(f.calls, fakeCall{name, input}) + err, respond := f.err, f.respond + out, ok := f.outputs[name] + f.mu.Unlock() + + if err != nil { + return nil, err + } + switch { + case respond != nil: + out = respond(name, input) + case !ok: + out = input + } + return &cortex.AgentResult{AgentName: name, Output: out, RunID: id.NewAgentRunID()}, nil +} + +func (f *fakeRunner) callCount() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.calls) +} + +func (f *fakeRunner) lastInput() string { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.calls) == 0 { + return "" + } + return f.calls[len(f.calls)-1].Input +} + +func (f *fakeRunner) setErr(err error) { + f.mu.Lock() + defer f.mu.Unlock() + f.err = err +} + +func (f *fakeRunner) setOutput(agent, out string) { + f.mu.Lock() + defer f.mu.Unlock() + f.outputs[agent] = out +} + +// fakeResumer records every resume so a test can assert exactly-once. +type fakeResumer struct { + mu sync.Mutex + resumes []resumeCall + err error +} + +type resumeCall struct { + RunID id.AgentRunID + CallID string + Result string +} + +func newFakeResumer() *fakeResumer { return &fakeResumer{} } + +func (f *fakeResumer) ResumeAgentReply(_ context.Context, runID id.AgentRunID, callID, result string) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return f.err + } + f.resumes = append(f.resumes, resumeCall{runID, callID, result}) + return nil +} + +func (f *fakeResumer) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.resumes) +} + +func (f *fakeResumer) last() resumeCall { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.resumes) == 0 { + return resumeCall{} + } + return f.resumes[len(f.resumes)-1] +} + +type recordingHooks struct { + mu sync.Mutex + sentN, deliveredN, refusedN int + lastRefusal string +} + +func (h *recordingHooks) MessageSent(context.Context, id.MessageID, string, string, string) { + h.mu.Lock() + defer h.mu.Unlock() + h.sentN++ +} + +func (h *recordingHooks) MessageDelivered(context.Context, id.MessageID, string) { + h.mu.Lock() + defer h.mu.Unlock() + h.deliveredN++ +} + +func (h *recordingHooks) MessageRefused(_ context.Context, _ id.MessageID, _, reason string) { + h.mu.Lock() + defer h.mu.Unlock() + h.refusedN++ + h.lastRefusal = reason +} + +func (h *recordingHooks) sent() int { h.mu.Lock(); defer h.mu.Unlock(); return h.sentN } +func (h *recordingHooks) delivered() int { h.mu.Lock(); defer h.mu.Unlock(); return h.deliveredN } +func (h *recordingHooks) refused() int { h.mu.Lock(); defer h.mu.Unlock(); return h.refusedN } diff --git a/a2a/transport.go b/a2a/transport.go new file mode 100644 index 0000000..ce4a887 --- /dev/null +++ b/a2a/transport.go @@ -0,0 +1,14 @@ +package a2a + +import "context" + +// inProcess is the default transport: it handles agents in this engine and +// leaves the work to the bus, which already holds the runner and the store. +// A remote transport does real I/O in Deliver instead. +type inProcess struct{} + +func (inProcess) Handles(addr Address) bool { return addr.IsLocal() } + +func (inProcess) Deliver(context.Context, *Envelope, Address) error { return nil } + +var _ Transport = inProcess{} From 3cad75ff017f96c09d2a82910c267ba309e0f447 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:26:24 -0500 Subject: [PATCH 07/50] test(a2a): pin the refusals that must happen before a send writes --- a2a/bus_refusal_test.go | 126 ++++++++++++++++++++++++++++++++++++++++ 1 file changed, 126 insertions(+) create mode 100644 a2a/bus_refusal_test.go diff --git a/a2a/bus_refusal_test.go b/a2a/bus_refusal_test.go new file mode 100644 index 0000000..3d986d8 --- /dev/null +++ b/a2a/bus_refusal_test.go @@ -0,0 +1,126 @@ +package a2a + +import ( + "context" + "testing" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/id" +) + +// assertNoMessagesWritten is the guarantee every refusal shares: nothing +// reached the store, so nothing can be waiting on a message that is not there. +func assertNoMessagesWritten(t *testing.T, ctx context.Context, st *memStore) { + t.Helper() + msgs, err := st.ListMessages(ctx, &MessageListFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if len(msgs) != 0 { + t.Fatalf("a refused send wrote %d messages, want 0", len(msgs)) + } +} + +func TestSendRefusesAClosedConversation(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + first, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "one", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + + conv, err := st.GetConversation(ctx, first.ConversationID) + if err != nil { + t.Fatalf("GetConversation: %v", err) + } + conv.Status = StatusClosed + if err := st.UpdateConversation(ctx, conv); err != nil { + t.Fatalf("UpdateConversation: %v", err) + } + + _, err = b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "two", ConversationID: first.ConversationID, + }) + if !errorsIs(err, ErrConversationClosed) { + t.Fatalf("err = %v, want ErrConversationClosed", err) + } +} + +func TestSendRefusesPastTheHopCeiling(t *testing.T) { + b, _, _, _, _, _ := newTestBus(t) // ceiling is 3 + ctx := testCtx() + + var convID id.ConversationID + for i := 0; i < 3; i++ { + res, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "msg", ConversationID: convID, + }) + if err != nil { + t.Fatalf("send %d: %v", i, err) + } + convID = res.ConversationID + } + + _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "one too many", ConversationID: convID, + }) + if !errorsIs(err, ErrHopCeiling) { + t.Fatalf("err = %v, want ErrHopCeiling", err) + } +} + +func TestSendRefusesAnUnroutableAddress(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + // Only the in-process transport is configured, and it handles local + // addresses. A Node names a peer nothing here can reach. + _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1", Node: "peer.example"}}, + Performative: Inform, Content: "hello over there", + }) + if !errorsIs(err, ErrUnroutable) { + t.Fatalf("err = %v, want ErrUnroutable", err) + } + assertNoMessagesWritten(t, ctx, st) +} + +func TestSendRequiresAScope(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + + _, err := b.Send(context.Background(), SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "no scope here", + }) + if !errorsIs(err, cortex.ErrNoScope) { + t.Fatalf("err = %v, want cortex.ErrNoScope", err) + } + assertNoMessagesWritten(t, testCtx(), st) +} + +func TestSendRefusesAnUnknownPerformativeBeforeOpeningAConversation(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: "shout", Content: "?", + }) + if !errorsIs(err, ErrInvalidPerformative) { + t.Fatalf("err = %v, want ErrInvalidPerformative", err) + } + convs, err := st.ListConversations(ctx, &ConversationListFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListConversations: %v", err) + } + if len(convs) != 0 { + t.Fatalf("a refused send opened %d conversations, want 0", len(convs)) + } +} From 9dd94a12cf4a70cfea3cff7e34615bde406b0031 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:26:40 -0500 Subject: [PATCH 08/50] feat(a2a): add the durable ask and its correlation ledger --- a2a/bus.go | 75 ++++++++++++++++++++++ a2a/bus_ask_test.go | 149 ++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 224 insertions(+) create mode 100644 a2a/bus_ask_test.go diff --git a/a2a/bus.go b/a2a/bus.go index 3aa98f8..49a979d 100644 --- a/a2a/bus.go +++ b/a2a/bus.go @@ -256,3 +256,78 @@ func addressList(addrs []Address) string { } return strings.Join(out, ",") } + +// Ask errors. +var ( + // ErrAskNeedsOneReceiver means Ask was given zero or several receivers. + // A durable ask correlates one reply to one waiting run. + ErrAskNeedsOneReceiver = errors.New("cortex: a2a: ask needs exactly one receiver") + // ErrAskNeedsDirective means the performative does not demand an answer, + // so nothing would ever resume the asker. + ErrAskNeedsDirective = errors.New("cortex: a2a: ask needs a directive performative") +) + +// AskParams is one message whose sender suspends until the answer arrives. +type AskParams struct { + SendParams + AskerRunID id.AgentRunID + ToolCallID string +} + +// AskResult identifies the message and the token a reply must carry. +type AskResult struct { + MessageID id.MessageID `json:"message_id"` + ConversationID id.ConversationID `json:"conversation_id"` + ReplyWith string `json:"reply_with"` +} + +// Ask sends a directive and records the sender's run as waiting on the +// answer. The caller suspends its run once this returns. +// +// The ledger row is written AFTER the message, and the whole thing is +// refused before either write when the send could not go out. A pending +// ask with no message behind it is a run nothing could ever resume. +func (b *Bus) Ask(ctx context.Context, p AskParams) (*AskResult, error) { + if p.Performative == "" { + p.Performative = Request + } + if len(p.Receivers) != 1 { + return nil, ErrAskNeedsOneReceiver + } + if c, ok := p.Performative.Class(); !ok || c != ClassDirective { + return nil, ErrAskNeedsDirective + } + if p.ReplyWith == "" { + p.ReplyWith = id.NewMessageID().String() + } + if p.ReplyBy == nil { + by := b.clock.Now().Add(b.opts.DefaultReplyBy) + p.ReplyBy = &by + } + + e, conv, err := b.prepare(ctx, p.SendParams) + if err != nil { + return nil, err + } + sent, err := b.submit(ctx, e, conv) + if err != nil { + return nil, err + } + + ask := &PendingAsk{ + Entity: cortex.NewEntity(), + Scope: e.Scope, + ReplyWith: e.ReplyWith, + ConversationID: e.ConversationID, + MessageID: e.ID, + AskerRunID: p.AskerRunID, + AskerAgent: e.Sender.Agent, + ToolCallID: p.ToolCallID, + Expected: e.Receivers[0], + Deadline: e.ReplyBy, + } + if err := b.store.CreatePendingAsk(ctx, ask); err != nil { + return nil, err + } + return &AskResult{MessageID: sent.MessageID, ConversationID: sent.ConversationID, ReplyWith: e.ReplyWith}, nil +} diff --git a/a2a/bus_ask_test.go b/a2a/bus_ask_test.go new file mode 100644 index 0000000..c95eb51 --- /dev/null +++ b/a2a/bus_ask_test.go @@ -0,0 +1,149 @@ +package a2a + +import ( + "testing" + "time" + + "github.com/xraph/cortex/id" +) + +func TestAskWritesAPendingAskKeyedByReplyWith(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + runID := id.NewAgentRunID() + + res, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Content: "what is the status?", + }, + AskerRunID: runID, + ToolCallID: "call-7", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + if res.ReplyWith == "" { + t.Fatal("Ask must mint a reply-with token") + } + + ask, err := st.ClaimPendingAsk(ctx, res.ReplyWith) + if err != nil { + t.Fatalf("ClaimPendingAsk: %v", err) + } + if ask.AskerRunID != runID || ask.ToolCallID != "call-7" { + t.Fatalf("pending ask lost its correlation: %+v", ask) + } + if ask.Expected.Agent != "w1" { + t.Fatalf("Expected = %s, want w1", ask.Expected) + } + if ask.Deadline == nil { + t.Fatal("an ask with no explicit ReplyBy must still get the default deadline") + } + if want := testNow.Add(DefaultReplyBy); !ask.Deadline.Equal(want) { + t.Fatalf("Deadline = %s, want %s", ask.Deadline, want) + } +} + +func TestAskDefaultsToRequest(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + res, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Content: "?"}, + AskerRunID: id.NewAgentRunID(), ToolCallID: "c1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + msg, err := st.GetMessage(ctx, res.MessageID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg.Performative != Request { + t.Fatalf("Performative = %s, want request", msg.Performative) + } + if msg.ReplyWith != res.ReplyWith { + t.Fatal("the envelope must carry the same reply-with as the ledger row") + } +} + +func TestAskRefusesMoreThanOneReceiver(t *testing.T) { + b, _, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + _, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "planner"}, + Receivers: []Address{{Agent: "w1"}, {Agent: "w2"}}, + Content: "?", + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "c1", + }) + if !errorsIs(err, ErrAskNeedsOneReceiver) { + t.Fatalf("err = %v, want ErrAskNeedsOneReceiver", err) + } +} + +func TestAskRefusesAnInformativePerformative(t *testing.T) { + b, _, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + _, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "this answers nothing", + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "c1", + }) + if !errorsIs(err, ErrAskNeedsDirective) { + t.Fatalf("err = %v, want ErrAskNeedsDirective", err) + } +} + +func TestAskWritesNoLedgerRowWhenTheSendIsRefused(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + _, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "planner"}}, + Content: "?", + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "c1", + }) + if !errorsIs(err, ErrSelfAddressed) { + t.Fatalf("err = %v, want ErrSelfAddressed", err) + } + asks, err := st.ListExpiredAsks(ctx, testNow.Add(time.Hour), 10) + if err != nil { + t.Fatalf("ListExpiredAsks: %v", err) + } + if len(asks) != 0 { + t.Fatalf("a refused ask left %d ledger rows, want 0", len(asks)) + } +} + +func TestAskKeepsAnExplicitDeadline(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + deadline := testNow.Add(90 * time.Second) + + res, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Content: "?", ReplyBy: &deadline, + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "c1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + ask, err := st.ClaimPendingAsk(ctx, res.ReplyWith) + if err != nil { + t.Fatalf("ClaimPendingAsk: %v", err) + } + if !ask.Deadline.Equal(deadline) { + t.Fatalf("Deadline = %s, want the explicit %s", ask.Deadline, deadline) + } +} From 95db1e320231968977e52775eeaa766d6cd5d2ce Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:27:57 -0500 Subject: [PATCH 09/50] feat(a2a): deliver by routing class, and reply with the run's output --- a2a/deliver.go | 107 +++++++++++++++++++++++++++++ a2a/deliver_test.go | 157 +++++++++++++++++++++++++++++++++++++++++++ a2a/memstore_test.go | 15 +++++ a2a/render.go | 27 ++++++++ a2a/store.go | 5 ++ 5 files changed, 311 insertions(+) create mode 100644 a2a/deliver.go create mode 100644 a2a/deliver_test.go create mode 100644 a2a/render.go diff --git a/a2a/deliver.go b/a2a/deliver.go new file mode 100644 index 0000000..0dedf46 --- /dev/null +++ b/a2a/deliver.go @@ -0,0 +1,107 @@ +package a2a + +import ( + "context" + "fmt" + + "github.com/xraph/cortex/id" +) + +// deliverOne carries one queued delivery to its receiver. What that means +// depends on the routing class, and the three cases below are the whole of +// the delivery contract. +// +// A recipient's failure is never returned here. It becomes a failure +// message the sender can read, because a peer that broke is information for +// the asker and not a reason to stop the delivery loop. +func (b *Bus) deliverOne(ctx context.Context, deliveryID id.DeliveryID) error { + d, err := b.store.ClaimDelivery(ctx, deliveryID) + if err != nil { + return err + } + e, err := b.store.GetMessage(ctx, d.MessageID) + if err != nil { + return b.failDelivery(ctx, d, err) + } + + class, ok := e.Performative.Class() + if !ok { + return b.failDelivery(ctx, d, ErrInvalidPerformative) + } + + switch class { + case ClassInformative: + return b.finishDelivery(ctx, d, e, id.AgentRunID{}) + case ClassControl: + if err := b.handleControl(ctx, e); err != nil { + return b.failDelivery(ctx, d, err) + } + return b.finishDelivery(ctx, d, e, id.AgentRunID{}) + case ClassDirective: + return b.runDirective(ctx, d, e) + default: + return b.failDelivery(ctx, d, ErrInvalidPerformative) + } +} + +// runDirective starts the recipient's run and turns its outcome into a +// reply on the same conversation. +func (b *Bus) runDirective(ctx context.Context, d *Delivery, e *Envelope) error { + out, runErr := b.runner.RunAgent(ctx, d.Receiver.Agent, RenderInput(e), nil) + + reply := SendParams{ + Sender: d.Receiver, + Receivers: []Address{e.Sender}, + ConversationID: e.ConversationID, + InReplyTo: e.ReplyWith, + Protocol: e.Protocol, + Ontology: e.Ontology, + } + var runID id.AgentRunID + if runErr != nil { + reply.Performative = Failure + reply.Content = fmt.Sprintf("%s could not answer: %v", d.Receiver.Agent, runErr) + } else { + reply.Performative = Inform + reply.Content = out.Output + runID = out.RunID + } + + if err := b.finishDelivery(ctx, d, e, runID); err != nil { + return err + } + // A reply that cannot be sent, because the conversation closed or the + // hop budget ran out, still has to reach whoever is waiting on it. + if _, err := b.Send(ctx, reply); err != nil { + return b.resolveAskWithFailure(ctx, e.ReplyWith, err.Error()) + } + return nil +} + +func (b *Bus) finishDelivery(ctx context.Context, d *Delivery, e *Envelope, runID id.AgentRunID) error { + now := b.clock.Now() + d.State = DeliveryDelivered + d.DeliveredAt = &now + d.RunID = runID + if err := b.store.UpdateDelivery(ctx, d); err != nil { + return err + } + b.hooks.MessageDelivered(ctx, e.ID, d.Receiver.String()) + return nil +} + +func (b *Bus) failDelivery(ctx context.Context, d *Delivery, cause error) error { + d.State = DeliveryFailed + d.Error = cause.Error() + if err := b.store.UpdateDelivery(ctx, d); err != nil { + return err + } + b.hooks.MessageRefused(ctx, d.MessageID, d.Receiver.String(), cause.Error()) + return nil +} + +// handleControl interprets a control message. Task 11 fills it in. +func (b *Bus) handleControl(context.Context, *Envelope) error { return nil } + +// resolveAskWithFailure un-pauses a waiting ask. Task 10 fills it in. +func (b *Bus) resolveAskWithFailure(context.Context, string, string) error { return nil } diff --git a/a2a/deliver_test.go b/a2a/deliver_test.go new file mode 100644 index 0000000..d05d32e --- /dev/null +++ b/a2a/deliver_test.go @@ -0,0 +1,157 @@ +package a2a + +import ( + "errors" + "strings" + "testing" +) + +func TestDeliverInformativeLandsInTheInboxAndStartsNoRun(t *testing.T) { + b, st, runner, _, hooks, _ := newTestBus(t) + ctx := testCtx() + + res, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "the build is green", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + queued, _ := st.ListQueuedDeliveries(ctx, 10) + if err := b.deliverOne(ctx, queued[0].ID); err != nil { + t.Fatalf("deliverOne: %v", err) + } + + if runner.callCount() != 0 { + t.Fatal("an informative must not start a run") + } + inbox, err := st.ListInbox(ctx, "w1", InboxFilter{UnreadOnly: true}) + if err != nil { + t.Fatalf("ListInbox: %v", err) + } + if len(inbox) != 1 || inbox[0].MessageID != res.MessageID { + t.Fatalf("inbox = %+v, want the sent message", inbox) + } + if hooks.delivered() != 1 { + t.Fatalf("MessageDelivered fired %d times, want 1", hooks.delivered()) + } +} + +func TestDeliverDirectiveStartsARunAndRepliesWithItsOutput(t *testing.T) { + b, st, runner, _, _, _ := newTestBus(t) + ctx := testCtx() + runner.setOutput("w1", "status: green") + + res, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Request, Content: "report status", ReplyWith: "rw-1", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + queued, _ := st.ListQueuedDeliveries(ctx, 10) + if err := b.deliverOne(ctx, queued[0].ID); err != nil { + t.Fatalf("deliverOne: %v", err) + } + + if runner.callCount() != 1 { + t.Fatalf("runner called %d times, want 1", runner.callCount()) + } + if !strings.Contains(runner.lastInput(), "report status") { + t.Fatalf("the rendered input lost the content: %q", runner.lastInput()) + } + + msgs, err := st.ListMessages(ctx, &MessageListFilter{ConversationID: res.ConversationID, Limit: 10}) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("conversation holds %d messages, want the request and its reply", len(msgs)) + } + reply := msgs[1] + if reply.Performative != Inform || reply.InReplyTo != "rw-1" { + t.Fatalf("reply is wrong: %+v", reply) + } + if reply.Content != "status: green" || reply.Sender.Agent != "w1" { + t.Fatalf("reply lost the run output: %+v", reply) + } +} + +func TestDeliverDirectiveWhoseRunFailsRepliesWithFailure(t *testing.T) { + b, st, runner, _, _, _ := newTestBus(t) + ctx := testCtx() + runner.setErr(errors.New("model exploded")) + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Request, Content: "report status", ReplyWith: "rw-1", + }); err != nil { + t.Fatalf("Send: %v", err) + } + queued, _ := st.ListQueuedDeliveries(ctx, 10) + if err := b.deliverOne(ctx, queued[0].ID); err != nil { + t.Fatalf("deliverOne must not surface the peer's failure as its own error: %v", err) + } + + msgs, _ := st.ListMessages(ctx, &MessageListFilter{Limit: 10}) + reply := msgs[len(msgs)-1] + if reply.Performative != Failure { + t.Fatalf("Performative = %s, want failure", reply.Performative) + } + if !strings.Contains(reply.Content, "model exploded") { + t.Fatalf("the failure must carry the error text, got %q", reply.Content) + } +} + +func TestDeliverMarksTheRowDelivered(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "x", + }); err != nil { + t.Fatalf("Send: %v", err) + } + queued, _ := st.ListQueuedDeliveries(ctx, 10) + if err := b.deliverOne(ctx, queued[0].ID); err != nil { + t.Fatalf("deliverOne: %v", err) + } + if left, _ := st.ListQueuedDeliveries(ctx, 10); len(left) != 0 { + t.Fatalf("%d rows still queued after delivery, want 0", len(left)) + } +} + +func TestDeliverAnAlreadyClaimedRowIsRefused(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "x", + }); err != nil { + t.Fatalf("Send: %v", err) + } + queued, _ := st.ListQueuedDeliveries(ctx, 10) + if err := b.deliverOne(ctx, queued[0].ID); err != nil { + t.Fatalf("first deliverOne: %v", err) + } + if err := b.deliverOne(ctx, queued[0].ID); !errorsIs(err, ErrDeliveryAlreadyClaimed) { + t.Fatalf("err = %v, want ErrDeliveryAlreadyClaimed", err) + } +} + +func TestRenderInputCarriesSenderPerformativeAndContent(t *testing.T) { + e := &Envelope{ + Performative: Request, + Sender: Address{Agent: "planner"}, + Content: "summarise the incident", + Ontology: "ops", + } + got := RenderInput(e) + for _, want := range []string{"planner", "request", "summarise the incident", "ops"} { + if !strings.Contains(got, want) { + t.Errorf("rendered input is missing %q:\n%s", want, got) + } + } +} diff --git a/a2a/memstore_test.go b/a2a/memstore_test.go index 57a80f5..04fdfcb 100644 --- a/a2a/memstore_test.go +++ b/a2a/memstore_test.go @@ -156,6 +156,21 @@ func (s *memStore) UpdateDelivery(_ context.Context, d *Delivery) error { return nil } +func (s *memStore) ClaimDelivery(_ context.Context, deliveryID id.DeliveryID) (*Delivery, error) { + s.mu.Lock() + defer s.mu.Unlock() + d, ok := s.deliveries[deliveryID.String()] + if !ok { + return nil, ErrDeliveryNotFound + } + if d.State != DeliveryQueued { + return nil, ErrDeliveryAlreadyClaimed + } + d.State = DeliveryDelivering + cp := *d + return &cp, nil +} + func (s *memStore) ListInbox(_ context.Context, agentName string, f InboxFilter) ([]*Delivery, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/a2a/render.go b/a2a/render.go new file mode 100644 index 0000000..6935cdf --- /dev/null +++ b/a2a/render.go @@ -0,0 +1,27 @@ +package a2a + +import ( + "fmt" + "strings" + "time" +) + +// RenderInput turns an envelope into the text a recipient's run receives. +// It names the sender and the speech act, because an agent that cannot tell +// a request from a proposal cannot answer either one properly. +func RenderInput(e *Envelope) string { + var sb strings.Builder + fmt.Fprintf(&sb, "Message from %s (%s)", e.Sender, e.Performative) + if e.Ontology != "" { + fmt.Fprintf(&sb, " [ontology: %s]", e.Ontology) + } + if e.Protocol != "" { + fmt.Fprintf(&sb, " [protocol: %s]", e.Protocol) + } + sb.WriteString("\n\n") + sb.WriteString(e.Content) + if e.ReplyBy != nil { + fmt.Fprintf(&sb, "\n\nReply by %s.", e.ReplyBy.Format(time.RFC3339)) + } + return sb.String() +} diff --git a/a2a/store.go b/a2a/store.go index a7e1941..b1d9726 100644 --- a/a2a/store.go +++ b/a2a/store.go @@ -47,6 +47,11 @@ type Store interface { CreateDelivery(ctx context.Context, d *Delivery) error UpdateDelivery(ctx context.Context, d *Delivery) error + // ClaimDelivery takes ownership of a queued delivery and marks it + // delivering. It returns ErrDeliveryAlreadyClaimed when the row is in + // any other state, which is what stops two workers running one + // directive twice. + ClaimDelivery(ctx context.Context, deliveryID id.DeliveryID) (*Delivery, error) ListInbox(ctx context.Context, agentName string, filter InboxFilter) ([]*Delivery, error) ListQueuedDeliveries(ctx context.Context, limit int) ([]*Delivery, error) MarkDeliveryRead(ctx context.Context, deliveryID id.DeliveryID) error From 0ffcb6024981e8c164f0e166d3d1a1f26b57cbab Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:28:32 -0500 Subject: [PATCH 10/50] feat(a2a): correlate replies to waiting asks and resume exactly once --- a2a/bus.go | 58 +++++++++++++++++ a2a/bus_reply_test.go | 141 ++++++++++++++++++++++++++++++++++++++++++ a2a/deliver.go | 32 +++++++++- 3 files changed, 229 insertions(+), 2 deletions(-) create mode 100644 a2a/bus_reply_test.go diff --git a/a2a/bus.go b/a2a/bus.go index 49a979d..5645283 100644 --- a/a2a/bus.go +++ b/a2a/bus.go @@ -2,6 +2,7 @@ package a2a import ( "context" + "encoding/json" "errors" "fmt" "strings" @@ -219,6 +220,19 @@ func (b *Bus) submit(ctx context.Context, e *Envelope, conv *Conversation) (*Sen } res := &SendResult{MessageID: e.ID, ConversationID: e.ConversationID} + + // A reply that answers a waiting ask reaches its asker through the + // resume, so queueing a delivery as well would hand the same agent the + // same words twice. + resumed, err := b.resolveAsk(ctx, e) + if err != nil { + return nil, err + } + if resumed { + b.hooks.MessageSent(ctx, e.ID, e.Sender.String(), addressList(e.Receivers), string(e.Performative)) + return res, nil + } + for _, r := range e.Receivers { d := &Delivery{ Entity: cortex.NewEntity(), @@ -331,3 +345,47 @@ func (b *Bus) Ask(ctx context.Context, p AskParams) (*AskResult, error) { } return &AskResult{MessageID: sent.MessageID, ConversationID: sent.ConversationID, ReplyWith: e.ReplyWith}, nil } + +// AskReply is what a resumed agent_ask tool call returns to the model. +type AskReply struct { + Performative string `json:"performative"` + Sender string `json:"sender"` + Content string `json:"content"` + ConversationID string `json:"conversation_id"` +} + +// resolveAsk matches an inbound reply to a waiting ask and resumes it, +// reporting whether a run was resumed. +// +// The claim happens before the resume, and that ordering is the design +// rather than a precaution: a late reply, the deadline sweep and a cancel +// are three writers racing for one row, and only the winner may resume. +func (b *Bus) resolveAsk(ctx context.Context, e *Envelope) (bool, error) { + if e.InReplyTo == "" || !e.Performative.ResolvesAsk() { + return false, nil + } + ask, err := b.store.ClaimPendingAsk(ctx, e.InReplyTo) + switch { + case errors.Is(err, ErrAskNotFound), errors.Is(err, ErrAskAlreadyClaimed): + return false, nil + case err != nil: + return false, err + } + if b.resumer == nil { + return false, nil + } + + payload, err := json.Marshal(AskReply{ + Performative: string(e.Performative), + Sender: e.Sender.String(), + Content: e.Content, + ConversationID: e.ConversationID.String(), + }) + if err != nil { + return false, err + } + if err := b.resumer.ResumeAgentReply(ctx, ask.AskerRunID, ask.ToolCallID, string(payload)); err != nil { + return false, err + } + return true, nil +} diff --git a/a2a/bus_reply_test.go b/a2a/bus_reply_test.go new file mode 100644 index 0000000..02fdf66 --- /dev/null +++ b/a2a/bus_reply_test.go @@ -0,0 +1,141 @@ +package a2a + +import ( + "encoding/json" + "testing" + + "github.com/xraph/cortex/id" +) + +// The full loop: A asks, B answers, A's run resumes with B's words. +func TestReplyResumesTheWaitingRun(t *testing.T) { + b, st, runner, resumer, _, _ := newTestBus(t) + ctx := testCtx() + runner.setOutput("w1", "all clear") + runID := id.NewAgentRunID() + + if _, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Content: "status?"}, + AskerRunID: runID, ToolCallID: "call-1", + }); err != nil { + t.Fatalf("Ask: %v", err) + } + + queued, _ := st.ListQueuedDeliveries(ctx, 10) + if err := b.deliverOne(ctx, queued[0].ID); err != nil { + t.Fatalf("deliverOne: %v", err) + } + + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want exactly 1", resumer.count()) + } + got := resumer.last() + if got.RunID != runID || got.CallID != "call-1" { + t.Fatalf("resumed the wrong call: %+v", got) + } + + var payload AskReply + if err := json.Unmarshal([]byte(got.Result), &payload); err != nil { + t.Fatalf("the resume result must be JSON the tool can return: %v", err) + } + if payload.Content != "all clear" || payload.Performative != string(Inform) || payload.Sender != "w1" { + t.Fatalf("reply payload is wrong: %+v", payload) + } +} + +// A resumed asker already has the content, so an inbox copy of the same +// reply would be a duplicate. +func TestAResolvedReplyQueuesNoDelivery(t *testing.T) { + b, st, runner, _, _, _ := newTestBus(t) + ctx := testCtx() + runner.setOutput("w1", "all clear") + + if _, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Content: "status?"}, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }); err != nil { + t.Fatalf("Ask: %v", err) + } + queued, _ := st.ListQueuedDeliveries(ctx, 10) + if err := b.deliverOne(ctx, queued[0].ID); err != nil { + t.Fatalf("deliverOne: %v", err) + } + + if left, _ := st.ListQueuedDeliveries(ctx, 10); len(left) != 0 { + t.Fatalf("%d deliveries queued for a reply that already resumed its asker", len(left)) + } +} + +// A second reply carrying the same in-reply-to must be stored and must not +// resume anything. The claim is what makes that true. +func TestSecondReplyDoesNotResumeTwice(t *testing.T) { + b, st, _, resumer, _, _ := newTestBus(t) + ctx := testCtx() + + ask, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Content: "status?"}, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + + for i := 0; i < 2; i++ { + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "w1"}, Receivers: []Address{{Agent: "planner"}}, + Performative: Inform, Content: "answer", ConversationID: ask.ConversationID, InReplyTo: ask.ReplyWith, + }); err != nil { + t.Fatalf("reply %d: %v", i, err) + } + } + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want exactly 1", resumer.count()) + } + msgs, _ := st.ListMessages(ctx, &MessageListFilter{Limit: 10}) + if len(msgs) != 3 { + t.Fatalf("stored %d messages, want the ask plus both replies", len(msgs)) + } +} + +// agree means "working on it". It must be delivered without resuming. +func TestAgreeDoesNotResume(t *testing.T) { + b, _, _, resumer, _, _ := newTestBus(t) + ctx := testCtx() + + ask, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Content: "status?"}, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "w1"}, Receivers: []Address{{Agent: "planner"}}, + Performative: Agree, Content: "on it", ConversationID: ask.ConversationID, InReplyTo: ask.ReplyWith, + }); err != nil { + t.Fatalf("Send: %v", err) + } + if resumer.count() != 0 { + t.Fatal("agree must not un-pause the asker") + } +} + +// A reply whose in-reply-to matches nothing is ordinary mail. +func TestUnmatchedReplyIsJustAMessage(t *testing.T) { + b, _, _, resumer, _, _ := newTestBus(t) + ctx := testCtx() + + res, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "w1"}, Receivers: []Address{{Agent: "planner"}}, + Performative: Inform, Content: "unsolicited", InReplyTo: "nobody-asked", + }) + if err != nil { + t.Fatalf("Send: %v", err) + } + if resumer.count() != 0 { + t.Fatal("an unmatched in-reply-to must not resume anything") + } + if len(res.Deliveries) != 1 { + t.Fatalf("got %d deliveries, want the message to be delivered normally", len(res.Deliveries)) + } +} diff --git a/a2a/deliver.go b/a2a/deliver.go index 0dedf46..92648d5 100644 --- a/a2a/deliver.go +++ b/a2a/deliver.go @@ -2,6 +2,8 @@ package a2a import ( "context" + "encoding/json" + "errors" "fmt" "github.com/xraph/cortex/id" @@ -103,5 +105,31 @@ func (b *Bus) failDelivery(ctx context.Context, d *Delivery, cause error) error // handleControl interprets a control message. Task 11 fills it in. func (b *Bus) handleControl(context.Context, *Envelope) error { return nil } -// resolveAskWithFailure un-pauses a waiting ask. Task 10 fills it in. -func (b *Bus) resolveAskWithFailure(context.Context, string, string) error { return nil } +// resolveAskWithFailure un-pauses a waiting ask with a failure the asking +// agent can read: a timeout, a cancelled conversation, a reply that could +// not be sent. It is the sweep's and the cancel path's way in. +func (b *Bus) resolveAskWithFailure(ctx context.Context, replyWith, reason string) error { + if replyWith == "" { + return nil + } + ask, err := b.store.ClaimPendingAsk(ctx, replyWith) + switch { + case errors.Is(err, ErrAskNotFound), errors.Is(err, ErrAskAlreadyClaimed): + return nil + case err != nil: + return err + } + if b.resumer == nil { + return nil + } + payload, err := json.Marshal(AskReply{ + Performative: string(Failure), + Sender: ask.Expected.String(), + Content: reason, + ConversationID: ask.ConversationID.String(), + }) + if err != nil { + return err + } + return b.resumer.ResumeAgentReply(ctx, ask.AskerRunID, ask.ToolCallID, string(payload)) +} From 0e76a712a83b6614ead21384f00adb060febd1dc Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:28:46 -0500 Subject: [PATCH 11/50] feat(a2a): handle cancel by closing the conversation and failing its asks --- a2a/bus_cancel_test.go | 81 ++++++++++++++++++++++++++++++++++++++++++ a2a/deliver.go | 28 +++++++++++++-- a2a/memstore_test.go | 15 ++++++++ a2a/store.go | 3 ++ 4 files changed, 125 insertions(+), 2 deletions(-) create mode 100644 a2a/bus_cancel_test.go diff --git a/a2a/bus_cancel_test.go b/a2a/bus_cancel_test.go new file mode 100644 index 0000000..9f669cf --- /dev/null +++ b/a2a/bus_cancel_test.go @@ -0,0 +1,81 @@ +package a2a + +import ( + "encoding/json" + "testing" + + "github.com/xraph/cortex/id" +) + +func TestCancelClosesTheConversationAndFailsWaitingAsks(t *testing.T) { + b, st, _, resumer, _, _ := newTestBus(t) + ctx := testCtx() + + ask, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Content: "status?"}, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Cancel, Content: "never mind", ConversationID: ask.ConversationID, + }); err != nil { + t.Fatalf("cancel Send: %v", err) + } + + // Deliver the cancel only. The original request is still queued, which + // is exactly the state a real cancel races with. + queued, _ := st.ListQueuedDeliveries(ctx, 10) + for _, d := range queued { + msg, err := st.GetMessage(ctx, d.MessageID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg.Performative != Cancel { + continue + } + if err := b.deliverOne(ctx, d.ID); err != nil { + t.Fatalf("deliverOne: %v", err) + } + } + + conv, err := st.GetConversation(ctx, ask.ConversationID) + if err != nil { + t.Fatalf("GetConversation: %v", err) + } + if conv.Status != StatusClosed { + t.Fatalf("Status = %s, want closed", conv.Status) + } + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want 1 (the cancelled ask)", resumer.count()) + } + var payload AskReply + if err := json.Unmarshal([]byte(resumer.last().Result), &payload); err != nil { + t.Fatalf("unmarshal resume payload: %v", err) + } + if payload.Performative != string(Failure) { + t.Fatalf("a cancelled ask must resume with a failure, got %s", payload.Performative) + } +} + +func TestCancelStartsNoRun(t *testing.T) { + b, st, runner, _, _, _ := newTestBus(t) + ctx := testCtx() + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Cancel, Content: "stop", + }); err != nil { + t.Fatalf("Send: %v", err) + } + queued, _ := st.ListQueuedDeliveries(ctx, 10) + if err := b.deliverOne(ctx, queued[0].ID); err != nil { + t.Fatalf("deliverOne: %v", err) + } + if runner.callCount() != 0 { + t.Fatal("handing an LLM a bookkeeping message is a wasted call") + } +} diff --git a/a2a/deliver.go b/a2a/deliver.go index 92648d5..5f1809f 100644 --- a/a2a/deliver.go +++ b/a2a/deliver.go @@ -102,8 +102,32 @@ func (b *Bus) failDelivery(ctx context.Context, d *Delivery, cause error) error return nil } -// handleControl interprets a control message. Task 11 fills it in. -func (b *Bus) handleControl(context.Context, *Envelope) error { return nil } +// handleControl interprets a control message. cancel closes the +// conversation and un-pauses everyone waiting on it: a run paused behind a +// cancelled conversation would otherwise sit until its deadline and learn +// nothing when it got there. +func (b *Bus) handleControl(ctx context.Context, e *Envelope) error { + if e.Performative != Cancel { + return nil + } + asks, err := b.store.ListPendingAsksByConversation(ctx, e.ConversationID) + if err != nil { + return err + } + reason := fmt.Sprintf("conversation cancelled by %s: %s", e.Sender, e.Content) + for _, a := range asks { + if err := b.resolveAskWithFailure(ctx, a.ReplyWith, reason); err != nil { + return err + } + } + + conv, err := b.store.GetConversation(ctx, e.ConversationID) + if err != nil { + return err + } + conv.Status = StatusClosed + return b.store.UpdateConversation(ctx, conv) +} // resolveAskWithFailure un-pauses a waiting ask with a failure the asking // agent can read: a timeout, a cancelled conversation, a reply that could diff --git a/a2a/memstore_test.go b/a2a/memstore_test.go index 04fdfcb..a62ed81 100644 --- a/a2a/memstore_test.go +++ b/a2a/memstore_test.go @@ -273,4 +273,19 @@ func (s *memStore) ListExpiredAsks(_ context.Context, now time.Time, limit int) return out, nil } +func (s *memStore) ListPendingAsksByConversation(_ context.Context, convID id.ConversationID) ([]*PendingAsk, error) { + s.mu.Lock() + defer s.mu.Unlock() + var out []*PendingAsk + for _, key := range s.askKeys { + a := s.asks[key] + if a.ClaimedAt != nil || a.ConversationID != convID { + continue + } + cp := *a + out = append(out, &cp) + } + return out, nil +} + var _ Store = (*memStore)(nil) diff --git a/a2a/store.go b/a2a/store.go index b1d9726..8ce7d67 100644 --- a/a2a/store.go +++ b/a2a/store.go @@ -63,4 +63,7 @@ type Store interface { // before resuming is what keeps a run from being resumed twice. ClaimPendingAsk(ctx context.Context, replyWith string) (*PendingAsk, error) ListExpiredAsks(ctx context.Context, now time.Time, limit int) ([]*PendingAsk, error) + // ListPendingAsksByConversation returns the unclaimed asks waiting on a + // conversation, which is what a cancel has to un-pause. + ListPendingAsksByConversation(ctx context.Context, convID id.ConversationID) ([]*PendingAsk, error) } From 28408c53f37e5b7ee0f4ca7d49343db9f3bce629 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:29:09 -0500 Subject: [PATCH 12/50] feat(a2a): resolve overdue asks into failures instead of failing the run --- a2a/sweep.go | 34 +++++++++++++++ a2a/sweep_test.go | 105 ++++++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 139 insertions(+) create mode 100644 a2a/sweep.go create mode 100644 a2a/sweep_test.go diff --git a/a2a/sweep.go b/a2a/sweep.go new file mode 100644 index 0000000..1b09420 --- /dev/null +++ b/a2a/sweep.go @@ -0,0 +1,34 @@ +package a2a + +import ( + "context" + "fmt" +) + +// sweepBatch caps one pass so a large backlog cannot hold a worker forever. +const sweepBatch = 100 + +// SweepExpiredAsks resolves every ask whose deadline has passed into a +// timeout failure and resumes the run waiting on it, returning how many it +// resolved. +// +// The engine's own suspension sweep FAILS a run nobody answered in time. +// For an agent-reply pause that is the wrong verb, so this runs first: a +// peer that did not answer is something the asking agent can react to, and +// killing the run throws that away. The engine sweep stays as the outer +// backstop for anything this missed. +func (b *Bus) SweepExpiredAsks(ctx context.Context) (int, error) { + asks, err := b.store.ListExpiredAsks(ctx, b.clock.Now(), sweepBatch) + if err != nil { + return 0, err + } + var n int + for _, a := range asks { + reason := fmt.Sprintf("no reply from %s before the deadline", a.Expected) + if err := b.resolveAskWithFailure(ctx, a.ReplyWith, reason); err != nil { + return n, err + } + n++ + } + return n, nil +} diff --git a/a2a/sweep_test.go b/a2a/sweep_test.go new file mode 100644 index 0000000..389467d --- /dev/null +++ b/a2a/sweep_test.go @@ -0,0 +1,105 @@ +package a2a + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/xraph/cortex/id" +) + +func TestSweepResolvesAnOverdueAskIntoAFailure(t *testing.T) { + b, _, _, resumer, _, clk := newTestBus(t) + ctx := testCtx() + + deadline := testNow.Add(time.Minute) + if _, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Content: "status?", ReplyBy: &deadline, + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }); err != nil { + t.Fatalf("Ask: %v", err) + } + + n, err := b.SweepExpiredAsks(ctx) + if err != nil { + t.Fatalf("SweepExpiredAsks: %v", err) + } + if n != 0 { + t.Fatalf("swept %d asks before the deadline, want 0", n) + } + + clk.advance(2 * time.Minute) + n, err = b.SweepExpiredAsks(ctx) + if err != nil { + t.Fatalf("SweepExpiredAsks: %v", err) + } + if n != 1 { + t.Fatalf("swept %d asks after the deadline, want 1", n) + } + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want 1", resumer.count()) + } + + var payload AskReply + if err := json.Unmarshal([]byte(resumer.last().Result), &payload); err != nil { + t.Fatalf("unmarshal: %v", err) + } + if payload.Performative != string(Failure) || !strings.Contains(payload.Content, "deadline") { + t.Fatalf("a swept ask must resume with a timeout failure, got %+v", payload) + } +} + +func TestSweepIsIdempotent(t *testing.T) { + b, _, _, resumer, _, clk := newTestBus(t) + ctx := testCtx() + + if _, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Content: "?"}, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }); err != nil { + t.Fatalf("Ask: %v", err) + } + clk.advance(DefaultReplyBy + time.Minute) + + for i := 0; i < 3; i++ { + if _, err := b.SweepExpiredAsks(ctx); err != nil { + t.Fatalf("sweep %d: %v", i, err) + } + } + if resumer.count() != 1 { + t.Fatalf("resumed %d times across three sweeps, want 1", resumer.count()) + } +} + +// A reply that lands after the sweep gave up must not resume the run a +// second time. The claim already went to the sweep. +func TestReplyAfterSweepDoesNotResumeAgain(t *testing.T) { + b, _, _, resumer, _, clk := newTestBus(t) + ctx := testCtx() + + ask, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Content: "?"}, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + clk.advance(DefaultReplyBy + time.Minute) + if _, err := b.SweepExpiredAsks(ctx); err != nil { + t.Fatalf("SweepExpiredAsks: %v", err) + } + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "w1"}, Receivers: []Address{{Agent: "planner"}}, + Performative: Inform, Content: "sorry, took a while", ConversationID: ask.ConversationID, InReplyTo: ask.ReplyWith, + }); err != nil { + t.Fatalf("late reply: %v", err) + } + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want 1", resumer.count()) + } +} From f51ad74911ed0290c30a1ece4f14b9472b486234 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:30:31 -0500 Subject: [PATCH 13/50] feat(a2a): add the dispatcher, a synchronous drain and restart redrive --- a2a/dispatcher.go | 138 +++++++++++++++++++++++++++++++++++-- a2a/dispatcher_test.go | 151 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 284 insertions(+), 5 deletions(-) create mode 100644 a2a/dispatcher_test.go diff --git a/a2a/dispatcher.go b/a2a/dispatcher.go index c2584f0..39a3d2a 100644 --- a/a2a/dispatcher.go +++ b/a2a/dispatcher.go @@ -1,16 +1,144 @@ package a2a -import "github.com/xraph/cortex/id" +import ( + "context" + "errors" + "sync" + "time" -// dispatcher carries queued deliveries to the bus. Task 13 gives it real -// workers; for now it only records the synchronous mode. + "github.com/xraph/cortex/id" +) + +// drainBatch caps one pass over the queue. +const drainBatch = 100 + +// dispatcher carries queued deliveries to the bus. In synchronous mode it +// delivers nothing on its own and waits for Drain, which is what lets a +// test assert without ever observing a run mid-flight. type dispatcher struct { bus *Bus synchronous bool + wake chan struct{} + + mu sync.Mutex + started bool + done chan struct{} + cancel context.CancelFunc } func newDispatcher(b *Bus, synchronous bool) *dispatcher { - return &dispatcher{bus: b, synchronous: synchronous} + return &dispatcher{bus: b, synchronous: synchronous, wake: make(chan struct{}, 1)} +} + +// enqueue nudges the workers. The queue itself is the store, so a nudge +// that is dropped costs a delay and never a delivery: the next wake, the +// next interval, or a redrive finds the row. +func (d *dispatcher) enqueue(id.DeliveryID) { + if d.synchronous { + return + } + select { + case d.wake <- struct{}{}: + default: + } +} + +// Start launches the delivery workers. Calling it twice is a no-op, and in +// synchronous mode it starts nothing at all. +func (b *Bus) Start(ctx context.Context) error { + d := b.dispatch + d.mu.Lock() + defer d.mu.Unlock() + if d.started || d.synchronous { + d.started = true + return nil + } + + runCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + // done is captured locally rather than read back off the struct when + // the workers finish. Stop clears the field as soon as it has the + // handle it needs, so a closer reading it later closes a nil channel. + done := make(chan struct{}) + d.cancel, d.done, d.started = cancel, done, true + + var wg sync.WaitGroup + for i := 0; i < b.opts.Workers; i++ { + wg.Add(1) + go func() { + defer wg.Done() + d.work(runCtx) + }() + } + go func() { + wg.Wait() + close(done) + }() + return nil +} + +// Stop cancels the workers and WAITS for them. Signalling without waiting +// would let Stop return while a delivery was still writing. +func (b *Bus) Stop() { + d := b.dispatch + d.mu.Lock() + cancel, done := d.cancel, d.done + d.started, d.cancel, d.done = false, nil, nil + d.mu.Unlock() + + if cancel == nil { + return + } + cancel() + <-done +} + +func (d *dispatcher) work(ctx context.Context) { + ticker := time.NewTicker(d.bus.opts.SweepInterval) + defer ticker.Stop() + for { + // A drain error is per batch and the loop keeps going: one bad row + // must not stop delivery for everyone else. + _, _ = d.bus.Drain(ctx) + + select { + case <-ctx.Done(): + return + case <-d.wake: + case <-ticker.C: + } + } +} + +// Drain delivers everything currently queued and reports how many rows it +// carried. Tests call it directly; workers call it in a loop. +// +// The count includes replies the directives produced, because a reply is +// itself a queued delivery and a drain that left them behind would stop +// halfway through the conversation it just started. +func (b *Bus) Drain(ctx context.Context) (int, error) { + var n int + for { + rows, err := b.store.ListQueuedDeliveries(ctx, drainBatch) + if err != nil { + return n, err + } + if len(rows) == 0 { + return n, nil + } + for _, row := range rows { + err := b.deliverOne(ctx, row.ID) + switch { + case errors.Is(err, ErrDeliveryAlreadyClaimed): + continue + case err != nil: + return n, err + } + n++ + } + } } -func (d *dispatcher) enqueue(id.DeliveryID) {} +// Redrive picks up deliveries a previous process queued and never carried. +// It is the work Drain does; the separate name is for the caller that runs +// it once at startup. +func (b *Bus) Redrive(ctx context.Context) (int, error) { return b.Drain(ctx) } diff --git a/a2a/dispatcher_test.go b/a2a/dispatcher_test.go new file mode 100644 index 0000000..440e662 --- /dev/null +++ b/a2a/dispatcher_test.go @@ -0,0 +1,151 @@ +package a2a + +import ( + "sync" + "testing" +) + +func TestDrainDeliversEverythingQueued(t *testing.T) { + b, st, runner, _, _, _ := newTestBus(t) + ctx := testCtx() + + for i := 0; i < 3; i++ { + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Request, Content: "go", + }); err != nil { + t.Fatalf("Send %d: %v", i, err) + } + } + + n, err := b.Drain(ctx) + if err != nil { + t.Fatalf("Drain: %v", err) + } + // Three requests, and the three replies they produced. A drain that + // stopped at the requests would leave the conversation half carried. + if n != 6 { + t.Fatalf("drained %d, want 6", n) + } + if runner.callCount() != 3 { + t.Fatalf("runner called %d times, want 3", runner.callCount()) + } + if left, _ := st.ListQueuedDeliveries(ctx, 10); len(left) != 0 { + t.Fatalf("%d rows still queued after a drain", len(left)) + } + inbox, _ := st.ListInbox(ctx, "planner", InboxFilter{UnreadOnly: true}) + if len(inbox) != 3 { + t.Fatalf("planner has %d replies in the inbox, want 3", len(inbox)) + } +} + +// Redrive is the restart story: rows queued by a process that died get +// picked up by the next one. +func TestRedrivePicksUpOrphanedDeliveries(t *testing.T) { + st, runner := newMemStore(), newFakeRunner() + ctx := testCtx() + + first, err := NewBus(BusConfig{Store: st, Runner: runner, Clock: &fakeClock{now: testNow}, Synchronous: true}) + if err != nil { + t.Fatalf("NewBus: %v", err) + } + if _, err := first.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Request, Content: "survive this", + }); err != nil { + t.Fatalf("Send: %v", err) + } + // first "crashes" here: nothing drained it. + + second, err := NewBus(BusConfig{Store: st, Runner: runner, Clock: &fakeClock{now: testNow}, Synchronous: true}) + if err != nil { + t.Fatalf("NewBus: %v", err) + } + n, err := second.Redrive(ctx) + if err != nil { + t.Fatalf("Redrive: %v", err) + } + if n != 2 { + t.Fatalf("redrove %d deliveries, want the request and its reply", n) + } + if runner.callCount() != 1 { + t.Fatalf("runner called %d times after redrive, want 1", runner.callCount()) + } +} + +// Two workers must never run the same directive twice. The delivery claim +// is what guarantees it. +func TestConcurrentDeliveryOfOneRowHappensOnce(t *testing.T) { + b, st, runner, _, _, _ := newTestBus(t) + ctx := testCtx() + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Request, Content: "once please", + }); err != nil { + t.Fatalf("Send: %v", err) + } + queued, _ := st.ListQueuedDeliveries(ctx, 10) + target := queued[0].ID + + var wg sync.WaitGroup + for i := 0; i < 8; i++ { + wg.Add(1) + go func() { + defer wg.Done() + _ = b.deliverOne(ctx, target) + }() + } + wg.Wait() + + if runner.callCount() != 1 { + t.Fatalf("the directive ran %d times, want exactly 1", runner.callCount()) + } +} + +func TestStartAndStopAreSafeToCallTwice(t *testing.T) { + st, runner := newMemStore(), newFakeRunner() + b, err := NewBus(BusConfig{Store: st, Runner: runner, Options: Options{Workers: 2}}) + if err != nil { + t.Fatalf("NewBus: %v", err) + } + ctx := testCtx() + if err := b.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + if err := b.Start(ctx); err != nil { + t.Fatalf("second Start must be a no-op, got %v", err) + } + b.Stop() + b.Stop() +} + +// The workers are the real path: send, then wait for the run to happen +// without the test ever driving delivery itself. +func TestWorkersDeliverWithoutADrainCall(t *testing.T) { + st, runner := newMemStore(), newFakeRunner() + done := make(chan struct{}) + var once sync.Once + runner.respond = func(string, string) string { + once.Do(func() { close(done) }) + return "answered" + } + + b, err := NewBus(BusConfig{Store: st, Runner: runner, Options: Options{Workers: 2}}) + if err != nil { + t.Fatalf("NewBus: %v", err) + } + ctx := testCtx() + if err := b.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + defer b.Stop() + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Request, Content: "wake up", + }); err != nil { + t.Fatalf("Send: %v", err) + } + <-done +} From 60c59cd8cf8a7839a7ae0ac92c94ff0183b20ce5 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:30:42 -0500 Subject: [PATCH 14/50] feat(a2a): add the inbox read path --- a2a/bus.go | 44 +++++++++++++++++++++++++++++++ a2a/bus_inbox_test.go | 60 +++++++++++++++++++++++++++++++++++++++++++ 2 files changed, 104 insertions(+) create mode 100644 a2a/bus_inbox_test.go diff --git a/a2a/bus.go b/a2a/bus.go index 5645283..5b2cc32 100644 --- a/a2a/bus.go +++ b/a2a/bus.go @@ -389,3 +389,47 @@ func (b *Bus) resolveAsk(ctx context.Context, e *Envelope) (bool, error) { } return true, nil } + +// InboxItem is one delivered message as an agent sees it. +type InboxItem struct { + DeliveryID string `json:"delivery_id"` + MessageID string `json:"message_id"` + ConversationID string `json:"conversation_id"` + Sender string `json:"sender"` + Performative string `json:"performative"` + Content string `json:"content"` + ReceivedAt string `json:"received_at,omitempty"` +} + +// Inbox returns delivered messages for an agent and marks what it returns +// as read. Reading is the acknowledgement: an agent that already saw a +// message in a tool result must not be handed it again next turn. +func (b *Bus) Inbox(ctx context.Context, agentName string, f InboxFilter) ([]InboxItem, error) { + rows, err := b.store.ListInbox(ctx, agentName, f) + if err != nil { + return nil, err + } + items := make([]InboxItem, 0, len(rows)) + for _, d := range rows { + e, err := b.store.GetMessage(ctx, d.MessageID) + if err != nil { + return nil, err + } + item := InboxItem{ + DeliveryID: d.ID.String(), + MessageID: e.ID.String(), + ConversationID: e.ConversationID.String(), + Sender: e.Sender.String(), + Performative: string(e.Performative), + Content: e.Content, + } + if d.DeliveredAt != nil { + item.ReceivedAt = d.DeliveredAt.Format(time.RFC3339) + } + items = append(items, item) + if err := b.store.MarkDeliveryRead(ctx, d.ID); err != nil { + return nil, err + } + } + return items, nil +} diff --git a/a2a/bus_inbox_test.go b/a2a/bus_inbox_test.go new file mode 100644 index 0000000..af38cfe --- /dev/null +++ b/a2a/bus_inbox_test.go @@ -0,0 +1,60 @@ +package a2a + +import "testing" + +func TestInboxReturnsEnvelopesAndMarksThemRead(t *testing.T) { + b, _, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "note one", + }); err != nil { + t.Fatalf("Send: %v", err) + } + if _, err := b.Drain(ctx); err != nil { + t.Fatalf("Drain: %v", err) + } + + items, err := b.Inbox(ctx, "w1", InboxFilter{UnreadOnly: true}) + if err != nil { + t.Fatalf("Inbox: %v", err) + } + if len(items) != 1 { + t.Fatalf("got %d items, want 1", len(items)) + } + if items[0].Content != "note one" || items[0].Sender != "planner" { + t.Fatalf("item is wrong: %+v", items[0]) + } + if items[0].Performative != string(Inform) || items[0].ReceivedAt == "" { + t.Fatalf("item lost its envelope detail: %+v", items[0]) + } + + // Reading is the acknowledgement, so a second call comes back empty. + again, err := b.Inbox(ctx, "w1", InboxFilter{UnreadOnly: true}) + if err != nil { + t.Fatalf("second Inbox: %v", err) + } + if len(again) != 0 { + t.Fatalf("got %d items on the second read, want 0", len(again)) + } +} + +func TestInboxLeavesUndeliveredRowsAlone(t *testing.T) { + b, _, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "still queued", + }); err != nil { + t.Fatalf("Send: %v", err) + } + items, err := b.Inbox(ctx, "w1", InboxFilter{UnreadOnly: true}) + if err != nil { + t.Fatalf("Inbox: %v", err) + } + if len(items) != 0 { + t.Fatal("a queued delivery has not arrived yet and must not show in an inbox") + } +} From 12649f0abe477e529ff8cbc43f96f6cb9d8cea2e Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:32:00 -0500 Subject: [PATCH 15/50] test(a2a): pin the leaf-package import boundary --- a2a/bus_cancel_test.go | 16 +++++++------- a2a/bus_refusal_test.go | 10 ++++----- a2a/conversation_test.go | 4 ++-- a2a/deliver.go | 4 ++-- a2a/deliver_test.go | 15 +++++++------ a2a/dispatcher.go | 7 ++++-- a2a/dispatcher_test.go | 6 +++--- a2a/imports_test.go | 46 ++++++++++++++++++++++++++++++++++++++++ 8 files changed, 80 insertions(+), 28 deletions(-) create mode 100644 a2a/imports_test.go diff --git a/a2a/bus_cancel_test.go b/a2a/bus_cancel_test.go index 9f669cf..69232b0 100644 --- a/a2a/bus_cancel_test.go +++ b/a2a/bus_cancel_test.go @@ -19,26 +19,26 @@ func TestCancelClosesTheConversationAndFailsWaitingAsks(t *testing.T) { t.Fatalf("Ask: %v", err) } - if _, err := b.Send(ctx, SendParams{ + if _, sendErr := b.Send(ctx, SendParams{ Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Performative: Cancel, Content: "never mind", ConversationID: ask.ConversationID, - }); err != nil { - t.Fatalf("cancel Send: %v", err) + }); sendErr != nil { + t.Fatalf("cancel Send: %v", sendErr) } // Deliver the cancel only. The original request is still queued, which // is exactly the state a real cancel races with. queued, _ := st.ListQueuedDeliveries(ctx, 10) for _, d := range queued { - msg, err := st.GetMessage(ctx, d.MessageID) - if err != nil { - t.Fatalf("GetMessage: %v", err) + msg, getErr := st.GetMessage(ctx, d.MessageID) + if getErr != nil { + t.Fatalf("GetMessage: %v", getErr) } if msg.Performative != Cancel { continue } - if err := b.deliverOne(ctx, d.ID); err != nil { - t.Fatalf("deliverOne: %v", err) + if delErr := b.deliverOne(ctx, d.ID); delErr != nil { + t.Fatalf("deliverOne: %v", delErr) } } diff --git a/a2a/bus_refusal_test.go b/a2a/bus_refusal_test.go index 3d986d8..7b800a1 100644 --- a/a2a/bus_refusal_test.go +++ b/a2a/bus_refusal_test.go @@ -10,7 +10,7 @@ import ( // assertNoMessagesWritten is the guarantee every refusal shares: nothing // reached the store, so nothing can be waiting on a message that is not there. -func assertNoMessagesWritten(t *testing.T, ctx context.Context, st *memStore) { +func assertNoMessagesWritten(ctx context.Context, t *testing.T, st *memStore) { t.Helper() msgs, err := st.ListMessages(ctx, &MessageListFilter{Limit: 10}) if err != nil { @@ -38,8 +38,8 @@ func TestSendRefusesAClosedConversation(t *testing.T) { t.Fatalf("GetConversation: %v", err) } conv.Status = StatusClosed - if err := st.UpdateConversation(ctx, conv); err != nil { - t.Fatalf("UpdateConversation: %v", err) + if updErr := st.UpdateConversation(ctx, conv); updErr != nil { + t.Fatalf("UpdateConversation: %v", updErr) } _, err = b.Send(ctx, SendParams{ @@ -89,7 +89,7 @@ func TestSendRefusesAnUnroutableAddress(t *testing.T) { if !errorsIs(err, ErrUnroutable) { t.Fatalf("err = %v, want ErrUnroutable", err) } - assertNoMessagesWritten(t, ctx, st) + assertNoMessagesWritten(ctx, t, st) } func TestSendRequiresAScope(t *testing.T) { @@ -102,7 +102,7 @@ func TestSendRequiresAScope(t *testing.T) { if !errorsIs(err, cortex.ErrNoScope) { t.Fatalf("err = %v, want cortex.ErrNoScope", err) } - assertNoMessagesWritten(t, testCtx(), st) + assertNoMessagesWritten(testCtx(), t, st) } func TestSendRefusesAnUnknownPerformativeBeforeOpeningAConversation(t *testing.T) { diff --git a/a2a/conversation_test.go b/a2a/conversation_test.go index 67d3f33..554479f 100644 --- a/a2a/conversation_test.go +++ b/a2a/conversation_test.go @@ -104,8 +104,8 @@ func TestListInboxReturnsUnreadOnly(t *testing.T) { t.Fatalf("got %d deliveries, want 1", len(got)) } - if err := s.MarkDeliveryRead(ctx, got[0].ID); err != nil { - t.Fatalf("MarkDeliveryRead: %v", err) + if readErr := s.MarkDeliveryRead(ctx, got[0].ID); readErr != nil { + t.Fatalf("MarkDeliveryRead: %v", readErr) } got, err = s.ListInbox(ctx, "worker", InboxFilter{UnreadOnly: true}) if err != nil { diff --git a/a2a/deliver.go b/a2a/deliver.go index 5f1809f..4f9ec16 100644 --- a/a2a/deliver.go +++ b/a2a/deliver.go @@ -116,8 +116,8 @@ func (b *Bus) handleControl(ctx context.Context, e *Envelope) error { } reason := fmt.Sprintf("conversation cancelled by %s: %s", e.Sender, e.Content) for _, a := range asks { - if err := b.resolveAskWithFailure(ctx, a.ReplyWith, reason); err != nil { - return err + if failErr := b.resolveAskWithFailure(ctx, a.ReplyWith, reason); failErr != nil { + return failErr } } diff --git a/a2a/deliver_test.go b/a2a/deliver_test.go index d05d32e..0c474b9 100644 --- a/a2a/deliver_test.go +++ b/a2a/deliver_test.go @@ -18,8 +18,8 @@ func TestDeliverInformativeLandsInTheInboxAndStartsNoRun(t *testing.T) { t.Fatalf("Send: %v", err) } queued, _ := st.ListQueuedDeliveries(ctx, 10) - if err := b.deliverOne(ctx, queued[0].ID); err != nil { - t.Fatalf("deliverOne: %v", err) + if delErr := b.deliverOne(ctx, queued[0].ID); delErr != nil { + t.Fatalf("deliverOne: %v", delErr) } if runner.callCount() != 0 { @@ -35,6 +35,9 @@ func TestDeliverInformativeLandsInTheInboxAndStartsNoRun(t *testing.T) { if hooks.delivered() != 1 { t.Fatalf("MessageDelivered fired %d times, want 1", hooks.delivered()) } + if hooks.refused() != 0 { + t.Fatalf("MessageRefused fired %d times on a clean delivery, want 0", hooks.refused()) + } } func TestDeliverDirectiveStartsARunAndRepliesWithItsOutput(t *testing.T) { @@ -50,8 +53,8 @@ func TestDeliverDirectiveStartsARunAndRepliesWithItsOutput(t *testing.T) { t.Fatalf("Send: %v", err) } queued, _ := st.ListQueuedDeliveries(ctx, 10) - if err := b.deliverOne(ctx, queued[0].ID); err != nil { - t.Fatalf("deliverOne: %v", err) + if delErr := b.deliverOne(ctx, queued[0].ID); delErr != nil { + t.Fatalf("deliverOne: %v", delErr) } if runner.callCount() != 1 { @@ -114,8 +117,8 @@ func TestDeliverMarksTheRowDelivered(t *testing.T) { t.Fatalf("Send: %v", err) } queued, _ := st.ListQueuedDeliveries(ctx, 10) - if err := b.deliverOne(ctx, queued[0].ID); err != nil { - t.Fatalf("deliverOne: %v", err) + if delErr := b.deliverOne(ctx, queued[0].ID); delErr != nil { + t.Fatalf("deliverOne: %v", delErr) } if left, _ := st.ListQueuedDeliveries(ctx, 10); len(left) != 0 { t.Fatalf("%d rows still queued after delivery, want 0", len(left)) diff --git a/a2a/dispatcher.go b/a2a/dispatcher.go index 39a3d2a..3869312 100644 --- a/a2a/dispatcher.go +++ b/a2a/dispatcher.go @@ -97,8 +97,11 @@ func (d *dispatcher) work(ctx context.Context) { defer ticker.Stop() for { // A drain error is per batch and the loop keeps going: one bad row - // must not stop delivery for everyone else. - _, _ = d.bus.Drain(ctx) + // must not stop delivery for everyone else. A cancelled context is + // the exception, because that is the worker being shut down. + if _, err := d.bus.Drain(ctx); errors.Is(err, context.Canceled) { + return + } select { case <-ctx.Done(): diff --git a/a2a/dispatcher_test.go b/a2a/dispatcher_test.go index 440e662..801a5b6 100644 --- a/a2a/dispatcher_test.go +++ b/a2a/dispatcher_test.go @@ -49,11 +49,11 @@ func TestRedrivePicksUpOrphanedDeliveries(t *testing.T) { if err != nil { t.Fatalf("NewBus: %v", err) } - if _, err := first.Send(ctx, SendParams{ + if _, sendErr := first.Send(ctx, SendParams{ Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Performative: Request, Content: "survive this", - }); err != nil { - t.Fatalf("Send: %v", err) + }); sendErr != nil { + t.Fatalf("Send: %v", sendErr) } // first "crashes" here: nothing drained it. diff --git a/a2a/imports_test.go b/a2a/imports_test.go new file mode 100644 index 0000000..cda126d --- /dev/null +++ b/a2a/imports_test.go @@ -0,0 +1,46 @@ +package a2a + +import ( + "go/parser" + "go/token" + "os" + "strings" + "testing" +) + +// a2a reaches the host through injected seams. An import of engine, plugin, +// store or orchestration is an import cycle waiting to happen, and it means +// the seams stopped being the boundary. Widening the allowlist is not the +// fix; moving whatever leaked back behind a seam is. +func TestPackageImportsNothingButCortexAndID(t *testing.T) { + const module = "github.com/xraph/cortex" + allowed := map[string]bool{ + module: true, + module + "/id": true, + } + + entries, err := os.ReadDir(".") + if err != nil { + t.Fatalf("ReadDir: %v", err) + } + fset := token.NewFileSet() + for _, entry := range entries { + name := entry.Name() + if entry.IsDir() || !strings.HasSuffix(name, ".go") || strings.HasSuffix(name, "_test.go") { + continue + } + file, parseErr := parser.ParseFile(fset, name, nil, parser.ImportsOnly) + if parseErr != nil { + t.Fatalf("ParseFile %s: %v", name, parseErr) + } + for _, imp := range file.Imports { + path := strings.Trim(imp.Path.Value, `"`) + if !strings.HasPrefix(path, module) { + continue // standard library and third party are fine + } + if !allowed[path] { + t.Errorf("%s imports %s, which breaks the leaf-package rule", name, path) + } + } + } +} From aa44561188689fa0b014dd7f5ef54b1f3cd3c08b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:34:34 -0500 Subject: [PATCH 16/50] docs(a2a): plan persistence and the engine wiring --- ...-26-cortex-a2a-2-persistence-and-engine.md | 396 ++++++++++++++++++ 1 file changed, 396 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-cortex-a2a-2-persistence-and-engine.md diff --git a/docs/superpowers/plans/2026-08-26-cortex-a2a-2-persistence-and-engine.md b/docs/superpowers/plans/2026-08-26-cortex-a2a-2-persistence-and-engine.md new file mode 100644 index 0000000..32bc88d --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-cortex-a2a-2-persistence-and-engine.md @@ -0,0 +1,396 @@ +# Cortex A2A Plan 2: persistence and the engine + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Put the `a2a` package behind real storage and wire it into the engine, so an agent can call `agent_send`, `agent_ask` and `agent_inbox` for real. + +**Architecture:** `a2a.Store` gets an implementation per backend and folds into the composite `store.Store`. The engine grows a new suspension reason, a builtin tool contract that can pend, three builtin tools, a `WithA2A` option, lifecycle wiring, and three plugin hooks. + +**Tech Stack:** Go 1.26, grove (the repo's query builder), sqlite/postgres/mongo backends, the existing `storetest` conformance harness. + +**Spec:** [docs/superpowers/specs/2026-08-26-cortex-a2a-messaging-design.md](../specs/2026-08-26-cortex-a2a-messaging-design.md) + +**Depends on:** [Plan 1](2026-08-26-cortex-a2a-1-package.md), complete. + +## Deviation from the usual plan format, stated up front + +Plan 1 carried full code for every step. This one carries full code for the parts where a wrong choice is expensive (the suspension reason, the resume gating, the builtin outcome contract, the tool schemas) and carries interfaces, file lists, test names and the specific gotchas for the parts that are mechanical repetition of an existing pattern (three store backends implementing sixteen methods each). The store work has a reference implementation to copy from in the same repo: [store/sqlite/orchestration.go](../../../store/sqlite/orchestration.go), [store/postgres/orchestration.go](../../../store/postgres/orchestration.go) and [store/mongo/orchestration.go](../../../store/mongo/orchestration.go). Restating 1,100 lines of that pattern in a plan document would not make it more correct. + +## Global Constraints + +- Everything from Plan 1's global constraints still holds, in particular TDD and lint-clean. +- **Scope is stamped on write and filtered on read.** Every store method reads `cortex.ScopeFromContext`, returns `cortex.ErrNoScope` on a zero scope, and filters with `scopePredicates`. This is not optional: [store/scopespy](../../../store/scopespy) exists because a scope sitting available and unread is how every row ended up in one bucket once already. +- **Scope columns are never updated.** Each backend keeps a `mutable...Columns` whitelist, mirroring `mutableOrchestrationConfigColumns`. Grove builds SET from every model field otherwise, and an update from a broader context would silently widen a row's stored scope. +- **Migrations are additive and idempotent.** New tables only, `CREATE TABLE IF NOT EXISTS`, with a `Down` that drops them. Never edit a shipped migration. +- **Postgres and mongo cannot be tested without Docker.** If containers do not start, say so and do not claim those backends pass. + +--- + +### Task 1: sqlite store + +**Files:** +- Create: `store/sqlite/a2a.go` +- Modify: `store/sqlite/models.go` (four models plus to/from converters), `store/sqlite/migrations.go` (one new migration) +- Test: `store/storetest/conformance.go` (new cases, run by all three backends) + +**Interfaces:** +- Produces: `*sqlite.Store` satisfying `a2a.Store` in full: the sixteen methods listed in [a2a/store.go](../../../a2a/store.go). + +Four tables, following the naming of the existing ones: + +| Table | Notes | +|---|---| +| `cortex_a2a_messages` | Envelope. Receivers, reply_to and metadata are JSON columns; the 13 ACL parameters are real columns. Index on `(scope_canon, conversation_id)`. | +| `cortex_a2a_conversations` | Index on `(scope_canon, status)`. | +| `cortex_a2a_deliveries` | Index on `(scope_canon, receiver_agent, state)`, which is the inbox query, and on `state` for the redrive query. | +| `cortex_a2a_pending_asks` | Unique index on `reply_with`. That uniqueness is what makes the claim safe. | + +- [ ] **Step 1: Write the failing conformance cases** + +Add to `store/storetest/conformance.go`, inside the existing `Conformance` function's subtest list. Every backend runs these, so they are written once: + +```go + t.Run("A2AMessageRoundTrip", func(t *testing.T) { a2aMessageRoundTrip(t, s) }) + t.Run("A2AConversationHops", func(t *testing.T) { a2aConversationHops(t, s) }) + t.Run("A2ADeliveryStates", func(t *testing.T) { a2aDeliveryStates(t, s) }) + t.Run("A2AClaimPendingAskOnce", func(t *testing.T) { a2aClaimPendingAskOnce(t, s) }) + t.Run("A2AExpiredAsks", func(t *testing.T) { a2aExpiredAsks(t, s) }) + t.Run("A2AScopeIsolation", func(t *testing.T) { a2aScopeIsolation(t, s) }) +``` + +The bodies mirror the memStore tests from Plan 1 Task 4, with two additions no in-memory double could prove: + +- `a2aClaimPendingAskOnce` runs the two claims **concurrently** from eight goroutines and asserts exactly one success and seven `ErrAskAlreadyClaimed`. Against a real database this exercises the unique index and the UPDATE ... WHERE claimed_at IS NULL, which is the actual mechanism. +- `a2aScopeIsolation` writes under scope A and reads under scope B, asserting nothing crosses. Use the `ctxWithScope` helper already in the file. + +- [ ] **Step 2: Run to verify they fail** + +Run: `go test ./store/sqlite/ -run TestConformance` +Expected: FAIL to compile, `*Store does not implement a2a.Store`. + +- [ ] **Step 3: Write the models and the migration** + +Follow `orchestrationConfigModel` in [store/sqlite/models.go:951](../../../store/sqlite/models.go) exactly: bun struct tags, the five scope columns (`scope_l0`, `scope_l1`, `scope_l2`, `scope_extra`, `scope_canon`), JSON marshalling for slice and map fields, and a `...FromModel` returning an error when a JSON column fails to decode. + +The migration goes at the end of the migration list with the next sequence number, name `create_a2a`, comment "Create cortex_a2a_messages, cortex_a2a_conversations, cortex_a2a_deliveries and cortex_a2a_pending_asks tables". Scope columns are in the CREATE TABLE from the start, so there is no follow-up scope migration. + +- [ ] **Step 4: Write the sixteen methods** + +Copy the shape of [store/sqlite/orchestration.go](../../../store/sqlite/orchestration.go). The three that are not boilerplate: + +```go +// ClaimPendingAsk takes the row only if nobody else has. The WHERE clause +// is the claim: two callers racing both issue this UPDATE, and exactly one +// of them changes a row. +func (s *Store) ClaimPendingAsk(ctx context.Context, replyWith string) (*a2a.PendingAsk, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + now := time.Now().UTC() + q := s.sdb.NewUpdate(&a2aPendingAskModel{}). + Set("claimed_at = ?", now). + Where("reply_with = ?", replyWith). + Where("claimed_at IS NULL") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return nil, fmt.Errorf("cortex/sqlite: claim pending ask: %w", err) + } + affected, err := res.RowsAffected() + if err != nil { + return nil, fmt.Errorf("cortex/sqlite: claim pending ask: %w", err) + } + if affected == 0 { + // Nothing changed for one of two reasons, and the caller needs to + // tell them apart: a claim that lost a race is normal, a claim for + // a token nobody minted is a bug somewhere upstream. + exists, existsErr := s.pendingAskExists(ctx, replyWith) + if existsErr != nil { + return nil, existsErr + } + if exists { + return nil, a2a.ErrAskAlreadyClaimed + } + return nil, a2a.ErrAskNotFound + } + return s.getPendingAsk(ctx, replyWith) +} +``` + +`ClaimDelivery` follows the same shape with `Where("state = ?", a2a.DeliveryQueued)` and `Set("state = ?", a2a.DeliveryDelivering)`, returning `a2a.ErrDeliveryAlreadyClaimed` and `a2a.ErrDeliveryNotFound`. + +`ListQueuedDeliveries` deliberately does **not** filter by scope: the dispatcher redrives across every scope in the process, and each delivered message carries its own scope forward. Write that reason in a comment above the method, because it is the one place in this file that breaks the rule the rest of it follows. + +- [ ] **Step 5: Run the conformance suite** + +Run: `go test ./store/sqlite/ -race -v -run TestConformance` +Expected: PASS, including the concurrent claim case. + +- [ ] **Step 6: Commit** + +```bash +git add store/sqlite/ store/storetest/ +git commit -m "feat(store/sqlite): persist a2a messages, conversations, deliveries and asks" +``` + +--- + +### Task 2: Fold `a2a.Store` into the composite + +**Files:** +- Modify: `store/store.go` +- Test: the existing `scopespy` completeness test, which enumerates the composite's methods + +- [ ] **Step 1: Add the interface** + +```go + orchestration.ConfigStore + orchestration.RunStore + a2a.Store +``` + +- [ ] **Step 2: Run** + +Run: `go build ./... && go test ./store/...` +Expected: postgres and mongo now fail to compile, which is the correct signal and is what Tasks 3 and 4 fix. Sqlite passes. + +- [ ] **Step 3: Commit after Tasks 3 and 4** + +The tree does not build between here and Task 4, so this task's changes are committed together with them. + +--- + +### Task 3: postgres store + +Same as Task 1, against [store/postgres/orchestration.go](../../../store/postgres/orchestration.go). Differences that matter: + +- Migrations are SQL files under `store/postgres/migrations/`, not inline strings. Follow the numbering already there. +- JSON columns are `jsonb`. +- `ClaimPendingAsk` can use `UPDATE ... RETURNING`, which collapses the claim and the read into one statement. Prefer it. + +Run: `go test ./store/postgres/ -race -run TestConformance`. **If Docker is unavailable, this cannot be verified. Say so plainly rather than reporting a pass.** + +--- + +### Task 4: mongo store + +Same again, against [store/mongo/orchestration.go](../../../store/mongo/orchestration.go). Differences that matter: + +- Mongo has no migrations in the SQL sense; index creation lives in [store/mongo/migrations.go](../../../store/mongo/migrations.go). +- The claim is `FindOneAndUpdate` with a filter of `{reply_with: x, claimed_at: nil}` and `ReturnDocument: After`. That is atomic, so no separate existence check is needed for the success path. +- The unique index on `reply_with` must be created, or the claim's atomicity rests on nothing. + +Run: `go test ./store/mongo/ -race -run TestConformance`. Same caveat about Docker. + +Commit Tasks 2, 3 and 4 together, because the tree does not build in between: + +```bash +git add store/ +git commit -m "feat(store): persist a2a across postgres and mongo, and fold it into the composite" +``` + +--- + +### Task 5: `ReasonAgentReply` and resume gating + +This is the load-bearing engine change. A host must not be able to forge a peer's reply. + +**Files:** +- Modify: `suspension/suspension.go`, `engine/resume.go` +- Test: `engine/a2a_resume_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +func TestResumeRefusesAnAgentReplyPause(t *testing.T) { + // A run paused waiting on a peer is not the caller's to answer. The + // public Resume must refuse it exactly like an approval pause. + e, runID := suspendedOnAgentReply(t) + _, err := e.Resume(ctx, runID, ResumeInput{Results: []ToolResult{{CallID: "call-1", Result: "forged"}}}) + if !errors.Is(err, ErrNotResumable) { + t.Fatalf("err = %v, want ErrNotResumable", err) + } +} + +func TestResumeAgentReplyContinuesTheRun(t *testing.T) { + // The bus's own path does resume it, and the model sees the reply as + // the tool result. + e, runID := suspendedOnAgentReply(t) + if err := e.resumeAgentReply(ctx, runID, "call-1", `{"content":"all clear"}`); err != nil { + t.Fatalf("resumeAgentReply: %v", err) + } + // assert the run completed and the fake LLM saw a tool message + // carrying "all clear" +} +``` + +- [ ] **Step 2: Implement** + +In `suspension/suspension.go`: + +```go + // ReasonAgentReply means the run is waiting on another agent's answer. + // + // It is not ReasonExternalTool even though both wait on something + // outside the loop, because the two say different things about who + // acts next. External says the CALLER executes the call and reports + // back. Agent-reply says cortex itself is waiting on a peer, and a + // caller answering it would be forging a message the peer never sent. + ReasonAgentReply SuspendReason = "agent_reply" +``` + +In `engine/resume.go`, `claimForResume` already refuses an approval pause. Extend that check so a public `Resume` refuses `ReasonAgentReply` too, and add the internal entry point the bus uses: + +```go +// resumeAgentReply continues a run that was waiting on a peer. It is the +// only path allowed to answer a ReasonAgentReply pause, and it is not +// exported: the correlation ledger is what decides a reply is genuine, and +// a public caller has no ledger row to prove it with. +func (e *Engine) resumeAgentReply(ctx context.Context, runID id.AgentRunID, callID, result string) (*run.Run, error) { + return e.resume(ctx, runID, ResumeInput{Results: []ToolResult{{CallID: callID, Result: result}}}, resumeSourceAgentReply) +} +``` + +`resume` currently takes `approved bool`. Two callers with two different privileges was already a boolean; three is where a boolean stops being honest. Replace it with a small `resumeSource` enum (`resumeSourcePublic`, `resumeSourceApproval`, `resumeSourceAgentReply`) and switch on it. Update the two existing call sites. + +- [ ] **Step 3: Run and commit** + +Run: `go test ./engine/ ./suspension/ -race` + +```bash +git add engine/ suspension/ +git commit -m "feat(engine): add the agent-reply suspension reason and gate its resume" +``` + +--- + +### Task 6: The builtin outcome contract, and the three tools + +**Files:** +- Modify: `engine/tools.go`, `engine/react.go`, `engine/options.go` +- Create: `engine/a2a_tools.go` +- Test: `engine/a2a_tools_test.go` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestAgentAskSuspendsTheRun(t *testing.T) // reason is ReasonAgentReply +func TestAgentAskSiblingToolCallStillCompletes(t *testing.T) // one step, two calls, one suspend +func TestAgentSendDoesNotSuspend(t *testing.T) // returns a result, run continues +func TestAgentInboxReturnsDeliveredMessages(t *testing.T) +func TestA2AToolsAbsentWithoutWithA2A(t *testing.T) // no option, no tools in the list +func TestAuthorizerDenialNeverReachesTheBus(t *testing.T) // no message written +func TestErrRequiresApprovalOnAgentAskOpensACheckpoint(t *testing.T) +``` + +- [ ] **Step 2: Change the builtin contract** + +`executeBuiltinTool` returns `(string, bool)` today, so a builtin can only complete. It becomes: + +```go +// executeBuiltinTool attempts to execute a built-in tool. The second return +// says whether this call was handled here at all; the outcome says how it +// ended, because agent_ask does not complete: it pends, and the loop +// suspends the step around it. +func (e *Engine) executeBuiltinTool(ctx context.Context, inv cortex.Invocation) (string, toolOutcome, bool) +``` + +`dispatchTool` propagates the outcome instead of assuming `outcomeCompleted`, and `executeTool` maps a pending builtin to `suspension.ReasonAgentReply` the way it maps an external tool to `ReasonExternalTool`. + +- [ ] **Step 3: Write the tools** + +Schemas, matching the spec §7.2. `agent_ask` calls `bus.Ask` with `inv.Subject.RunID` and `inv.Call.ID`, then returns `("", outcomePending, true)`. `agent_send` and `agent_inbox` return JSON results and `outcomeCompleted`. + +The tools appear in `builtinTools()` only when `e.a2a != nil`, mirroring the knowledge gate. + +- [ ] **Step 4: Add the option** + +```go +// WithA2A turns on agent-to-agent messaging. The three tools appear only +// when it is set, so a host that does not configure it sees no new tools +// and no new tables touched. +func WithA2A(opts a2a.Options) Option +``` + +It builds the bus from `e.store` (which satisfies `a2a.Store` after Task 2), the engine's own runner adapter, a resumer wrapping `resumeAgentReply`, and a hooks adapter over `e.extensions`. It errors if there is no store. + +- [ ] **Step 5: Run and commit** + +Run: `go test ./engine/ -race` + +```bash +git add engine/ +git commit -m "feat(engine): let a builtin pend, and add the three a2a tools" +``` + +--- + +### Task 7: Lifecycle and hooks + +**Files:** +- Modify: `engine/engine.go` (Start/Stop), `plugin/plugin.go`, `plugin/registry.go` +- Test: `engine/a2a_lifecycle_test.go`, `plugin/registry_test.go` + +- [ ] **Step 1: Wire the lifecycle** + +`Engine.Start` starts the bus dispatcher and redrives orphaned deliveries; `Engine.Stop` stops it and waits, joining rather than signalling, the way `stopSweeper` already does. The ask sweep runs on the bus's own interval, ahead of the engine's suspension sweep. + +- [ ] **Step 2: Add the hooks** + +Three new per-hook interfaces beside `AgentHandoff`, plus their registry emitters and type-cached dispatch: + +```go +// MessageSent fires when an envelope is accepted and queued. +type MessageSent interface { + OnMessageSent(ctx context.Context, msgID id.MessageID, from, to, performative string) +} + +// MessageDelivered fires when an envelope reaches a receiver. +type MessageDelivered interface { + OnMessageDelivered(ctx context.Context, msgID id.MessageID, to string) +} + +// MessageRefused fires when delivery is refused: an exhausted hop budget, +// an unroutable address, a failed delivery. +type MessageRefused interface { + OnMessageRefused(ctx context.Context, msgID id.MessageID, to, reason string) +} +``` + +`AgentHandoff` is untouched. Orchestration handoffs and ACL messages are different events, and collapsing them would lie to every existing subscriber. + +- [ ] **Step 3: Run and commit** + +Run: `go test ./... -race` (mongo excluded if Docker is down) + +```bash +git add engine/ plugin/ +git commit -m "feat(engine): run the message bus with the engine, and emit its hooks" +``` + +--- + +### Task 8: The end-to-end test + +One test that proves the whole thing, with a fake LLM and a real sqlite store: agent A's model calls `agent_ask`, A's run suspends with `ReasonAgentReply`, the dispatcher runs B, B's output comes back as a reply, A resumes and its model sees the answer as the tool result. + +**Files:** +- Test: `engine/a2a_e2e_test.go` + +This is the test that would have caught every integration mistake the unit tests cannot see, so it is worth writing even though every piece under it is already covered. + +```bash +git add engine/ +git commit -m "test(engine): prove the ask, run, reply, resume loop end to end" +``` + +--- + +## Self-review + +**Spec coverage.** §6 to Tasks 1 through 4, §7.5 to Task 6, §9.5 to Task 5, §10.1 to Task 6, §10.2 and §10.3 to Task 5, §10.4 to Task 6, §10.5 and §10.6 to Task 7, §10.7 and §10.8 landed in Plan 1. + +**Carried forward from Plan 1.** The stale-`delivering` gap: a delivery claimed by a process that dies is never redriven. Task 1 gives the delivery row a `claimed_at`, and the redrive query picks up rows in `delivering` older than a threshold. That is only testable against a real store, which is why it lands here and not in Plan 1. From 194dc57fa526baa11402697c1faa0801b762f447 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:46:13 -0500 Subject: [PATCH 17/50] feat(store/sqlite): persist a2a messages, conversations, deliveries and asks The claim is the interesting part. ClaimPendingAsk and ClaimDelivery are conditional updates, so a late reply, a deadline sweep and a cancel can all reach for one row and exactly one of them changes it. Conformance races both against a real database, which is the only place that guarantee can actually be tested. Rescope now skips scoped tables with no id column. Pending asks are keyed by their reply-with token, and a table born with scope columns has no legacy rows to backfill. --- a2a/store.go | 11 + store/sqlite/a2a.go | 598 +++++++++++++++++++++++++++++++++ store/sqlite/migrations.go | 123 +++++++ store/sqlite/models.go | 348 +++++++++++++++++++ store/sqlite/rescope.go | 10 + store/sqlite/store.go | 5 + store/store.go | 2 + store/storetest/conformance.go | 358 ++++++++++++++++++++ 8 files changed, 1455 insertions(+) create mode 100644 store/sqlite/a2a.go diff --git a/a2a/store.go b/a2a/store.go index 8ce7d67..0007e0a 100644 --- a/a2a/store.go +++ b/a2a/store.go @@ -2,11 +2,22 @@ package a2a import ( "context" + "errors" "time" "github.com/xraph/cortex/id" ) +// Store lookup errors. They are package sentinels rather than each +// backend's own error, so a caller matches one thing with errors.Is +// whichever database is underneath. +var ( + // ErrMessageNotFound means no envelope carries that id in this scope. + ErrMessageNotFound = errors.New("cortex: a2a: message not found") + // ErrConversationNotFound means no conversation carries that id in this scope. + ErrConversationNotFound = errors.New("cortex: a2a: conversation not found") +) + // InboxFilter controls an inbox listing. Scope arrives on the context. type InboxFilter struct { UnreadOnly bool diff --git a/store/sqlite/a2a.go b/store/sqlite/a2a.go new file mode 100644 index 0000000..e7c1832 --- /dev/null +++ b/store/sqlite/a2a.go @@ -0,0 +1,598 @@ +package sqlite + +import ( + "context" + "fmt" + "time" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" +) + +// mutableA2AConversationColumns is every cortex_a2a_conversations column +// UpdateConversation may write. The five scope columns are deliberately +// absent: a conversation's scope is set once at creation, and grove builds +// SET from every model field by default, so without this whitelist an +// update issued from a broader (but still matching) context would widen +// the row's stored scope. +var mutableA2AConversationColumns = []string{ + "protocol", + "participants", + "status", + "hop_ceiling", + "hops_used", + "deadline", + "updated_at", +} + +// mutableA2ADeliveryColumns mirrors the above for cortex_a2a_deliveries. +// message_id and receiver are absent as well as scope: a delivery never +// changes who it is for, only how far along it is. +var mutableA2ADeliveryColumns = []string{ + "state", + "error", + "delivered_at", + "read_at", + "run_id", + "updated_at", +} + +// ────────────────────────────────────────────────── +// Messages +// ────────────────────────────────────────────────── + +// CreateMessage persists an envelope, stamping the scope from the context. +// Envelopes are immutable once written, so there is no update counterpart. +func (s *Store) CreateMessage(ctx context.Context, e *a2a.Envelope) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + now := time.Now().UTC() + e.CreatedAt = now + e.UpdatedAt = now + e.Scope = scope + if _, err := s.sdb.NewInsert(a2aMessageToModel(e)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex/sqlite: create a2a message: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex/sqlite: create a2a message: %w", err) + } + return nil +} + +func (s *Store) GetMessage(ctx context.Context, msgID id.MessageID) (*a2a.Envelope, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + m := new(a2aMessageModel) + q := s.sdb.NewSelect(m).Where("id = ?", msgID.String()) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + if isNoRows(err) { + return nil, a2a.ErrMessageNotFound + } + return nil, fmt.Errorf("cortex/sqlite: get a2a message: %w", err) + } + return a2aMessageFromModel(m) +} + +// ListMessages returns a conversation's messages oldest first, because a +// conversation is read as a transcript. +func (s *Store) ListMessages(ctx context.Context, filter *a2a.MessageListFilter) ([]*a2a.Envelope, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aMessageModel + q := s.sdb.NewSelect(&models).OrderExpr("created_at ASC, id ASC") + exact := filter != nil && filter.Exact + for _, p := range scopePredicates(scope, exact) { + q = q.Where(p.Column+" = ?", p.Value) + } + if filter != nil { + if !filter.ConversationID.IsNil() { + q = q.Where("conversation_id = ?", filter.ConversationID.String()) + } + if filter.Limit > 0 { + q = q.Limit(filter.Limit) + } + if filter.Offset > 0 { + q = q.Offset(filter.Offset) + } + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/sqlite: list a2a messages: %w", err) + } + out := make([]*a2a.Envelope, len(models)) + for i := range models { + e, convErr := a2aMessageFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = e + } + return out, nil +} + +// ────────────────────────────────────────────────── +// Conversations +// ────────────────────────────────────────────────── + +func (s *Store) CreateConversation(ctx context.Context, c *a2a.Conversation) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + now := time.Now().UTC() + c.CreatedAt = now + c.UpdatedAt = now + c.Scope = scope + if _, err := s.sdb.NewInsert(a2aConversationToModel(c)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex/sqlite: create a2a conversation: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex/sqlite: create a2a conversation: %w", err) + } + return nil +} + +func (s *Store) GetConversation(ctx context.Context, convID id.ConversationID) (*a2a.Conversation, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + m := new(a2aConversationModel) + q := s.sdb.NewSelect(m).Where("id = ?", convID.String()) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + if isNoRows(err) { + return nil, a2a.ErrConversationNotFound + } + return nil, fmt.Errorf("cortex/sqlite: get a2a conversation: %w", err) + } + return a2aConversationFromModel(m) +} + +func (s *Store) UpdateConversation(ctx context.Context, c *a2a.Conversation) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + c.UpdatedAt = time.Now().UTC() + q := s.sdb.NewUpdate(a2aConversationToModel(c)). + Column(mutableA2AConversationColumns...). + Where("id = ?", c.ID.String()) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return fmt.Errorf("cortex/sqlite: update a2a conversation: %w", err) + } + n, rowsErr := res.RowsAffected() + if rowsErr != nil { + return fmt.Errorf("cortex/sqlite: update a2a conversation rows affected: %w", rowsErr) + } + if n == 0 { + return a2a.ErrConversationNotFound + } + return nil +} + +func (s *Store) ListConversations(ctx context.Context, filter *a2a.ConversationListFilter) ([]*a2a.Conversation, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aConversationModel + q := s.sdb.NewSelect(&models).OrderExpr("created_at DESC, id DESC") + exact := filter != nil && filter.Exact + for _, p := range scopePredicates(scope, exact) { + q = q.Where(p.Column+" = ?", p.Value) + } + if filter != nil { + if filter.Status != "" { + q = q.Where("status = ?", filter.Status) + } + if filter.Limit > 0 { + q = q.Limit(filter.Limit) + } + if filter.Offset > 0 { + q = q.Offset(filter.Offset) + } + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/sqlite: list a2a conversations: %w", err) + } + out := make([]*a2a.Conversation, len(models)) + for i := range models { + c, convErr := a2aConversationFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = c + } + return out, nil +} + +// ────────────────────────────────────────────────── +// Deliveries +// ────────────────────────────────────────────────── + +func (s *Store) CreateDelivery(ctx context.Context, d *a2a.Delivery) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + now := time.Now().UTC() + d.CreatedAt = now + d.UpdatedAt = now + d.Scope = scope + if _, err := s.sdb.NewInsert(a2aDeliveryToModel(d)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex/sqlite: create a2a delivery: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex/sqlite: create a2a delivery: %w", err) + } + return nil +} + +func (s *Store) UpdateDelivery(ctx context.Context, d *a2a.Delivery) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + d.UpdatedAt = time.Now().UTC() + q := s.sdb.NewUpdate(a2aDeliveryToModel(d)). + Column(mutableA2ADeliveryColumns...). + Where("id = ?", d.ID.String()) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return fmt.Errorf("cortex/sqlite: update a2a delivery: %w", err) + } + n, rowsErr := res.RowsAffected() + if rowsErr != nil { + return fmt.Errorf("cortex/sqlite: update a2a delivery rows affected: %w", rowsErr) + } + if n == 0 { + return a2a.ErrDeliveryNotFound + } + return nil +} + +// ClaimDelivery takes a queued delivery and marks it delivering. The +// state = 'queued' predicate is the claim: two workers racing both issue +// this UPDATE and exactly one of them changes a row, which is what stops +// one directive starting two runs. +func (s *Store) ClaimDelivery(ctx context.Context, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + now := time.Now().UTC() + q := s.sdb.NewUpdate((*a2aDeliveryModel)(nil)). + Set("state = ?", a2a.DeliveryDelivering). + Set("claimed_at = ?", now). + Set("updated_at = ?", now). + Where("id = ?", deliveryID.String()). + Where("state = ?", a2a.DeliveryQueued) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return nil, fmt.Errorf("cortex/sqlite: claim a2a delivery: %w", err) + } + n, rowsErr := res.RowsAffected() + if rowsErr != nil { + return nil, fmt.Errorf("cortex/sqlite: claim a2a delivery rows affected: %w", rowsErr) + } + if n == 0 { + // Nothing changed for one of two reasons and the caller has to + // tell them apart: losing a race is ordinary, and a delivery id + // nobody minted is a bug further up. + exists, existsErr := s.deliveryExists(ctx, scope, deliveryID) + if existsErr != nil { + return nil, existsErr + } + if exists { + return nil, a2a.ErrDeliveryAlreadyClaimed + } + return nil, a2a.ErrDeliveryNotFound + } + return s.getDelivery(ctx, scope, deliveryID) +} + +// ListInbox returns messages that have ARRIVED for an agent. A queued +// delivery is deliberately excluded: it has not reached anyone yet, and an +// inbox that showed it would be showing mail that is still in transit. +func (s *Store) ListInbox(ctx context.Context, agentName string, filter a2a.InboxFilter) ([]*a2a.Delivery, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aDeliveryModel + q := s.sdb.NewSelect(&models). + Where("receiver_agent = ?", agentName). + Where("state = ?", a2a.DeliveryDelivered). + OrderExpr("created_at ASC, id ASC") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if filter.UnreadOnly { + q = q.Where("read_at IS NULL") + } + if !filter.ConversationID.IsNil() { + q = q.Where("message_id IN (SELECT id FROM cortex_a2a_messages WHERE conversation_id = ?)", filter.ConversationID.String()) + } + if filter.Limit > 0 { + q = q.Limit(filter.Limit) + } + if filter.Offset > 0 { + q = q.Offset(filter.Offset) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/sqlite: list a2a inbox: %w", err) + } + out := make([]*a2a.Delivery, len(models)) + for i := range models { + d, convErr := a2aDeliveryFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = d + } + return out, nil +} + +// ListQueuedDeliveries deliberately does NOT filter by scope. +// +// It is the dispatcher's read, and the dispatcher runs per process rather +// than per tenant: a delivery queued under one scope has to be carried +// even when nobody from that scope is currently calling in. Every row it +// returns carries its own scope, and deliverOne puts that scope back on +// the context before touching anything, so the isolation the rest of this +// file enforces is preserved by the caller instead of by the query. This +// mirrors the sweeper's cross-scope read of expired suspensions. +func (s *Store) ListQueuedDeliveries(ctx context.Context, limit int) ([]*a2a.Delivery, error) { + var models []a2aDeliveryModel + q := s.sdb.NewSelect(&models). + Where("state = ?", a2a.DeliveryQueued). + OrderExpr("created_at ASC, id ASC") + if limit > 0 { + q = q.Limit(limit) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/sqlite: list queued a2a deliveries: %w", err) + } + out := make([]*a2a.Delivery, len(models)) + for i := range models { + d, convErr := a2aDeliveryFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = d + } + return out, nil +} + +func (s *Store) MarkDeliveryRead(ctx context.Context, deliveryID id.DeliveryID) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + now := time.Now().UTC() + q := s.sdb.NewUpdate((*a2aDeliveryModel)(nil)). + Set("read_at = ?", now). + Set("updated_at = ?", now). + Where("id = ?", deliveryID.String()). + Where("read_at IS NULL") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return fmt.Errorf("cortex/sqlite: mark a2a delivery read: %w", err) + } + n, rowsErr := res.RowsAffected() + if rowsErr != nil { + return fmt.Errorf("cortex/sqlite: mark a2a delivery read rows affected: %w", rowsErr) + } + if n == 0 { + // Already read is not an error. Two readers draining one inbox is + // ordinary, and the second one has nothing to report. + exists, existsErr := s.deliveryExists(ctx, scope, deliveryID) + if existsErr != nil { + return existsErr + } + if !exists { + return a2a.ErrDeliveryNotFound + } + } + return nil +} + +// ────────────────────────────────────────────────── +// Pending asks +// ────────────────────────────────────────────────── + +func (s *Store) CreatePendingAsk(ctx context.Context, a *a2a.PendingAsk) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + now := time.Now().UTC() + a.CreatedAt = now + a.UpdatedAt = now + a.Scope = scope + if _, err := s.sdb.NewInsert(a2aPendingAskToModel(a)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex/sqlite: create a2a pending ask: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex/sqlite: create a2a pending ask: %w", err) + } + return nil +} + +// ClaimPendingAsk takes the ask carrying replyWith, but only if nobody has +// yet. The claimed_at IS NULL predicate is the whole guarantee: a late +// reply, the deadline sweep and a cancel can all reach for one row, and +// exactly one of them changes it and goes on to resume the waiting run. +func (s *Store) ClaimPendingAsk(ctx context.Context, replyWith string) (*a2a.PendingAsk, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + now := time.Now().UTC() + q := s.sdb.NewUpdate((*a2aPendingAskModel)(nil)). + Set("claimed_at = ?", now). + Set("updated_at = ?", now). + Where("reply_with = ?", replyWith). + Where("claimed_at IS NULL") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return nil, fmt.Errorf("cortex/sqlite: claim a2a pending ask: %w", err) + } + n, rowsErr := res.RowsAffected() + if rowsErr != nil { + return nil, fmt.Errorf("cortex/sqlite: claim a2a pending ask rows affected: %w", rowsErr) + } + if n == 0 { + exists, existsErr := s.pendingAskExists(ctx, scope, replyWith) + if existsErr != nil { + return nil, existsErr + } + if exists { + return nil, a2a.ErrAskAlreadyClaimed + } + return nil, a2a.ErrAskNotFound + } + return s.getPendingAsk(ctx, scope, replyWith) +} + +// ListExpiredAsks returns unclaimed asks past their deadline. Claimed rows +// are excluded, which is what makes sweeping the same backlog twice safe. +func (s *Store) ListExpiredAsks(ctx context.Context, now time.Time, limit int) ([]*a2a.PendingAsk, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aPendingAskModel + q := s.sdb.NewSelect(&models). + Where("claimed_at IS NULL"). + Where("deadline IS NOT NULL"). + Where("deadline <= ?", now.UTC()). + OrderExpr("deadline ASC") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if limit > 0 { + q = q.Limit(limit) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/sqlite: list expired a2a asks: %w", err) + } + return a2aPendingAsksFromModels(models) +} + +func (s *Store) ListPendingAsksByConversation(ctx context.Context, convID id.ConversationID) ([]*a2a.PendingAsk, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aPendingAskModel + q := s.sdb.NewSelect(&models). + Where("conversation_id = ?", convID.String()). + Where("claimed_at IS NULL"). + OrderExpr("created_at ASC") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/sqlite: list a2a asks by conversation: %w", err) + } + return a2aPendingAsksFromModels(models) +} + +// ────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────── + +func a2aPendingAsksFromModels(models []a2aPendingAskModel) ([]*a2a.PendingAsk, error) { + out := make([]*a2a.PendingAsk, len(models)) + for i := range models { + a, convErr := a2aPendingAskFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = a + } + return out, nil +} + +func (s *Store) getDelivery(ctx context.Context, scope cortex.Scope, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + m := new(a2aDeliveryModel) + q := s.sdb.NewSelect(m).Where("id = ?", deliveryID.String()) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + if isNoRows(err) { + return nil, a2a.ErrDeliveryNotFound + } + return nil, fmt.Errorf("cortex/sqlite: get a2a delivery: %w", err) + } + return a2aDeliveryFromModel(m) +} + +func (s *Store) deliveryExists(ctx context.Context, scope cortex.Scope, deliveryID id.DeliveryID) (bool, error) { + _, err := s.getDelivery(ctx, scope, deliveryID) + switch { + case err == nil: + return true, nil + case isNotFound(err, a2a.ErrDeliveryNotFound): + return false, nil + default: + return false, err + } +} + +func (s *Store) getPendingAsk(ctx context.Context, scope cortex.Scope, replyWith string) (*a2a.PendingAsk, error) { + m := new(a2aPendingAskModel) + q := s.sdb.NewSelect(m).Where("reply_with = ?", replyWith) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + if isNoRows(err) { + return nil, a2a.ErrAskNotFound + } + return nil, fmt.Errorf("cortex/sqlite: get a2a pending ask: %w", err) + } + return a2aPendingAskFromModel(m) +} + +func (s *Store) pendingAskExists(ctx context.Context, scope cortex.Scope, replyWith string) (bool, error) { + _, err := s.getPendingAsk(ctx, scope, replyWith) + switch { + case err == nil: + return true, nil + case isNotFound(err, a2a.ErrAskNotFound): + return false, nil + default: + return false, err + } +} diff --git a/store/sqlite/migrations.go b/store/sqlite/migrations.go index efb291b..0f34c38 100644 --- a/store/sqlite/migrations.go +++ b/store/sqlite/migrations.go @@ -1214,6 +1214,129 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_cortex_overlays_agent_scope return err }, }, + &migrate.Migration{ + Name: "create_a2a", + Version: "20260826000003", + Comment: "Create the four cortex_a2a_* tables: messages, conversations, deliveries and pending asks", + Up: func(ctx context.Context, exec migrate.Executor) error { + // Scope columns are here from the start, unlike the older + // tables that had to have them added later: nothing has + // ever written an unscoped a2a row, so there is no + // backfill to do and no scope migration to follow. + // + // The unique index on pending asks is load-bearing rather + // than hygiene. ClaimPendingAsk resolves exactly one + // waiting run, and two rows sharing a reply-with token + // would let a reply resume a run that never asked. + _, err := exec.Exec(ctx, ` +CREATE TABLE IF NOT EXISTS cortex_a2a_messages ( + id TEXT PRIMARY KEY, + performative TEXT NOT NULL, + sender_agent TEXT NOT NULL, + sender_node TEXT NOT NULL DEFAULT '', + receivers TEXT NOT NULL DEFAULT '[]', + reply_to TEXT NOT NULL DEFAULT '[]', + content TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT '', + encoding TEXT NOT NULL DEFAULT '', + ontology TEXT NOT NULL DEFAULT '', + protocol TEXT NOT NULL DEFAULT '', + conversation_id TEXT NOT NULL DEFAULT '', + reply_with TEXT NOT NULL DEFAULT '', + in_reply_to TEXT NOT NULL DEFAULT '', + reply_by TEXT, + hops INTEGER NOT NULL DEFAULT 0, + origin_run_id TEXT NOT NULL DEFAULT '', + metadata TEXT NOT NULL DEFAULT '{}', + scope_l0 TEXT NOT NULL DEFAULT '', + scope_l1 TEXT NOT NULL DEFAULT '', + scope_l2 TEXT NOT NULL DEFAULT '', + scope_extra TEXT NOT NULL DEFAULT '{}', + scope_canon TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_messages_scope_conv ON cortex_a2a_messages (scope_canon, conversation_id); + +CREATE TABLE IF NOT EXISTS cortex_a2a_conversations ( + id TEXT PRIMARY KEY, + protocol TEXT NOT NULL DEFAULT '', + initiator_agent TEXT NOT NULL DEFAULT '', + initiator_node TEXT NOT NULL DEFAULT '', + participants TEXT NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'open', + hop_ceiling INTEGER NOT NULL DEFAULT 0, + hops_used INTEGER NOT NULL DEFAULT 0, + deadline TEXT, + scope_l0 TEXT NOT NULL DEFAULT '', + scope_l1 TEXT NOT NULL DEFAULT '', + scope_l2 TEXT NOT NULL DEFAULT '', + scope_extra TEXT NOT NULL DEFAULT '{}', + scope_canon TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_conversations_scope_status ON cortex_a2a_conversations (scope_canon, status); + +CREATE TABLE IF NOT EXISTS cortex_a2a_deliveries ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + receiver_agent TEXT NOT NULL, + receiver_node TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'queued', + error TEXT NOT NULL DEFAULT '', + claimed_at TEXT, + delivered_at TEXT, + read_at TEXT, + run_id TEXT NOT NULL DEFAULT '', + scope_l0 TEXT NOT NULL DEFAULT '', + scope_l1 TEXT NOT NULL DEFAULT '', + scope_l2 TEXT NOT NULL DEFAULT '', + scope_extra TEXT NOT NULL DEFAULT '{}', + scope_canon TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_deliveries_inbox ON cortex_a2a_deliveries (scope_canon, receiver_agent, state); +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_deliveries_state ON cortex_a2a_deliveries (state); + +CREATE TABLE IF NOT EXISTS cortex_a2a_pending_asks ( + reply_with TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL DEFAULT '', + message_id TEXT NOT NULL DEFAULT '', + asker_run_id TEXT NOT NULL DEFAULT '', + asker_agent TEXT NOT NULL DEFAULT '', + tool_call_id TEXT NOT NULL DEFAULT '', + expected_agent TEXT NOT NULL DEFAULT '', + expected_node TEXT NOT NULL DEFAULT '', + deadline TEXT, + claimed_at TEXT, + scope_l0 TEXT NOT NULL DEFAULT '', + scope_l1 TEXT NOT NULL DEFAULT '', + scope_l2 TEXT NOT NULL DEFAULT '', + scope_extra TEXT NOT NULL DEFAULT '{}', + scope_canon TEXT NOT NULL DEFAULT '', + created_at TEXT NOT NULL DEFAULT (datetime('now')), + updated_at TEXT NOT NULL DEFAULT (datetime('now')) +); + +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_pending_asks_deadline ON cortex_a2a_pending_asks (claimed_at, deadline); +`) + return err + }, + Down: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, ` +DROP TABLE IF EXISTS cortex_a2a_pending_asks; +DROP TABLE IF EXISTS cortex_a2a_deliveries; +DROP TABLE IF EXISTS cortex_a2a_conversations; +DROP TABLE IF EXISTS cortex_a2a_messages; +`) + return err + }, + }, ) } diff --git a/store/sqlite/models.go b/store/sqlite/models.go index aec6070..8faf69d 100644 --- a/store/sqlite/models.go +++ b/store/sqlite/models.go @@ -8,6 +8,7 @@ import ( "github.com/xraph/grove" "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" "github.com/xraph/cortex/agent" "github.com/xraph/cortex/behavior" "github.com/xraph/cortex/checkpoint" @@ -1333,3 +1334,350 @@ func stringsOrEmpty(v []string) []string { } return v } + +// ────────────────────────────────────────────────── +// a2a models +// ────────────────────────────────────────────────── + +type a2aMessageModel struct { + grove.BaseModel `grove:"table:cortex_a2a_messages"` + ID string `grove:"id,pk"` + Performative string `grove:"performative,notnull"` + SenderAgent string `grove:"sender_agent,notnull"` + SenderNode string `grove:"sender_node,notnull"` + Receivers string `grove:"receivers,notnull"` + ReplyTo string `grove:"reply_to,notnull"` + Content string `grove:"content,notnull"` + Language string `grove:"language,notnull"` + Encoding string `grove:"encoding,notnull"` + Ontology string `grove:"ontology,notnull"` + Protocol string `grove:"protocol,notnull"` + ConversationID string `grove:"conversation_id,notnull"` + ReplyWith string `grove:"reply_with,notnull"` + InReplyTo string `grove:"in_reply_to,notnull"` + ReplyBy *time.Time `grove:"reply_by"` + Hops int `grove:"hops,notnull"` + OriginRunID string `grove:"origin_run_id,notnull"` + Metadata string `grove:"metadata,notnull"` + ScopeL0 string `grove:"scope_l0,notnull"` + ScopeL1 string `grove:"scope_l1,notnull"` + ScopeL2 string `grove:"scope_l2,notnull"` + ScopeExtra string `grove:"scope_extra,notnull"` + ScopeCanon string `grove:"scope_canon,notnull"` + CreatedAt time.Time `grove:"created_at"` + UpdatedAt time.Time `grove:"updated_at"` +} + +func a2aMessageToModel(e *a2a.Envelope) *a2aMessageModel { + l0, l1, l2, extra := scopeColumns(e.Scope) + return &a2aMessageModel{ + ID: e.ID.String(), + Performative: string(e.Performative), + SenderAgent: e.Sender.Agent, + SenderNode: e.Sender.Node, + Receivers: mustJSON(e.Receivers), + ReplyTo: mustJSON(e.ReplyTo), + Content: e.Content, + Language: e.Language, + Encoding: e.Encoding, + Ontology: e.Ontology, + Protocol: e.Protocol, + ConversationID: e.ConversationID.String(), + ReplyWith: e.ReplyWith, + InReplyTo: e.InReplyTo, + ReplyBy: e.ReplyBy, + Hops: e.Hops, + OriginRunID: e.OriginRunID.String(), + Metadata: mustJSON(e.Metadata), + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: e.Scope.Canonical(), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func a2aMessageFromModel(m *a2aMessageModel) (*a2a.Envelope, error) { + msgID, err := id.ParseWithPrefix(m.ID, id.PrefixMessage) + if err != nil { + return nil, err + } + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a message %s: %w", msgID, err) + } + e := &a2a.Envelope{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: msgID, + Scope: scope, + Performative: a2a.Performative(m.Performative), + Sender: a2a.Address{Agent: m.SenderAgent, Node: m.SenderNode}, + Content: m.Content, + Language: m.Language, + Encoding: m.Encoding, + Ontology: m.Ontology, + Protocol: m.Protocol, + ReplyWith: m.ReplyWith, + InReplyTo: m.InReplyTo, + ReplyBy: m.ReplyBy, + Hops: m.Hops, + } + if m.ConversationID != "" { + convID, convErr := id.ParseWithPrefix(m.ConversationID, id.PrefixConversation) + if convErr != nil { + return nil, fmt.Errorf("a2a message %s: conversation id: %w", msgID, convErr) + } + e.ConversationID = convID + } + if m.OriginRunID != "" { + runID, runErr := id.ParseWithPrefix(m.OriginRunID, id.PrefixAgentRun) + if runErr != nil { + return nil, fmt.Errorf("a2a message %s: origin run id: %w", msgID, runErr) + } + e.OriginRunID = runID + } + for _, f := range []struct { + name string + data string + dest any + }{ + {"receivers", m.Receivers, &e.Receivers}, + {"reply_to", m.ReplyTo, &e.ReplyTo}, + {"metadata", m.Metadata, &e.Metadata}, + } { + if err := unmarshalField(f.name, f.data, f.dest); err != nil { + return nil, err + } + } + return e, nil +} + +type a2aConversationModel struct { + grove.BaseModel `grove:"table:cortex_a2a_conversations"` + ID string `grove:"id,pk"` + Protocol string `grove:"protocol,notnull"` + InitiatorAgent string `grove:"initiator_agent,notnull"` + InitiatorNode string `grove:"initiator_node,notnull"` + Participants string `grove:"participants,notnull"` + Status string `grove:"status,notnull"` + HopCeiling int `grove:"hop_ceiling,notnull"` + HopsUsed int `grove:"hops_used,notnull"` + Deadline *time.Time `grove:"deadline"` + ScopeL0 string `grove:"scope_l0,notnull"` + ScopeL1 string `grove:"scope_l1,notnull"` + ScopeL2 string `grove:"scope_l2,notnull"` + ScopeExtra string `grove:"scope_extra,notnull"` + ScopeCanon string `grove:"scope_canon,notnull"` + CreatedAt time.Time `grove:"created_at"` + UpdatedAt time.Time `grove:"updated_at"` +} + +func a2aConversationToModel(c *a2a.Conversation) *a2aConversationModel { + l0, l1, l2, extra := scopeColumns(c.Scope) + return &a2aConversationModel{ + ID: c.ID.String(), + Protocol: c.Protocol, + InitiatorAgent: c.Initiator.Agent, + InitiatorNode: c.Initiator.Node, + Participants: mustJSON(c.Participants), + Status: c.Status, + HopCeiling: c.HopCeiling, + HopsUsed: c.HopsUsed, + Deadline: c.Deadline, + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: c.Scope.Canonical(), + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func a2aConversationFromModel(m *a2aConversationModel) (*a2a.Conversation, error) { + convID, err := id.ParseWithPrefix(m.ID, id.PrefixConversation) + if err != nil { + return nil, err + } + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a conversation %s: %w", convID, err) + } + c := &a2a.Conversation{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: convID, + Scope: scope, + Protocol: m.Protocol, + Initiator: a2a.Address{Agent: m.InitiatorAgent, Node: m.InitiatorNode}, + Status: m.Status, + HopCeiling: m.HopCeiling, + HopsUsed: m.HopsUsed, + Deadline: m.Deadline, + } + if err := unmarshalField("participants", m.Participants, &c.Participants); err != nil { + return nil, err + } + return c, nil +} + +type a2aDeliveryModel struct { + grove.BaseModel `grove:"table:cortex_a2a_deliveries"` + ID string `grove:"id,pk"` + MessageID string `grove:"message_id,notnull"` + ReceiverAgent string `grove:"receiver_agent,notnull"` + ReceiverNode string `grove:"receiver_node,notnull"` + State string `grove:"state,notnull"` + Error string `grove:"error,notnull"` + ClaimedAt *time.Time `grove:"claimed_at"` + DeliveredAt *time.Time `grove:"delivered_at"` + ReadAt *time.Time `grove:"read_at"` + RunID string `grove:"run_id,notnull"` + ScopeL0 string `grove:"scope_l0,notnull"` + ScopeL1 string `grove:"scope_l1,notnull"` + ScopeL2 string `grove:"scope_l2,notnull"` + ScopeExtra string `grove:"scope_extra,notnull"` + ScopeCanon string `grove:"scope_canon,notnull"` + CreatedAt time.Time `grove:"created_at"` + UpdatedAt time.Time `grove:"updated_at"` +} + +func a2aDeliveryToModel(d *a2a.Delivery) *a2aDeliveryModel { + l0, l1, l2, extra := scopeColumns(d.Scope) + return &a2aDeliveryModel{ + ID: d.ID.String(), + MessageID: d.MessageID.String(), + ReceiverAgent: d.Receiver.Agent, + ReceiverNode: d.Receiver.Node, + State: d.State, + Error: d.Error, + DeliveredAt: d.DeliveredAt, + ReadAt: d.ReadAt, + RunID: d.RunID.String(), + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: d.Scope.Canonical(), + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } +} + +func a2aDeliveryFromModel(m *a2aDeliveryModel) (*a2a.Delivery, error) { + dlvID, err := id.ParseWithPrefix(m.ID, id.PrefixDelivery) + if err != nil { + return nil, err + } + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a delivery %s: %w", dlvID, err) + } + msgID, err := id.ParseWithPrefix(m.MessageID, id.PrefixMessage) + if err != nil { + return nil, fmt.Errorf("a2a delivery %s: message id: %w", dlvID, err) + } + d := &a2a.Delivery{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: dlvID, + Scope: scope, + MessageID: msgID, + Receiver: a2a.Address{Agent: m.ReceiverAgent, Node: m.ReceiverNode}, + State: m.State, + Error: m.Error, + DeliveredAt: m.DeliveredAt, + ReadAt: m.ReadAt, + } + if m.RunID != "" { + runID, runErr := id.ParseWithPrefix(m.RunID, id.PrefixAgentRun) + if runErr != nil { + return nil, fmt.Errorf("a2a delivery %s: run id: %w", dlvID, runErr) + } + d.RunID = runID + } + return d, nil +} + +type a2aPendingAskModel struct { + grove.BaseModel `grove:"table:cortex_a2a_pending_asks"` + ReplyWith string `grove:"reply_with,pk"` + ConversationID string `grove:"conversation_id,notnull"` + MessageID string `grove:"message_id,notnull"` + AskerRunID string `grove:"asker_run_id,notnull"` + AskerAgent string `grove:"asker_agent,notnull"` + ToolCallID string `grove:"tool_call_id,notnull"` + ExpectedAgent string `grove:"expected_agent,notnull"` + ExpectedNode string `grove:"expected_node,notnull"` + Deadline *time.Time `grove:"deadline"` + ClaimedAt *time.Time `grove:"claimed_at"` + ScopeL0 string `grove:"scope_l0,notnull"` + ScopeL1 string `grove:"scope_l1,notnull"` + ScopeL2 string `grove:"scope_l2,notnull"` + ScopeExtra string `grove:"scope_extra,notnull"` + ScopeCanon string `grove:"scope_canon,notnull"` + CreatedAt time.Time `grove:"created_at"` + UpdatedAt time.Time `grove:"updated_at"` +} + +func a2aPendingAskToModel(a *a2a.PendingAsk) *a2aPendingAskModel { + l0, l1, l2, extra := scopeColumns(a.Scope) + return &a2aPendingAskModel{ + ReplyWith: a.ReplyWith, + ConversationID: a.ConversationID.String(), + MessageID: a.MessageID.String(), + AskerRunID: a.AskerRunID.String(), + AskerAgent: a.AskerAgent, + ToolCallID: a.ToolCallID, + ExpectedAgent: a.Expected.Agent, + ExpectedNode: a.Expected.Node, + Deadline: a.Deadline, + ClaimedAt: a.ClaimedAt, + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: a.Scope.Canonical(), + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + } +} + +func a2aPendingAskFromModel(m *a2aPendingAskModel) (*a2a.PendingAsk, error) { + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a pending ask %s: %w", m.ReplyWith, err) + } + a := &a2a.PendingAsk{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + Scope: scope, + ReplyWith: m.ReplyWith, + AskerAgent: m.AskerAgent, + ToolCallID: m.ToolCallID, + Expected: a2a.Address{Agent: m.ExpectedAgent, Node: m.ExpectedNode}, + Deadline: m.Deadline, + ClaimedAt: m.ClaimedAt, + } + if m.ConversationID != "" { + convID, convErr := id.ParseWithPrefix(m.ConversationID, id.PrefixConversation) + if convErr != nil { + return nil, fmt.Errorf("a2a pending ask %s: conversation id: %w", m.ReplyWith, convErr) + } + a.ConversationID = convID + } + if m.MessageID != "" { + msgID, msgErr := id.ParseWithPrefix(m.MessageID, id.PrefixMessage) + if msgErr != nil { + return nil, fmt.Errorf("a2a pending ask %s: message id: %w", m.ReplyWith, msgErr) + } + a.MessageID = msgID + } + if m.AskerRunID != "" { + runID, runErr := id.ParseWithPrefix(m.AskerRunID, id.PrefixAgentRun) + if runErr != nil { + return nil, fmt.Errorf("a2a pending ask %s: asker run id: %w", m.ReplyWith, runErr) + } + a.AskerRunID = runID + } + return a, nil +} diff --git a/store/sqlite/rescope.go b/store/sqlite/rescope.go index 4fc3d82..d189e1f 100644 --- a/store/sqlite/rescope.go +++ b/store/sqlite/rescope.go @@ -150,6 +150,16 @@ func (s *Store) discoverScopedTables(ctx context.Context) (map[string]tableShape if !cols["scope_canon"] { continue } + // A scoped table with no id column is one this pass cannot key a + // row by, and it is also one that never needs to: the tables that + // predate scope all carry a TypeID id, while a table keyed by + // something else (cortex_a2a_pending_asks, keyed by its reply-with + // token) was born with its scope columns and has no legacy rows to + // backfill. Skipping is therefore not a gap, and scanning it would + // only produce "no such column: id". + if !cols["id"] { + continue + } shapes[table] = tableShape{ hasName: cols["name"], hasAppID: cols["app_id"], diff --git a/store/sqlite/store.go b/store/sqlite/store.go index 8b86678..a2c591b 100644 --- a/store/sqlite/store.go +++ b/store/sqlite/store.go @@ -106,3 +106,8 @@ func isUniqueViolation(err error) bool { // Fallback in case the typed error is not surfaced by the driver. return strings.Contains(err.Error(), "UNIQUE constraint failed") } + +// isNotFound reports whether err is the given not-found sentinel. It reads +// better at the call sites in a2a.go than errors.Is inline, and it keeps +// the import out of a file that otherwise has no use for it. +func isNotFound(err, sentinel error) bool { return errors.Is(err, sentinel) } diff --git a/store/store.go b/store/store.go index 38e4df6..70d0315 100644 --- a/store/store.go +++ b/store/store.go @@ -5,6 +5,7 @@ import ( "context" "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" "github.com/xraph/cortex/agent" "github.com/xraph/cortex/behavior" "github.com/xraph/cortex/checkpoint" @@ -34,6 +35,7 @@ type Store interface { suspension.Store orchestration.ConfigStore orchestration.RunStore + a2a.Store Migrate(ctx context.Context, opts ...cortex.MigrateOption) error Ping(ctx context.Context) error diff --git a/store/storetest/conformance.go b/store/storetest/conformance.go index cdc5b09..0c40e58 100644 --- a/store/storetest/conformance.go +++ b/store/storetest/conformance.go @@ -15,6 +15,7 @@ import ( "time" "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" "github.com/xraph/cortex/agent" "github.com/xraph/cortex/behavior" "github.com/xraph/cortex/checkpoint" @@ -85,6 +86,18 @@ func Conformance(t *testing.T, newStore func(t *testing.T) store.Store) { // is exempt from the rule they enforce, so it needs a group that // says so on purpose. t.Run("SweeperReads", func(t *testing.T) { testSweeperReads(t, newStore) }) + + // The a2a groups below prove what the package's in-memory double + // cannot. The claim is a conditional UPDATE on sqlite and postgres and + // a FindOneAndUpdate on mongo, and the ledger's exactly-once + // guarantee rests entirely on it, so it is raced here against each + // real backend. The inbox and redrive reads are the other two: one + // filters by scope, and the other deliberately does not. + t.Run("A2ARoundTrip", func(t *testing.T) { testA2ARoundTrip(t, newStore) }) + t.Run("A2AClaimPendingAskConcurrency", func(t *testing.T) { testA2AClaimPendingAskConcurrency(t, newStore) }) + t.Run("A2AClaimDeliveryConcurrency", func(t *testing.T) { testA2AClaimDeliveryConcurrency(t, newStore) }) + t.Run("A2AExpiredAsks", func(t *testing.T) { testA2AExpiredAsks(t, newStore) }) + t.Run("A2AScopeIsolation", func(t *testing.T) { testA2AScopeIsolation(t, newStore) }) } // ────────────────────────────────────────────────── @@ -4071,3 +4084,348 @@ func testAgentSections(t *testing.T, newStore func(t *testing.T) store.Store) { } }) } + +// ────────────────────────────────────────────────── +// a2a messaging +// ────────────────────────────────────────────────── + +// newA2AEnvelope builds a valid envelope on the given conversation. +func newA2AEnvelope(convID id.ConversationID, sender, receiver string) *a2a.Envelope { + return &a2a.Envelope{ + Entity: cortex.NewEntity(), + ID: id.NewMessageID(), + Performative: a2a.Request, + Sender: a2a.Address{Agent: sender}, + Receivers: []a2a.Address{{Agent: receiver}}, + Content: "do the thing", + ConversationID: convID, + ReplyWith: "rw-" + receiver, + Hops: 1, + } +} + +func newA2AConversation(initiator string) *a2a.Conversation { + return &a2a.Conversation{ + Entity: cortex.NewEntity(), + ID: id.NewConversationID(), + Initiator: a2a.Address{Agent: initiator}, + Status: a2a.StatusOpen, + HopCeiling: 8, + } +} + +func testA2ARoundTrip(t *testing.T, newStore func(t *testing.T) store.Store) { + s := newStore(t) + ctx := ctxWithScope("acme") + + conv := newA2AConversation("planner") + if err := s.CreateConversation(ctx, conv); err != nil { + t.Fatalf("CreateConversation: %v", err) + } + e := newA2AEnvelope(conv.ID, "planner", "worker") + e.Ontology = "ops" + e.Protocol = "fipa-request" + e.Metadata = map[string]any{"trace": "abc"} + if err := s.CreateMessage(ctx, e); err != nil { + t.Fatalf("CreateMessage: %v", err) + } + + got, err := s.GetMessage(ctx, e.ID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if got.Performative != a2a.Request || got.Content != "do the thing" { + t.Fatalf("envelope lost its ACL fields: %+v", got) + } + if got.Ontology != "ops" || got.Protocol != "fipa-request" || got.ReplyWith != "rw-worker" { + t.Fatalf("envelope lost its correlation fields: %+v", got) + } + if len(got.Receivers) != 1 || got.Receivers[0].Agent != "worker" { + t.Fatalf("receivers did not round trip: %+v", got.Receivers) + } + if got.ConversationID != conv.ID || got.Hops != 1 { + t.Fatalf("conversation link did not round trip: %+v", got) + } + + // The conversation's hop counter is the containment budget, so it has + // to survive an update. + conv.HopsUsed = 3 + conv.AddParticipant(a2a.Address{Agent: "worker"}) + if updErr := s.UpdateConversation(ctx, conv); updErr != nil { + t.Fatalf("UpdateConversation: %v", updErr) + } + gotConv, err := s.GetConversation(ctx, conv.ID) + if err != nil { + t.Fatalf("GetConversation: %v", err) + } + if gotConv.HopsUsed != 3 || len(gotConv.Participants) != 1 { + t.Fatalf("conversation update lost fields: %+v", gotConv) + } + + msgs, err := s.ListMessages(ctx, &a2a.MessageListFilter{ConversationID: conv.ID, Limit: 10}) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("ListMessages returned %d, want 1", len(msgs)) + } + + // A delivery is the inbox row, and it only shows once it has arrived. + d := &a2a.Delivery{ + Entity: cortex.NewEntity(), + ID: id.NewDeliveryID(), + MessageID: e.ID, + Receiver: a2a.Address{Agent: "worker"}, + State: a2a.DeliveryQueued, + } + if createErr := s.CreateDelivery(ctx, d); createErr != nil { + t.Fatalf("CreateDelivery: %v", createErr) + } + inbox, err := s.ListInbox(ctx, "worker", a2a.InboxFilter{UnreadOnly: true}) + if err != nil { + t.Fatalf("ListInbox: %v", err) + } + if len(inbox) != 0 { + t.Fatalf("a queued delivery has not arrived and must not be in an inbox, got %d", len(inbox)) + } + + claimed, err := s.ClaimDelivery(ctx, d.ID) + if err != nil { + t.Fatalf("ClaimDelivery: %v", err) + } + now := time.Now().UTC() + claimed.State = a2a.DeliveryDelivered + claimed.DeliveredAt = &now + if updErr := s.UpdateDelivery(ctx, claimed); updErr != nil { + t.Fatalf("UpdateDelivery: %v", updErr) + } + + inbox, err = s.ListInbox(ctx, "worker", a2a.InboxFilter{UnreadOnly: true}) + if err != nil { + t.Fatalf("ListInbox after delivery: %v", err) + } + if len(inbox) != 1 { + t.Fatalf("inbox holds %d, want 1", len(inbox)) + } + if readErr := s.MarkDeliveryRead(ctx, d.ID); readErr != nil { + t.Fatalf("MarkDeliveryRead: %v", readErr) + } + inbox, err = s.ListInbox(ctx, "worker", a2a.InboxFilter{UnreadOnly: true}) + if err != nil { + t.Fatalf("ListInbox after read: %v", err) + } + if len(inbox) != 0 { + t.Fatalf("inbox holds %d unread after a read, want 0", len(inbox)) + } +} + +// testA2AClaimPendingAskConcurrency is the ledger's whole guarantee: a +// reply, a deadline sweep and a cancel can all reach for one row, and only +// one of them may resume the waiting run. +func testA2AClaimPendingAskConcurrency(t *testing.T, newStore func(t *testing.T) store.Store) { + s := newStore(t) + ctx := ctxWithScope("acme") + + conv := newA2AConversation("planner") + if err := s.CreateConversation(ctx, conv); err != nil { + t.Fatalf("CreateConversation: %v", err) + } + ask := &a2a.PendingAsk{ + Entity: cortex.NewEntity(), + ReplyWith: "rw-race", + ConversationID: conv.ID, + MessageID: id.NewMessageID(), + AskerRunID: id.NewAgentRunID(), + AskerAgent: "planner", + ToolCallID: "call-1", + Expected: a2a.Address{Agent: "worker"}, + } + if err := s.CreatePendingAsk(ctx, ask); err != nil { + t.Fatalf("CreatePendingAsk: %v", err) + } + + // Two racers, not a storm. Sqlite serialises writers outright, so a + // larger crowd measures lock contention rather than atomicity, and a + // SQLITE_BUSY is not a lost race: it is a retryable error the + // dispatcher redrives. Two is what the suspension claim races above, + // and two is enough: a read-then-write implementation loses to it. + const racers = 2 + var ( + wg sync.WaitGroup + start = make(chan struct{}) + results = make([]error, racers) + ) + for i := range racers { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + _, err := s.ClaimPendingAsk(ctx, "rw-race") + results[i] = err + }(i) + } + close(start) + wg.Wait() + + var won, lost int + for i, err := range results { + switch { + case err == nil: + won++ + case errors.Is(err, a2a.ErrAskAlreadyClaimed): + lost++ + default: + t.Errorf("racer %d: unexpected error %v", i, err) + } + } + if won != 1 { + t.Fatalf("%d racers claimed the ask, want exactly 1", won) + } + if lost != racers-1 { + t.Fatalf("%d racers lost, want %d", lost, racers-1) + } +} + +func testA2AClaimDeliveryConcurrency(t *testing.T, newStore func(t *testing.T) store.Store) { + s := newStore(t) + ctx := ctxWithScope("acme") + + d := &a2a.Delivery{ + Entity: cortex.NewEntity(), + ID: id.NewDeliveryID(), + MessageID: id.NewMessageID(), + Receiver: a2a.Address{Agent: "worker"}, + State: a2a.DeliveryQueued, + } + if err := s.CreateDelivery(ctx, d); err != nil { + t.Fatalf("CreateDelivery: %v", err) + } + + const racers = 2 + var ( + wg sync.WaitGroup + start = make(chan struct{}) + results = make([]error, racers) + ) + for i := range racers { + wg.Add(1) + go func(i int) { + defer wg.Done() + <-start + _, err := s.ClaimDelivery(ctx, d.ID) + results[i] = err + }(i) + } + close(start) + wg.Wait() + + var won int + for i, err := range results { + switch { + case err == nil: + won++ + case errors.Is(err, a2a.ErrDeliveryAlreadyClaimed): + default: + t.Errorf("racer %d: unexpected error %v", i, err) + } + } + if won != 1 { + t.Fatalf("%d workers claimed the delivery, want exactly 1: a directive would have run %d times", won, won) + } +} + +func testA2AExpiredAsks(t *testing.T, newStore func(t *testing.T) store.Store) { + s := newStore(t) + ctx := ctxWithScope("acme") + + past := time.Now().UTC().Add(-time.Hour) + future := time.Now().UTC().Add(time.Hour) + overdue := &a2a.PendingAsk{ + Entity: cortex.NewEntity(), ReplyWith: "rw-overdue", MessageID: id.NewMessageID(), + AskerRunID: id.NewAgentRunID(), ToolCallID: "c1", Expected: a2a.Address{Agent: "w"}, Deadline: &past, + } + pending := &a2a.PendingAsk{ + Entity: cortex.NewEntity(), ReplyWith: "rw-pending", MessageID: id.NewMessageID(), + AskerRunID: id.NewAgentRunID(), ToolCallID: "c2", Expected: a2a.Address{Agent: "w"}, Deadline: &future, + } + for _, a := range []*a2a.PendingAsk{overdue, pending} { + if err := s.CreatePendingAsk(ctx, a); err != nil { + t.Fatalf("CreatePendingAsk: %v", err) + } + } + + got, err := s.ListExpiredAsks(ctx, time.Now().UTC(), 10) + if err != nil { + t.Fatalf("ListExpiredAsks: %v", err) + } + if len(got) != 1 || got[0].ReplyWith != "rw-overdue" { + t.Fatalf("ListExpiredAsks returned %+v, want only the overdue one", got) + } + + // A claimed ask is somebody else's already, so the sweep must not see + // it again. This is what makes sweeping idempotent. + if _, claimErr := s.ClaimPendingAsk(ctx, "rw-overdue"); claimErr != nil { + t.Fatalf("ClaimPendingAsk: %v", claimErr) + } + got, err = s.ListExpiredAsks(ctx, time.Now().UTC(), 10) + if err != nil { + t.Fatalf("ListExpiredAsks after claim: %v", err) + } + if len(got) != 0 { + t.Fatalf("a claimed ask is still listed as expired: %+v", got) + } +} + +func testA2AScopeIsolation(t *testing.T, newStore func(t *testing.T) store.Store) { + s := newStore(t) + acme, other := ctxWithScope("acme"), ctxWithScope("other") + + conv := newA2AConversation("planner") + if err := s.CreateConversation(acme, conv); err != nil { + t.Fatalf("CreateConversation: %v", err) + } + e := newA2AEnvelope(conv.ID, "planner", "worker") + if err := s.CreateMessage(acme, e); err != nil { + t.Fatalf("CreateMessage: %v", err) + } + d := &a2a.Delivery{ + Entity: cortex.NewEntity(), ID: id.NewDeliveryID(), MessageID: e.ID, + Receiver: a2a.Address{Agent: "worker"}, State: a2a.DeliveryDelivered, + } + if err := s.CreateDelivery(acme, d); err != nil { + t.Fatalf("CreateDelivery: %v", err) + } + ask := &a2a.PendingAsk{ + Entity: cortex.NewEntity(), ReplyWith: "rw-scoped", ConversationID: conv.ID, + MessageID: e.ID, AskerRunID: id.NewAgentRunID(), ToolCallID: "c1", Expected: a2a.Address{Agent: "worker"}, + } + if err := s.CreatePendingAsk(acme, ask); err != nil { + t.Fatalf("CreatePendingAsk: %v", err) + } + + if _, err := s.GetMessage(other, e.ID); err == nil { + t.Error("a message must not be readable from another scope") + } + if _, err := s.GetConversation(other, conv.ID); err == nil { + t.Error("a conversation must not be readable from another scope") + } + inbox, err := s.ListInbox(other, "worker", a2a.InboxFilter{UnreadOnly: true}) + if err != nil { + t.Fatalf("ListInbox: %v", err) + } + if len(inbox) != 0 { + t.Errorf("another scope sees %d inbox rows, want 0", len(inbox)) + } + // The claim is a write, and a write that crossed scopes would resume a + // run in a tenant the claimant cannot see. + if _, claimErr := s.ClaimPendingAsk(other, "rw-scoped"); !errors.Is(claimErr, a2a.ErrAskNotFound) { + t.Errorf("cross-scope claim: err = %v, want ErrAskNotFound", claimErr) + } + msgs, err := s.ListMessages(other, &a2a.MessageListFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if len(msgs) != 0 { + t.Errorf("another scope lists %d messages, want 0", len(msgs)) + } +} From 9a12fc188431d8f5d2aed43495d6b3d03ddbc626 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:51:20 -0500 Subject: [PATCH 18/50] feat(store): persist a2a across postgres and mongo, and fold it into the composite Postgres mirrors the sqlite implementation with jsonb columns and the same conditional-update claim. Mongo claims with FindOneAndUpdate, whose match and write are one operation, and keys pending asks by their reply-with token so the ledger's one-row-per-token guarantee comes from the primary key itself. Neither backend was exercised: testcontainers cannot start a database in the environment this was written in, so both are compile-verified only and the conformance suite is what will actually prove them. --- store/mongo/a2a.go | 595 ++++++++++++++++++++++++++++++++++ store/mongo/migrations.go | 29 ++ store/mongo/models.go | 340 ++++++++++++++++++++ store/mongo/rescope.go | 4 + store/mongo/store.go | 8 + store/postgres/a2a.go | 600 +++++++++++++++++++++++++++++++++++ store/postgres/migrations.go | 124 ++++++++ store/postgres/models.go | 348 ++++++++++++++++++++ store/postgres/rescope.go | 11 +- 9 files changed, 2058 insertions(+), 1 deletion(-) create mode 100644 store/mongo/a2a.go create mode 100644 store/postgres/a2a.go diff --git a/store/mongo/a2a.go b/store/mongo/a2a.go new file mode 100644 index 0000000..b80436b --- /dev/null +++ b/store/mongo/a2a.go @@ -0,0 +1,595 @@ +package mongo + +import ( + "context" + "fmt" + "time" + + "go.mongodb.org/mongo-driver/v2/bson" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" +) + +// ────────────────────────────────────────────────── +// Messages +// ────────────────────────────────────────────────── + +// CreateMessage persists an envelope, stamping the scope from the context. +// Envelopes are immutable once written, so there is no update counterpart. +func (s *Store) CreateMessage(ctx context.Context, e *a2a.Envelope) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + t := now() + e.CreatedAt = t + e.UpdatedAt = t + e.Scope = scope + + if _, err := s.mdb.NewInsert(a2aMessageToModel(e)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex/mongo: create a2a message: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex/mongo: create a2a message: %w", err) + } + return nil +} + +func (s *Store) GetMessage(ctx context.Context, msgID id.MessageID) (*a2a.Envelope, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var m a2aMessageModel + + filter := bson.M{"_id": msgID.String()} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + if err := s.mdb.NewFind(&m).Filter(filter).Scan(ctx); err != nil { + if isNoDocuments(err) { + return nil, a2a.ErrMessageNotFound + } + return nil, fmt.Errorf("cortex/mongo: get a2a message: %w", err) + } + return a2aMessageFromModel(&m) +} + +// ListMessages returns a conversation's messages oldest first, because a +// conversation is read as a transcript. +func (s *Store) ListMessages(ctx context.Context, f *a2a.MessageListFilter) ([]*a2a.Envelope, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aMessageModel + + exact := f != nil && f.Exact + filter := bson.M{} + for k, v := range scopeFilter(scope, exact) { + filter[k] = v + } + if f != nil && !f.ConversationID.IsNil() { + filter["conversation_id"] = f.ConversationID.String() + } + + q := s.mdb.NewFind(&models).Filter(filter).Sort(bson.D{{Key: "created_at", Value: 1}, {Key: "_id", Value: 1}}) + if f != nil { + if f.Limit > 0 { + q = q.Limit(int64(f.Limit)) + } + if f.Offset > 0 { + q = q.Skip(int64(f.Offset)) + } + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/mongo: list a2a messages: %w", err) + } + out := make([]*a2a.Envelope, len(models)) + for i := range models { + e, convErr := a2aMessageFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = e + } + return out, nil +} + +// ────────────────────────────────────────────────── +// Conversations +// ────────────────────────────────────────────────── + +func (s *Store) CreateConversation(ctx context.Context, c *a2a.Conversation) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + t := now() + c.CreatedAt = t + c.UpdatedAt = t + c.Scope = scope + + if _, err := s.mdb.NewInsert(a2aConversationToModel(c)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex/mongo: create a2a conversation: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex/mongo: create a2a conversation: %w", err) + } + return nil +} + +func (s *Store) GetConversation(ctx context.Context, convID id.ConversationID) (*a2a.Conversation, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var m a2aConversationModel + + filter := bson.M{"_id": convID.String()} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + if err := s.mdb.NewFind(&m).Filter(filter).Scan(ctx); err != nil { + if isNoDocuments(err) { + return nil, a2a.ErrConversationNotFound + } + return nil, fmt.Errorf("cortex/mongo: get a2a conversation: %w", err) + } + return a2aConversationFromModel(&m) +} + +// UpdateConversation writes the mutable half of a conversation. The scope +// fields are deliberately absent from the $set: a conversation's scope is +// fixed at creation, and an update issued from a broader context would +// otherwise widen it. +func (s *Store) UpdateConversation(ctx context.Context, c *a2a.Conversation) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + c.UpdatedAt = now() + m := a2aConversationToModel(c) + + filter := bson.M{"_id": m.ID} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + set := bson.M{ + "protocol": m.Protocol, + "participants": m.Participants, + "status": m.Status, + "hop_ceiling": m.HopCeiling, + "hops_used": m.HopsUsed, + "deadline": m.Deadline, + "updated_at": m.UpdatedAt, + } + + res, err := s.mdb.NewUpdate((*a2aConversationModel)(nil)). + Filter(filter). + SetUpdate(bson.M{"$set": set}). + Exec(ctx) + if err != nil { + return fmt.Errorf("cortex/mongo: update a2a conversation: %w", err) + } + if res.MatchedCount() == 0 { + return a2a.ErrConversationNotFound + } + return nil +} + +func (s *Store) ListConversations(ctx context.Context, f *a2a.ConversationListFilter) ([]*a2a.Conversation, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aConversationModel + + exact := f != nil && f.Exact + filter := bson.M{} + for k, v := range scopeFilter(scope, exact) { + filter[k] = v + } + if f != nil && f.Status != "" { + filter["status"] = f.Status + } + + q := s.mdb.NewFind(&models).Filter(filter).Sort(bson.D{{Key: "created_at", Value: -1}, {Key: "_id", Value: -1}}) + if f != nil { + if f.Limit > 0 { + q = q.Limit(int64(f.Limit)) + } + if f.Offset > 0 { + q = q.Skip(int64(f.Offset)) + } + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/mongo: list a2a conversations: %w", err) + } + out := make([]*a2a.Conversation, len(models)) + for i := range models { + c, convErr := a2aConversationFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = c + } + return out, nil +} + +// ────────────────────────────────────────────────── +// Deliveries +// ────────────────────────────────────────────────── + +func (s *Store) CreateDelivery(ctx context.Context, d *a2a.Delivery) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + t := now() + d.CreatedAt = t + d.UpdatedAt = t + d.Scope = scope + + if _, err := s.mdb.NewInsert(a2aDeliveryToModel(d)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex/mongo: create a2a delivery: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex/mongo: create a2a delivery: %w", err) + } + return nil +} + +func (s *Store) UpdateDelivery(ctx context.Context, d *a2a.Delivery) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + d.UpdatedAt = now() + m := a2aDeliveryToModel(d) + + filter := bson.M{"_id": m.ID} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + set := bson.M{ + "state": m.State, + "error": m.Error, + "delivered_at": m.DeliveredAt, + "read_at": m.ReadAt, + "run_id": m.RunID, + "updated_at": m.UpdatedAt, + } + + res, err := s.mdb.NewUpdate((*a2aDeliveryModel)(nil)). + Filter(filter). + SetUpdate(bson.M{"$set": set}). + Exec(ctx) + if err != nil { + return fmt.Errorf("cortex/mongo: update a2a delivery: %w", err) + } + if res.MatchedCount() == 0 { + return a2a.ErrDeliveryNotFound + } + return nil +} + +// ClaimDelivery takes a queued delivery and marks it delivering. +// +// FindOneAndUpdate applies the match and the write as one operation, so +// the state = "queued" filter is the claim: two workers racing both issue +// it and exactly one of them matches a document. That is what stops one +// directive starting two runs. +func (s *Store) ClaimDelivery(ctx context.Context, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + t := now() + filter := bson.M{"_id": deliveryID.String(), "state": a2a.DeliveryQueued} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + update := bson.M{"$set": bson.M{ + "state": a2a.DeliveryDelivering, + "claimed_at": t, + "updated_at": t, + }} + + res := s.mdb.Collection(colA2ADeliveries).FindOneAndUpdate(ctx, filter, update) + if err := res.Err(); err != nil { + if isNoDocuments(err) { + // No match means one of two things and the caller has to tell + // them apart: losing a race is ordinary, a delivery id nobody + // minted is a bug further up. + exists, existsErr := s.deliveryExists(ctx, scope, deliveryID) + if existsErr != nil { + return nil, existsErr + } + if exists { + return nil, a2a.ErrDeliveryAlreadyClaimed + } + return nil, a2a.ErrDeliveryNotFound + } + return nil, fmt.Errorf("cortex/mongo: claim a2a delivery: %w", err) + } + return s.getDelivery(ctx, scope, deliveryID) +} + +// ListInbox returns messages that have ARRIVED for an agent. A queued +// delivery is deliberately excluded: it has not reached anyone yet, and an +// inbox that showed it would be showing mail still in transit. +func (s *Store) ListInbox(ctx context.Context, agentName string, f a2a.InboxFilter) ([]*a2a.Delivery, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + filter := bson.M{"receiver_agent": agentName, "state": a2a.DeliveryDelivered} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + if f.UnreadOnly { + filter["read_at"] = nil + } + if !f.ConversationID.IsNil() { + // Mongo has no join, so the conversation's message ids are read + // first and the delivery query filters on them. + msgs, err := s.ListMessages(ctx, &a2a.MessageListFilter{ConversationID: f.ConversationID}) + if err != nil { + return nil, err + } + ids := make([]string, len(msgs)) + for i, m := range msgs { + ids[i] = m.ID.String() + } + filter["message_id"] = bson.M{"$in": ids} + } + + var models []a2aDeliveryModel + q := s.mdb.NewFind(&models).Filter(filter).Sort(bson.D{{Key: "created_at", Value: 1}, {Key: "_id", Value: 1}}) + if f.Limit > 0 { + q = q.Limit(int64(f.Limit)) + } + if f.Offset > 0 { + q = q.Skip(int64(f.Offset)) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/mongo: list a2a inbox: %w", err) + } + return a2aDeliveriesFromModels(models) +} + +// ListQueuedDeliveries deliberately does NOT filter by scope. +// +// It is the dispatcher's read, and the dispatcher runs per process rather +// than per tenant: a delivery queued under one scope has to be carried +// even when nobody from that scope is currently calling in. Every document +// it returns carries its own scope, and the caller puts that scope back on +// the context before touching anything, so isolation is preserved by the +// caller instead of by the query. This mirrors the sweeper's cross-scope +// read of expired suspensions. +func (s *Store) ListQueuedDeliveries(ctx context.Context, limit int) ([]*a2a.Delivery, error) { + var models []a2aDeliveryModel + q := s.mdb.NewFind(&models). + Filter(bson.M{"state": a2a.DeliveryQueued}). + Sort(bson.D{{Key: "created_at", Value: 1}, {Key: "_id", Value: 1}}) + if limit > 0 { + q = q.Limit(int64(limit)) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/mongo: list queued a2a deliveries: %w", err) + } + return a2aDeliveriesFromModels(models) +} + +func (s *Store) MarkDeliveryRead(ctx context.Context, deliveryID id.DeliveryID) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + t := now() + filter := bson.M{"_id": deliveryID.String()} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + + res, err := s.mdb.NewUpdate((*a2aDeliveryModel)(nil)). + Filter(filter). + SetUpdate(bson.M{"$set": bson.M{"read_at": t, "updated_at": t}}). + Exec(ctx) + if err != nil { + return fmt.Errorf("cortex/mongo: mark a2a delivery read: %w", err) + } + if res.MatchedCount() == 0 { + return a2a.ErrDeliveryNotFound + } + return nil +} + +// ────────────────────────────────────────────────── +// Pending asks +// ────────────────────────────────────────────────── + +func (s *Store) CreatePendingAsk(ctx context.Context, a *a2a.PendingAsk) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + t := now() + a.CreatedAt = t + a.UpdatedAt = t + a.Scope = scope + + if _, err := s.mdb.NewInsert(a2aPendingAskToModel(a)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex/mongo: create a2a pending ask: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex/mongo: create a2a pending ask: %w", err) + } + return nil +} + +// ClaimPendingAsk takes the ask carrying replyWith, but only if nobody +// has yet. +// +// The claimed_at: nil filter inside a FindOneAndUpdate is the whole +// guarantee: a late reply, the deadline sweep and a cancel can all reach +// for one document, and exactly one of them matches and goes on to resume +// the waiting run. The reply-with token is the document's _id, so there +// can only ever be one of them to race for. +func (s *Store) ClaimPendingAsk(ctx context.Context, replyWith string) (*a2a.PendingAsk, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + t := now() + filter := bson.M{"_id": replyWith, "claimed_at": nil} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + update := bson.M{"$set": bson.M{"claimed_at": t, "updated_at": t}} + + res := s.mdb.Collection(colA2APendingAsks).FindOneAndUpdate(ctx, filter, update) + if err := res.Err(); err != nil { + if isNoDocuments(err) { + exists, existsErr := s.pendingAskExists(ctx, scope, replyWith) + if existsErr != nil { + return nil, existsErr + } + if exists { + return nil, a2a.ErrAskAlreadyClaimed + } + return nil, a2a.ErrAskNotFound + } + return nil, fmt.Errorf("cortex/mongo: claim a2a pending ask: %w", err) + } + return s.getPendingAsk(ctx, scope, replyWith) +} + +// ListExpiredAsks returns unclaimed asks past their deadline. Claimed +// documents are excluded, which is what makes sweeping the same backlog +// twice safe. +func (s *Store) ListExpiredAsks(ctx context.Context, at time.Time, limit int) ([]*a2a.PendingAsk, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + filter := bson.M{ + "claimed_at": nil, + "deadline": bson.M{"$ne": nil, "$lte": at.UTC()}, + } + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + + var models []a2aPendingAskModel + q := s.mdb.NewFind(&models).Filter(filter).Sort(bson.D{{Key: "deadline", Value: 1}}) + if limit > 0 { + q = q.Limit(int64(limit)) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/mongo: list expired a2a asks: %w", err) + } + return a2aPendingAsksFromModels(models) +} + +func (s *Store) ListPendingAsksByConversation(ctx context.Context, convID id.ConversationID) ([]*a2a.PendingAsk, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + filter := bson.M{"conversation_id": convID.String(), "claimed_at": nil} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + + var models []a2aPendingAskModel + if err := s.mdb.NewFind(&models).Filter(filter).Sort(bson.D{{Key: "created_at", Value: 1}}).Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex/mongo: list a2a asks by conversation: %w", err) + } + return a2aPendingAsksFromModels(models) +} + +// ────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────── + +func a2aDeliveriesFromModels(models []a2aDeliveryModel) ([]*a2a.Delivery, error) { + out := make([]*a2a.Delivery, len(models)) + for i := range models { + d, convErr := a2aDeliveryFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = d + } + return out, nil +} + +func a2aPendingAsksFromModels(models []a2aPendingAskModel) ([]*a2a.PendingAsk, error) { + out := make([]*a2a.PendingAsk, len(models)) + for i := range models { + a, convErr := a2aPendingAskFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = a + } + return out, nil +} + +func (s *Store) getDelivery(ctx context.Context, scope cortex.Scope, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + var m a2aDeliveryModel + filter := bson.M{"_id": deliveryID.String()} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + if err := s.mdb.NewFind(&m).Filter(filter).Scan(ctx); err != nil { + if isNoDocuments(err) { + return nil, a2a.ErrDeliveryNotFound + } + return nil, fmt.Errorf("cortex/mongo: get a2a delivery: %w", err) + } + return a2aDeliveryFromModel(&m) +} + +func (s *Store) deliveryExists(ctx context.Context, scope cortex.Scope, deliveryID id.DeliveryID) (bool, error) { + _, err := s.getDelivery(ctx, scope, deliveryID) + switch { + case err == nil: + return true, nil + case isNotFound(err, a2a.ErrDeliveryNotFound): + return false, nil + default: + return false, err + } +} + +func (s *Store) getPendingAsk(ctx context.Context, scope cortex.Scope, replyWith string) (*a2a.PendingAsk, error) { + var m a2aPendingAskModel + filter := bson.M{"_id": replyWith} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + if err := s.mdb.NewFind(&m).Filter(filter).Scan(ctx); err != nil { + if isNoDocuments(err) { + return nil, a2a.ErrAskNotFound + } + return nil, fmt.Errorf("cortex/mongo: get a2a pending ask: %w", err) + } + return a2aPendingAskFromModel(&m) +} + +func (s *Store) pendingAskExists(ctx context.Context, scope cortex.Scope, replyWith string) (bool, error) { + _, err := s.getPendingAsk(ctx, scope, replyWith) + switch { + case err == nil: + return true, nil + case isNotFound(err, a2a.ErrAskNotFound): + return false, nil + default: + return false, err + } +} diff --git a/store/mongo/migrations.go b/store/mongo/migrations.go index f0e7574..fa4922c 100644 --- a/store/mongo/migrations.go +++ b/store/mongo/migrations.go @@ -414,6 +414,35 @@ func migrationIndexes() map[string][]mongo.IndexModel { {Keys: bson.D{{Key: "created_at", Value: -1}}}, scopeIndex, }, + colA2AMessages: { + {Keys: bson.D{{Key: "conversation_id", Value: 1}, {Key: "created_at", Value: 1}}}, + {Keys: bson.D{{Key: "created_at", Value: 1}}}, + scopeIndex, + }, + colA2AConversations: { + {Keys: bson.D{{Key: "status", Value: 1}}}, + {Keys: bson.D{{Key: "created_at", Value: -1}}}, + scopeIndex, + }, + colA2ADeliveries: { + // The inbox read and the dispatcher's redrive read are the two + // queries this collection serves, and they are different + // shapes: one is per recipient inside a scope, the other is + // state-only across every scope. + {Keys: bson.D{{Key: "receiver_agent", Value: 1}, {Key: "state", Value: 1}, {Key: "read_at", Value: 1}}}, + {Keys: bson.D{{Key: "state", Value: 1}, {Key: "created_at", Value: 1}}}, + {Keys: bson.D{{Key: "message_id", Value: 1}}}, + scopeIndex, + }, + colA2APendingAsks: { + // The reply-with token IS the _id, so the ledger's + // one-row-per-token guarantee comes from mongo's own primary + // key rather than an index declared here. What is left is the + // sweep's read. + {Keys: bson.D{{Key: "claimed_at", Value: 1}, {Key: "deadline", Value: 1}}}, + {Keys: bson.D{{Key: "conversation_id", Value: 1}}}, + scopeIndex, + }, } } diff --git a/store/mongo/models.go b/store/mongo/models.go index 5c723b7..e3c5cd1 100644 --- a/store/mongo/models.go +++ b/store/mongo/models.go @@ -8,6 +8,7 @@ import ( "github.com/xraph/grove" "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" "github.com/xraph/cortex/agent" "github.com/xraph/cortex/behavior" "github.com/xraph/cortex/checkpoint" @@ -1205,3 +1206,342 @@ func overlayFromModel(m *overlayModel) (*prompt.Overlay, error) { MaxTokens: m.MaxTokens, }, nil } + +// ────────────────────────────────────────────────── +// a2a models +// ────────────────────────────────────────────────── +// +// Unlike the sqlite and postgres models, these store their slice and map +// fields natively rather than as JSON strings: bson already carries the +// structure, and flattening it would make the documents unqueryable from +// a mongo shell for no gain. + +type a2aMessageModel struct { + grove.BaseModel `grove:"table:cortex_a2a_messages"` + ID string `grove:"id,pk" bson:"_id"` + Performative string `grove:"performative" bson:"performative"` + SenderAgent string `grove:"sender_agent" bson:"sender_agent"` + SenderNode string `grove:"sender_node" bson:"sender_node"` + Receivers []a2a.Address `grove:"receivers" bson:"receivers,omitempty"` + ReplyTo []a2a.Address `grove:"reply_to" bson:"reply_to,omitempty"` + Content string `grove:"content" bson:"content"` + Language string `grove:"language" bson:"language"` + Encoding string `grove:"encoding" bson:"encoding"` + Ontology string `grove:"ontology" bson:"ontology"` + Protocol string `grove:"protocol" bson:"protocol"` + ConversationID string `grove:"conversation_id" bson:"conversation_id"` + ReplyWith string `grove:"reply_with" bson:"reply_with"` + InReplyTo string `grove:"in_reply_to" bson:"in_reply_to"` + ReplyBy *time.Time `grove:"reply_by" bson:"reply_by,omitempty"` + Hops int `grove:"hops" bson:"hops"` + OriginRunID string `grove:"origin_run_id" bson:"origin_run_id"` + Metadata map[string]any `grove:"metadata" bson:"metadata,omitempty"` + ScopeL0 string `grove:"scope_l0" bson:"scope_l0"` + ScopeL1 string `grove:"scope_l1" bson:"scope_l1"` + ScopeL2 string `grove:"scope_l2" bson:"scope_l2"` + ScopeExtra map[string]string `grove:"scope_extra" bson:"scope_extra,omitempty"` + ScopeCanon string `grove:"scope_canon" bson:"scope_canon"` + CreatedAt time.Time `grove:"created_at" bson:"created_at"` + UpdatedAt time.Time `grove:"updated_at" bson:"updated_at"` +} + +func a2aMessageToModel(e *a2a.Envelope) *a2aMessageModel { + l0, l1, l2, extra := scopeColumns(e.Scope) + return &a2aMessageModel{ + ID: e.ID.String(), + Performative: string(e.Performative), + SenderAgent: e.Sender.Agent, + SenderNode: e.Sender.Node, + Receivers: e.Receivers, + ReplyTo: e.ReplyTo, + Content: e.Content, + Language: e.Language, + Encoding: e.Encoding, + Ontology: e.Ontology, + Protocol: e.Protocol, + ConversationID: e.ConversationID.String(), + ReplyWith: e.ReplyWith, + InReplyTo: e.InReplyTo, + ReplyBy: e.ReplyBy, + Hops: e.Hops, + OriginRunID: e.OriginRunID.String(), + Metadata: e.Metadata, + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: e.Scope.Canonical(), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func a2aMessageFromModel(m *a2aMessageModel) (*a2a.Envelope, error) { + msgID, err := id.ParseWithPrefix(m.ID, id.PrefixMessage) + if err != nil { + return nil, err + } + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a message %s: %w", msgID, err) + } + e := &a2a.Envelope{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: msgID, + Scope: scope, + Performative: a2a.Performative(m.Performative), + Sender: a2a.Address{Agent: m.SenderAgent, Node: m.SenderNode}, + Receivers: m.Receivers, + ReplyTo: m.ReplyTo, + Content: m.Content, + Language: m.Language, + Encoding: m.Encoding, + Ontology: m.Ontology, + Protocol: m.Protocol, + ReplyWith: m.ReplyWith, + InReplyTo: m.InReplyTo, + ReplyBy: m.ReplyBy, + Hops: m.Hops, + Metadata: m.Metadata, + } + if m.ConversationID != "" { + convID, convErr := id.ParseWithPrefix(m.ConversationID, id.PrefixConversation) + if convErr != nil { + return nil, fmt.Errorf("a2a message %s: conversation id: %w", msgID, convErr) + } + e.ConversationID = convID + } + if m.OriginRunID != "" { + runID, runErr := id.ParseWithPrefix(m.OriginRunID, id.PrefixAgentRun) + if runErr != nil { + return nil, fmt.Errorf("a2a message %s: origin run id: %w", msgID, runErr) + } + e.OriginRunID = runID + } + return e, nil +} + +type a2aConversationModel struct { + grove.BaseModel `grove:"table:cortex_a2a_conversations"` + ID string `grove:"id,pk" bson:"_id"` + Protocol string `grove:"protocol" bson:"protocol"` + InitiatorAgent string `grove:"initiator_agent" bson:"initiator_agent"` + InitiatorNode string `grove:"initiator_node" bson:"initiator_node"` + Participants []a2a.Address `grove:"participants" bson:"participants,omitempty"` + Status string `grove:"status" bson:"status"` + HopCeiling int `grove:"hop_ceiling" bson:"hop_ceiling"` + HopsUsed int `grove:"hops_used" bson:"hops_used"` + Deadline *time.Time `grove:"deadline" bson:"deadline,omitempty"` + ScopeL0 string `grove:"scope_l0" bson:"scope_l0"` + ScopeL1 string `grove:"scope_l1" bson:"scope_l1"` + ScopeL2 string `grove:"scope_l2" bson:"scope_l2"` + ScopeExtra map[string]string `grove:"scope_extra" bson:"scope_extra,omitempty"` + ScopeCanon string `grove:"scope_canon" bson:"scope_canon"` + CreatedAt time.Time `grove:"created_at" bson:"created_at"` + UpdatedAt time.Time `grove:"updated_at" bson:"updated_at"` +} + +func a2aConversationToModel(c *a2a.Conversation) *a2aConversationModel { + l0, l1, l2, extra := scopeColumns(c.Scope) + return &a2aConversationModel{ + ID: c.ID.String(), + Protocol: c.Protocol, + InitiatorAgent: c.Initiator.Agent, + InitiatorNode: c.Initiator.Node, + Participants: c.Participants, + Status: c.Status, + HopCeiling: c.HopCeiling, + HopsUsed: c.HopsUsed, + Deadline: c.Deadline, + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: c.Scope.Canonical(), + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func a2aConversationFromModel(m *a2aConversationModel) (*a2a.Conversation, error) { + convID, err := id.ParseWithPrefix(m.ID, id.PrefixConversation) + if err != nil { + return nil, err + } + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a conversation %s: %w", convID, err) + } + return &a2a.Conversation{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: convID, + Scope: scope, + Protocol: m.Protocol, + Initiator: a2a.Address{Agent: m.InitiatorAgent, Node: m.InitiatorNode}, + Participants: m.Participants, + Status: m.Status, + HopCeiling: m.HopCeiling, + HopsUsed: m.HopsUsed, + Deadline: m.Deadline, + }, nil +} + +type a2aDeliveryModel struct { + grove.BaseModel `grove:"table:cortex_a2a_deliveries"` + ID string `grove:"id,pk" bson:"_id"` + MessageID string `grove:"message_id" bson:"message_id"` + ReceiverAgent string `grove:"receiver_agent" bson:"receiver_agent"` + ReceiverNode string `grove:"receiver_node" bson:"receiver_node"` + State string `grove:"state" bson:"state"` + Error string `grove:"error" bson:"error"` + ClaimedAt *time.Time `grove:"claimed_at" bson:"claimed_at,omitempty"` + DeliveredAt *time.Time `grove:"delivered_at" bson:"delivered_at,omitempty"` + ReadAt *time.Time `grove:"read_at" bson:"read_at,omitempty"` + RunID string `grove:"run_id" bson:"run_id"` + ScopeL0 string `grove:"scope_l0" bson:"scope_l0"` + ScopeL1 string `grove:"scope_l1" bson:"scope_l1"` + ScopeL2 string `grove:"scope_l2" bson:"scope_l2"` + ScopeExtra map[string]string `grove:"scope_extra" bson:"scope_extra,omitempty"` + ScopeCanon string `grove:"scope_canon" bson:"scope_canon"` + CreatedAt time.Time `grove:"created_at" bson:"created_at"` + UpdatedAt time.Time `grove:"updated_at" bson:"updated_at"` +} + +func a2aDeliveryToModel(d *a2a.Delivery) *a2aDeliveryModel { + l0, l1, l2, extra := scopeColumns(d.Scope) + return &a2aDeliveryModel{ + ID: d.ID.String(), + MessageID: d.MessageID.String(), + ReceiverAgent: d.Receiver.Agent, + ReceiverNode: d.Receiver.Node, + State: d.State, + Error: d.Error, + DeliveredAt: d.DeliveredAt, + ReadAt: d.ReadAt, + RunID: d.RunID.String(), + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: d.Scope.Canonical(), + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } +} + +func a2aDeliveryFromModel(m *a2aDeliveryModel) (*a2a.Delivery, error) { + dlvID, err := id.ParseWithPrefix(m.ID, id.PrefixDelivery) + if err != nil { + return nil, err + } + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a delivery %s: %w", dlvID, err) + } + msgID, err := id.ParseWithPrefix(m.MessageID, id.PrefixMessage) + if err != nil { + return nil, fmt.Errorf("a2a delivery %s: message id: %w", dlvID, err) + } + d := &a2a.Delivery{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: dlvID, + Scope: scope, + MessageID: msgID, + Receiver: a2a.Address{Agent: m.ReceiverAgent, Node: m.ReceiverNode}, + State: m.State, + Error: m.Error, + DeliveredAt: m.DeliveredAt, + ReadAt: m.ReadAt, + } + if m.RunID != "" { + runID, runErr := id.ParseWithPrefix(m.RunID, id.PrefixAgentRun) + if runErr != nil { + return nil, fmt.Errorf("a2a delivery %s: run id: %w", dlvID, runErr) + } + d.RunID = runID + } + return d, nil +} + +type a2aPendingAskModel struct { + grove.BaseModel `grove:"table:cortex_a2a_pending_asks"` + ReplyWith string `grove:"reply_with,pk" bson:"_id"` + ConversationID string `grove:"conversation_id" bson:"conversation_id"` + MessageID string `grove:"message_id" bson:"message_id"` + AskerRunID string `grove:"asker_run_id" bson:"asker_run_id"` + AskerAgent string `grove:"asker_agent" bson:"asker_agent"` + ToolCallID string `grove:"tool_call_id" bson:"tool_call_id"` + ExpectedAgent string `grove:"expected_agent" bson:"expected_agent"` + ExpectedNode string `grove:"expected_node" bson:"expected_node"` + Deadline *time.Time `grove:"deadline" bson:"deadline,omitempty"` + ClaimedAt *time.Time `grove:"claimed_at" bson:"claimed_at,omitempty"` + ScopeL0 string `grove:"scope_l0" bson:"scope_l0"` + ScopeL1 string `grove:"scope_l1" bson:"scope_l1"` + ScopeL2 string `grove:"scope_l2" bson:"scope_l2"` + ScopeExtra map[string]string `grove:"scope_extra" bson:"scope_extra,omitempty"` + ScopeCanon string `grove:"scope_canon" bson:"scope_canon"` + CreatedAt time.Time `grove:"created_at" bson:"created_at"` + UpdatedAt time.Time `grove:"updated_at" bson:"updated_at"` +} + +func a2aPendingAskToModel(a *a2a.PendingAsk) *a2aPendingAskModel { + l0, l1, l2, extra := scopeColumns(a.Scope) + return &a2aPendingAskModel{ + ReplyWith: a.ReplyWith, + ConversationID: a.ConversationID.String(), + MessageID: a.MessageID.String(), + AskerRunID: a.AskerRunID.String(), + AskerAgent: a.AskerAgent, + ToolCallID: a.ToolCallID, + ExpectedAgent: a.Expected.Agent, + ExpectedNode: a.Expected.Node, + Deadline: a.Deadline, + ClaimedAt: a.ClaimedAt, + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: a.Scope.Canonical(), + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + } +} + +func a2aPendingAskFromModel(m *a2aPendingAskModel) (*a2a.PendingAsk, error) { + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a pending ask %s: %w", m.ReplyWith, err) + } + a := &a2a.PendingAsk{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + Scope: scope, + ReplyWith: m.ReplyWith, + AskerAgent: m.AskerAgent, + ToolCallID: m.ToolCallID, + Expected: a2a.Address{Agent: m.ExpectedAgent, Node: m.ExpectedNode}, + Deadline: m.Deadline, + ClaimedAt: m.ClaimedAt, + } + if m.ConversationID != "" { + convID, convErr := id.ParseWithPrefix(m.ConversationID, id.PrefixConversation) + if convErr != nil { + return nil, fmt.Errorf("a2a pending ask %s: conversation id: %w", m.ReplyWith, convErr) + } + a.ConversationID = convID + } + if m.MessageID != "" { + msgID, msgErr := id.ParseWithPrefix(m.MessageID, id.PrefixMessage) + if msgErr != nil { + return nil, fmt.Errorf("a2a pending ask %s: message id: %w", m.ReplyWith, msgErr) + } + a.MessageID = msgID + } + if m.AskerRunID != "" { + runID, runErr := id.ParseWithPrefix(m.AskerRunID, id.PrefixAgentRun) + if runErr != nil { + return nil, fmt.Errorf("a2a pending ask %s: asker run id: %w", m.ReplyWith, runErr) + } + a.AskerRunID = runID + } + return a, nil +} diff --git a/store/mongo/rescope.go b/store/mongo/rescope.go index 3c48dd2..80957b3 100644 --- a/store/mongo/rescope.go +++ b/store/mongo/rescope.go @@ -18,6 +18,10 @@ import ( // (discoverScopedCollections), not assumed -- a partially migrated // database is exactly the state this pass is most likely to meet, since // later phases bring more collections under scope over time. +// +// The four cortex_a2a_* collections are deliberately absent. They were +// born with their scope fields, so no document in them has ever been +// unscoped and there is nothing here to backfill. var candidateCollections = []string{ colAgents, colRuns, colSteps, colToolCalls, colMemories, colCheckpoints, colSkills, colTraits, colBehaviors, colPersonas, colSessions, diff --git a/store/mongo/store.go b/store/mongo/store.go index fd6be2e..38ea817 100644 --- a/store/mongo/store.go +++ b/store/mongo/store.go @@ -32,6 +32,10 @@ const ( colOverlays = "cortex_overlays" colOrchestrationConfigs = "cortex_orchestration_configs" colOrchestrationRuns = "cortex_orchestration_runs" + colA2AMessages = "cortex_a2a_messages" + colA2AConversations = "cortex_a2a_conversations" + colA2ADeliveries = "cortex_a2a_deliveries" + colA2APendingAsks = "cortex_a2a_pending_asks" ) // Compile-time interface check. @@ -231,3 +235,7 @@ func isNoDocuments(err error) bool { func isUniqueViolation(err error) bool { return mongo.IsDuplicateKeyError(err) } + +// isNotFound reports whether err is the given not-found sentinel. It reads +// better at the call sites in a2a.go than errors.Is inline. +func isNotFound(err, sentinel error) bool { return errors.Is(err, sentinel) } diff --git a/store/postgres/a2a.go b/store/postgres/a2a.go new file mode 100644 index 0000000..4b4efa4 --- /dev/null +++ b/store/postgres/a2a.go @@ -0,0 +1,600 @@ +package postgres + +import ( + "context" + "database/sql" + "errors" + "fmt" + "time" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" +) + +// mutableA2AConversationColumns is every cortex_a2a_conversations column +// UpdateConversation may write. The five scope columns are deliberately +// absent: a conversation's scope is set once at creation, and grove builds +// SET from every model field by default, so without this whitelist an +// update issued from a broader (but still matching) context would widen +// the row's stored scope. +var mutableA2AConversationColumns = []string{ + "protocol", + "participants", + "status", + "hop_ceiling", + "hops_used", + "deadline", + "updated_at", +} + +// mutableA2ADeliveryColumns mirrors the above for cortex_a2a_deliveries. +// message_id and receiver are absent as well as scope: a delivery never +// changes who it is for, only how far along it is. +var mutableA2ADeliveryColumns = []string{ + "state", + "error", + "delivered_at", + "read_at", + "run_id", + "updated_at", +} + +// ────────────────────────────────────────────────── +// Messages +// ────────────────────────────────────────────────── + +// CreateMessage persists an envelope, stamping the scope from the context. +// Envelopes are immutable once written, so there is no update counterpart. +func (s *Store) CreateMessage(ctx context.Context, e *a2a.Envelope) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + now := time.Now().UTC() + e.CreatedAt = now + e.UpdatedAt = now + e.Scope = scope + if _, err := s.pgdb.NewInsert(a2aMessageToModel(e)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex: create a2a message: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex: create a2a message: %w", err) + } + return nil +} + +func (s *Store) GetMessage(ctx context.Context, msgID id.MessageID) (*a2a.Envelope, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + m := new(a2aMessageModel) + q := s.pgdb.NewSelect(m).Where("id = ?", msgID.String()) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, a2a.ErrMessageNotFound + } + return nil, fmt.Errorf("cortex: get a2a message: %w", err) + } + return a2aMessageFromModel(m) +} + +// ListMessages returns a conversation's messages oldest first, because a +// conversation is read as a transcript. +func (s *Store) ListMessages(ctx context.Context, filter *a2a.MessageListFilter) ([]*a2a.Envelope, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aMessageModel + q := s.pgdb.NewSelect(&models).OrderExpr("created_at ASC, id ASC") + exact := filter != nil && filter.Exact + for _, p := range scopePredicates(scope, exact) { + q = q.Where(p.Column+" = ?", p.Value) + } + if filter != nil { + if !filter.ConversationID.IsNil() { + q = q.Where("conversation_id = ?", filter.ConversationID.String()) + } + if filter.Limit > 0 { + q = q.Limit(filter.Limit) + } + if filter.Offset > 0 { + q = q.Offset(filter.Offset) + } + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex: list a2a messages: %w", err) + } + out := make([]*a2a.Envelope, len(models)) + for i := range models { + e, convErr := a2aMessageFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = e + } + return out, nil +} + +// ────────────────────────────────────────────────── +// Conversations +// ────────────────────────────────────────────────── + +func (s *Store) CreateConversation(ctx context.Context, c *a2a.Conversation) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + now := time.Now().UTC() + c.CreatedAt = now + c.UpdatedAt = now + c.Scope = scope + if _, err := s.pgdb.NewInsert(a2aConversationToModel(c)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex: create a2a conversation: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex: create a2a conversation: %w", err) + } + return nil +} + +func (s *Store) GetConversation(ctx context.Context, convID id.ConversationID) (*a2a.Conversation, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + m := new(a2aConversationModel) + q := s.pgdb.NewSelect(m).Where("id = ?", convID.String()) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, a2a.ErrConversationNotFound + } + return nil, fmt.Errorf("cortex: get a2a conversation: %w", err) + } + return a2aConversationFromModel(m) +} + +func (s *Store) UpdateConversation(ctx context.Context, c *a2a.Conversation) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + c.UpdatedAt = time.Now().UTC() + q := s.pgdb.NewUpdate(a2aConversationToModel(c)). + Column(mutableA2AConversationColumns...). + Where("id = ?", c.ID.String()) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return fmt.Errorf("cortex: update a2a conversation: %w", err) + } + n, rowsErr := res.RowsAffected() + if rowsErr != nil { + return fmt.Errorf("cortex: update a2a conversation rows affected: %w", rowsErr) + } + if n == 0 { + return a2a.ErrConversationNotFound + } + return nil +} + +func (s *Store) ListConversations(ctx context.Context, filter *a2a.ConversationListFilter) ([]*a2a.Conversation, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aConversationModel + q := s.pgdb.NewSelect(&models).OrderExpr("created_at DESC, id DESC") + exact := filter != nil && filter.Exact + for _, p := range scopePredicates(scope, exact) { + q = q.Where(p.Column+" = ?", p.Value) + } + if filter != nil { + if filter.Status != "" { + q = q.Where("status = ?", filter.Status) + } + if filter.Limit > 0 { + q = q.Limit(filter.Limit) + } + if filter.Offset > 0 { + q = q.Offset(filter.Offset) + } + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex: list a2a conversations: %w", err) + } + out := make([]*a2a.Conversation, len(models)) + for i := range models { + c, convErr := a2aConversationFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = c + } + return out, nil +} + +// ────────────────────────────────────────────────── +// Deliveries +// ────────────────────────────────────────────────── + +func (s *Store) CreateDelivery(ctx context.Context, d *a2a.Delivery) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + now := time.Now().UTC() + d.CreatedAt = now + d.UpdatedAt = now + d.Scope = scope + if _, err := s.pgdb.NewInsert(a2aDeliveryToModel(d)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex: create a2a delivery: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex: create a2a delivery: %w", err) + } + return nil +} + +func (s *Store) UpdateDelivery(ctx context.Context, d *a2a.Delivery) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + d.UpdatedAt = time.Now().UTC() + q := s.pgdb.NewUpdate(a2aDeliveryToModel(d)). + Column(mutableA2ADeliveryColumns...). + Where("id = ?", d.ID.String()) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return fmt.Errorf("cortex: update a2a delivery: %w", err) + } + n, rowsErr := res.RowsAffected() + if rowsErr != nil { + return fmt.Errorf("cortex: update a2a delivery rows affected: %w", rowsErr) + } + if n == 0 { + return a2a.ErrDeliveryNotFound + } + return nil +} + +// ClaimDelivery takes a queued delivery and marks it delivering. The +// state = 'queued' predicate is the claim: two workers racing both issue +// this UPDATE and exactly one of them changes a row, which is what stops +// one directive starting two runs. +func (s *Store) ClaimDelivery(ctx context.Context, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + now := time.Now().UTC() + q := s.pgdb.NewUpdate((*a2aDeliveryModel)(nil)). + Set("state = ?", a2a.DeliveryDelivering). + Set("claimed_at = ?", now). + Set("updated_at = ?", now). + Where("id = ?", deliveryID.String()). + Where("state = ?", a2a.DeliveryQueued) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return nil, fmt.Errorf("cortex: claim a2a delivery: %w", err) + } + n, rowsErr := res.RowsAffected() + if rowsErr != nil { + return nil, fmt.Errorf("cortex: claim a2a delivery rows affected: %w", rowsErr) + } + if n == 0 { + // Nothing changed for one of two reasons and the caller has to + // tell them apart: losing a race is ordinary, and a delivery id + // nobody minted is a bug further up. + exists, existsErr := s.deliveryExists(ctx, scope, deliveryID) + if existsErr != nil { + return nil, existsErr + } + if exists { + return nil, a2a.ErrDeliveryAlreadyClaimed + } + return nil, a2a.ErrDeliveryNotFound + } + return s.getDelivery(ctx, scope, deliveryID) +} + +// ListInbox returns messages that have ARRIVED for an agent. A queued +// delivery is deliberately excluded: it has not reached anyone yet, and an +// inbox that showed it would be showing mail that is still in transit. +func (s *Store) ListInbox(ctx context.Context, agentName string, filter a2a.InboxFilter) ([]*a2a.Delivery, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aDeliveryModel + q := s.pgdb.NewSelect(&models). + Where("receiver_agent = ?", agentName). + Where("state = ?", a2a.DeliveryDelivered). + OrderExpr("created_at ASC, id ASC") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if filter.UnreadOnly { + q = q.Where("read_at IS NULL") + } + if !filter.ConversationID.IsNil() { + q = q.Where("message_id IN (SELECT id FROM cortex_a2a_messages WHERE conversation_id = ?)", filter.ConversationID.String()) + } + if filter.Limit > 0 { + q = q.Limit(filter.Limit) + } + if filter.Offset > 0 { + q = q.Offset(filter.Offset) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex: list a2a inbox: %w", err) + } + out := make([]*a2a.Delivery, len(models)) + for i := range models { + d, convErr := a2aDeliveryFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = d + } + return out, nil +} + +// ListQueuedDeliveries deliberately does NOT filter by scope. +// +// It is the dispatcher's read, and the dispatcher runs per process rather +// than per tenant: a delivery queued under one scope has to be carried +// even when nobody from that scope is currently calling in. Every row it +// returns carries its own scope, and deliverOne puts that scope back on +// the context before touching anything, so the isolation the rest of this +// file enforces is preserved by the caller instead of by the query. This +// mirrors the sweeper's cross-scope read of expired suspensions. +func (s *Store) ListQueuedDeliveries(ctx context.Context, limit int) ([]*a2a.Delivery, error) { + var models []a2aDeliveryModel + q := s.pgdb.NewSelect(&models). + Where("state = ?", a2a.DeliveryQueued). + OrderExpr("created_at ASC, id ASC") + if limit > 0 { + q = q.Limit(limit) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex: list queued a2a deliveries: %w", err) + } + out := make([]*a2a.Delivery, len(models)) + for i := range models { + d, convErr := a2aDeliveryFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = d + } + return out, nil +} + +func (s *Store) MarkDeliveryRead(ctx context.Context, deliveryID id.DeliveryID) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + now := time.Now().UTC() + q := s.pgdb.NewUpdate((*a2aDeliveryModel)(nil)). + Set("read_at = ?", now). + Set("updated_at = ?", now). + Where("id = ?", deliveryID.String()). + Where("read_at IS NULL") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return fmt.Errorf("cortex: mark a2a delivery read: %w", err) + } + n, rowsErr := res.RowsAffected() + if rowsErr != nil { + return fmt.Errorf("cortex: mark a2a delivery read rows affected: %w", rowsErr) + } + if n == 0 { + // Already read is not an error. Two readers draining one inbox is + // ordinary, and the second one has nothing to report. + exists, existsErr := s.deliveryExists(ctx, scope, deliveryID) + if existsErr != nil { + return existsErr + } + if !exists { + return a2a.ErrDeliveryNotFound + } + } + return nil +} + +// ────────────────────────────────────────────────── +// Pending asks +// ────────────────────────────────────────────────── + +func (s *Store) CreatePendingAsk(ctx context.Context, a *a2a.PendingAsk) error { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return cortex.ErrNoScope + } + now := time.Now().UTC() + a.CreatedAt = now + a.UpdatedAt = now + a.Scope = scope + if _, err := s.pgdb.NewInsert(a2aPendingAskToModel(a)).Exec(ctx); err != nil { + if isUniqueViolation(err) { + return fmt.Errorf("cortex: create a2a pending ask: %w", cortex.ErrAlreadyExists) + } + return fmt.Errorf("cortex: create a2a pending ask: %w", err) + } + return nil +} + +// ClaimPendingAsk takes the ask carrying replyWith, but only if nobody has +// yet. The claimed_at IS NULL predicate is the whole guarantee: a late +// reply, the deadline sweep and a cancel can all reach for one row, and +// exactly one of them changes it and goes on to resume the waiting run. +func (s *Store) ClaimPendingAsk(ctx context.Context, replyWith string) (*a2a.PendingAsk, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + now := time.Now().UTC() + q := s.pgdb.NewUpdate((*a2aPendingAskModel)(nil)). + Set("claimed_at = ?", now). + Set("updated_at = ?", now). + Where("reply_with = ?", replyWith). + Where("claimed_at IS NULL") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + res, err := q.Exec(ctx) + if err != nil { + return nil, fmt.Errorf("cortex: claim a2a pending ask: %w", err) + } + n, rowsErr := res.RowsAffected() + if rowsErr != nil { + return nil, fmt.Errorf("cortex: claim a2a pending ask rows affected: %w", rowsErr) + } + if n == 0 { + exists, existsErr := s.pendingAskExists(ctx, scope, replyWith) + if existsErr != nil { + return nil, existsErr + } + if exists { + return nil, a2a.ErrAskAlreadyClaimed + } + return nil, a2a.ErrAskNotFound + } + return s.getPendingAsk(ctx, scope, replyWith) +} + +// ListExpiredAsks returns unclaimed asks past their deadline. Claimed rows +// are excluded, which is what makes sweeping the same backlog twice safe. +func (s *Store) ListExpiredAsks(ctx context.Context, now time.Time, limit int) ([]*a2a.PendingAsk, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aPendingAskModel + q := s.pgdb.NewSelect(&models). + Where("claimed_at IS NULL"). + Where("deadline IS NOT NULL"). + Where("deadline <= ?", now.UTC()). + OrderExpr("deadline ASC") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if limit > 0 { + q = q.Limit(limit) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex: list expired a2a asks: %w", err) + } + return a2aPendingAsksFromModels(models) +} + +func (s *Store) ListPendingAsksByConversation(ctx context.Context, convID id.ConversationID) ([]*a2a.PendingAsk, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + var models []a2aPendingAskModel + q := s.pgdb.NewSelect(&models). + Where("conversation_id = ?", convID.String()). + Where("claimed_at IS NULL"). + OrderExpr("created_at ASC") + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + return nil, fmt.Errorf("cortex: list a2a asks by conversation: %w", err) + } + return a2aPendingAsksFromModels(models) +} + +// ────────────────────────────────────────────────── +// Helpers +// ────────────────────────────────────────────────── + +func a2aPendingAsksFromModels(models []a2aPendingAskModel) ([]*a2a.PendingAsk, error) { + out := make([]*a2a.PendingAsk, len(models)) + for i := range models { + a, convErr := a2aPendingAskFromModel(&models[i]) + if convErr != nil { + return nil, convErr + } + out[i] = a + } + return out, nil +} + +func (s *Store) getDelivery(ctx context.Context, scope cortex.Scope, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + m := new(a2aDeliveryModel) + q := s.pgdb.NewSelect(m).Where("id = ?", deliveryID.String()) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, a2a.ErrDeliveryNotFound + } + return nil, fmt.Errorf("cortex: get a2a delivery: %w", err) + } + return a2aDeliveryFromModel(m) +} + +func (s *Store) deliveryExists(ctx context.Context, scope cortex.Scope, deliveryID id.DeliveryID) (bool, error) { + _, err := s.getDelivery(ctx, scope, deliveryID) + switch { + case err == nil: + return true, nil + case errors.Is(err, a2a.ErrDeliveryNotFound): + return false, nil + default: + return false, err + } +} + +func (s *Store) getPendingAsk(ctx context.Context, scope cortex.Scope, replyWith string) (*a2a.PendingAsk, error) { + m := new(a2aPendingAskModel) + q := s.pgdb.NewSelect(m).Where("reply_with = ?", replyWith) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, a2a.ErrAskNotFound + } + return nil, fmt.Errorf("cortex: get a2a pending ask: %w", err) + } + return a2aPendingAskFromModel(m) +} + +func (s *Store) pendingAskExists(ctx context.Context, scope cortex.Scope, replyWith string) (bool, error) { + _, err := s.getPendingAsk(ctx, scope, replyWith) + switch { + case err == nil: + return true, nil + case errors.Is(err, a2a.ErrAskNotFound): + return false, nil + default: + return false, err + } +} diff --git a/store/postgres/migrations.go b/store/postgres/migrations.go index 47a89ef..0c5017b 100644 --- a/store/postgres/migrations.go +++ b/store/postgres/migrations.go @@ -1229,6 +1229,130 @@ CREATE UNIQUE INDEX IF NOT EXISTS idx_cortex_overlays_agent_scope return err }, }, + &migrate.Migration{ + Name: "create_a2a", + Version: "20260826000003", + Comment: "Create the four cortex_a2a_* tables: messages, conversations, deliveries and pending asks", + Up: func(ctx context.Context, exec migrate.Executor) error { + // Scope columns are here from the start, unlike the older + // tables that had them added later: nothing has ever + // written an unscoped a2a row, so there is no backfill to + // do and no scope migration to follow. + // + // reply_with is the primary key of the pending-ask table + // rather than a surrogate id, and that is load-bearing: + // ClaimPendingAsk resolves exactly one waiting run, and + // two rows sharing a token would let one reply resume a + // run that never asked. + _, err := exec.Exec(ctx, ` +CREATE TABLE IF NOT EXISTS cortex_a2a_messages ( + id TEXT PRIMARY KEY, + performative TEXT NOT NULL, + sender_agent TEXT NOT NULL, + sender_node TEXT NOT NULL DEFAULT '', + receivers JSONB NOT NULL DEFAULT '[]', + reply_to JSONB NOT NULL DEFAULT '[]', + content TEXT NOT NULL DEFAULT '', + language TEXT NOT NULL DEFAULT '', + encoding TEXT NOT NULL DEFAULT '', + ontology TEXT NOT NULL DEFAULT '', + protocol TEXT NOT NULL DEFAULT '', + conversation_id TEXT NOT NULL DEFAULT '', + reply_with TEXT NOT NULL DEFAULT '', + in_reply_to TEXT NOT NULL DEFAULT '', + reply_by TIMESTAMPTZ, + hops INTEGER NOT NULL DEFAULT 0, + origin_run_id TEXT NOT NULL DEFAULT '', + metadata JSONB NOT NULL DEFAULT '{}', + scope_l0 TEXT NOT NULL DEFAULT '', + scope_l1 TEXT NOT NULL DEFAULT '', + scope_l2 TEXT NOT NULL DEFAULT '', + scope_extra JSONB NOT NULL DEFAULT '{}', + scope_canon TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_messages_scope_conv ON cortex_a2a_messages (scope_canon, conversation_id); + +CREATE TABLE IF NOT EXISTS cortex_a2a_conversations ( + id TEXT PRIMARY KEY, + protocol TEXT NOT NULL DEFAULT '', + initiator_agent TEXT NOT NULL DEFAULT '', + initiator_node TEXT NOT NULL DEFAULT '', + participants JSONB NOT NULL DEFAULT '[]', + status TEXT NOT NULL DEFAULT 'open', + hop_ceiling INTEGER NOT NULL DEFAULT 0, + hops_used INTEGER NOT NULL DEFAULT 0, + deadline TIMESTAMPTZ, + scope_l0 TEXT NOT NULL DEFAULT '', + scope_l1 TEXT NOT NULL DEFAULT '', + scope_l2 TEXT NOT NULL DEFAULT '', + scope_extra JSONB NOT NULL DEFAULT '{}', + scope_canon TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_conversations_scope_status ON cortex_a2a_conversations (scope_canon, status); + +CREATE TABLE IF NOT EXISTS cortex_a2a_deliveries ( + id TEXT PRIMARY KEY, + message_id TEXT NOT NULL, + receiver_agent TEXT NOT NULL, + receiver_node TEXT NOT NULL DEFAULT '', + state TEXT NOT NULL DEFAULT 'queued', + error TEXT NOT NULL DEFAULT '', + claimed_at TIMESTAMPTZ, + delivered_at TIMESTAMPTZ, + read_at TIMESTAMPTZ, + run_id TEXT NOT NULL DEFAULT '', + scope_l0 TEXT NOT NULL DEFAULT '', + scope_l1 TEXT NOT NULL DEFAULT '', + scope_l2 TEXT NOT NULL DEFAULT '', + scope_extra JSONB NOT NULL DEFAULT '{}', + scope_canon TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_deliveries_inbox ON cortex_a2a_deliveries (scope_canon, receiver_agent, state); +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_deliveries_state ON cortex_a2a_deliveries (state); + +CREATE TABLE IF NOT EXISTS cortex_a2a_pending_asks ( + reply_with TEXT PRIMARY KEY, + conversation_id TEXT NOT NULL DEFAULT '', + message_id TEXT NOT NULL DEFAULT '', + asker_run_id TEXT NOT NULL DEFAULT '', + asker_agent TEXT NOT NULL DEFAULT '', + tool_call_id TEXT NOT NULL DEFAULT '', + expected_agent TEXT NOT NULL DEFAULT '', + expected_node TEXT NOT NULL DEFAULT '', + deadline TIMESTAMPTZ, + claimed_at TIMESTAMPTZ, + scope_l0 TEXT NOT NULL DEFAULT '', + scope_l1 TEXT NOT NULL DEFAULT '', + scope_l2 TEXT NOT NULL DEFAULT '', + scope_extra JSONB NOT NULL DEFAULT '{}', + scope_canon TEXT NOT NULL DEFAULT '', + created_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP, + updated_at TIMESTAMPTZ NOT NULL DEFAULT CURRENT_TIMESTAMP +); + +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_pending_asks_deadline ON cortex_a2a_pending_asks (claimed_at, deadline); +`) + return err + }, + Down: func(ctx context.Context, exec migrate.Executor) error { + _, err := exec.Exec(ctx, ` +DROP TABLE IF EXISTS cortex_a2a_pending_asks; +DROP TABLE IF EXISTS cortex_a2a_deliveries; +DROP TABLE IF EXISTS cortex_a2a_conversations; +DROP TABLE IF EXISTS cortex_a2a_messages; +`) + return err + }, + }, ) return g }() diff --git a/store/postgres/models.go b/store/postgres/models.go index bd3c97d..5b405be 100644 --- a/store/postgres/models.go +++ b/store/postgres/models.go @@ -8,6 +8,7 @@ import ( "github.com/xraph/grove" "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" "github.com/xraph/cortex/agent" "github.com/xraph/cortex/behavior" "github.com/xraph/cortex/checkpoint" @@ -1325,3 +1326,350 @@ func stringsOrEmpty(v []string) []string { } return v } + +// ────────────────────────────────────────────────── +// a2a models +// ────────────────────────────────────────────────── + +type a2aMessageModel struct { + grove.BaseModel `grove:"table:cortex_a2a_messages"` + ID string `grove:"id,pk"` + Performative string `grove:"performative,notnull"` + SenderAgent string `grove:"sender_agent,notnull"` + SenderNode string `grove:"sender_node,notnull"` + Receivers string `grove:"receivers,type:jsonb,notnull"` + ReplyTo string `grove:"reply_to,type:jsonb,notnull"` + Content string `grove:"content,notnull"` + Language string `grove:"language,notnull"` + Encoding string `grove:"encoding,notnull"` + Ontology string `grove:"ontology,notnull"` + Protocol string `grove:"protocol,notnull"` + ConversationID string `grove:"conversation_id,notnull"` + ReplyWith string `grove:"reply_with,notnull"` + InReplyTo string `grove:"in_reply_to,notnull"` + ReplyBy *time.Time `grove:"reply_by"` + Hops int `grove:"hops,notnull"` + OriginRunID string `grove:"origin_run_id,notnull"` + Metadata string `grove:"metadata,type:jsonb,notnull"` + ScopeL0 string `grove:"scope_l0,notnull"` + ScopeL1 string `grove:"scope_l1,notnull"` + ScopeL2 string `grove:"scope_l2,notnull"` + ScopeExtra map[string]string `grove:"scope_extra,type:jsonb,notnull"` + ScopeCanon string `grove:"scope_canon,notnull"` + CreatedAt time.Time `grove:"created_at,notnull,default:current_timestamp"` + UpdatedAt time.Time `grove:"updated_at,notnull,default:current_timestamp"` +} + +func a2aMessageToModel(e *a2a.Envelope) *a2aMessageModel { + l0, l1, l2, extra := scopeColumns(e.Scope) + return &a2aMessageModel{ + ID: e.ID.String(), + Performative: string(e.Performative), + SenderAgent: e.Sender.Agent, + SenderNode: e.Sender.Node, + Receivers: mustJSON(e.Receivers), + ReplyTo: mustJSON(e.ReplyTo), + Content: e.Content, + Language: e.Language, + Encoding: e.Encoding, + Ontology: e.Ontology, + Protocol: e.Protocol, + ConversationID: e.ConversationID.String(), + ReplyWith: e.ReplyWith, + InReplyTo: e.InReplyTo, + ReplyBy: e.ReplyBy, + Hops: e.Hops, + OriginRunID: e.OriginRunID.String(), + Metadata: mustJSON(e.Metadata), + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: e.Scope.Canonical(), + CreatedAt: e.CreatedAt, + UpdatedAt: e.UpdatedAt, + } +} + +func a2aMessageFromModel(m *a2aMessageModel) (*a2a.Envelope, error) { + msgID, err := id.ParseWithPrefix(m.ID, id.PrefixMessage) + if err != nil { + return nil, err + } + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a message %s: %w", msgID, err) + } + e := &a2a.Envelope{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: msgID, + Scope: scope, + Performative: a2a.Performative(m.Performative), + Sender: a2a.Address{Agent: m.SenderAgent, Node: m.SenderNode}, + Content: m.Content, + Language: m.Language, + Encoding: m.Encoding, + Ontology: m.Ontology, + Protocol: m.Protocol, + ReplyWith: m.ReplyWith, + InReplyTo: m.InReplyTo, + ReplyBy: m.ReplyBy, + Hops: m.Hops, + } + if m.ConversationID != "" { + convID, convErr := id.ParseWithPrefix(m.ConversationID, id.PrefixConversation) + if convErr != nil { + return nil, fmt.Errorf("a2a message %s: conversation id: %w", msgID, convErr) + } + e.ConversationID = convID + } + if m.OriginRunID != "" { + runID, runErr := id.ParseWithPrefix(m.OriginRunID, id.PrefixAgentRun) + if runErr != nil { + return nil, fmt.Errorf("a2a message %s: origin run id: %w", msgID, runErr) + } + e.OriginRunID = runID + } + for _, f := range []struct { + name string + data string + dest any + }{ + {"receivers", m.Receivers, &e.Receivers}, + {"reply_to", m.ReplyTo, &e.ReplyTo}, + {"metadata", m.Metadata, &e.Metadata}, + } { + if err := unmarshalField(f.name, f.data, f.dest); err != nil { + return nil, err + } + } + return e, nil +} + +type a2aConversationModel struct { + grove.BaseModel `grove:"table:cortex_a2a_conversations"` + ID string `grove:"id,pk"` + Protocol string `grove:"protocol,notnull"` + InitiatorAgent string `grove:"initiator_agent,notnull"` + InitiatorNode string `grove:"initiator_node,notnull"` + Participants string `grove:"participants,type:jsonb,notnull"` + Status string `grove:"status,notnull"` + HopCeiling int `grove:"hop_ceiling,notnull"` + HopsUsed int `grove:"hops_used,notnull"` + Deadline *time.Time `grove:"deadline"` + ScopeL0 string `grove:"scope_l0,notnull"` + ScopeL1 string `grove:"scope_l1,notnull"` + ScopeL2 string `grove:"scope_l2,notnull"` + ScopeExtra map[string]string `grove:"scope_extra,type:jsonb,notnull"` + ScopeCanon string `grove:"scope_canon,notnull"` + CreatedAt time.Time `grove:"created_at,notnull,default:current_timestamp"` + UpdatedAt time.Time `grove:"updated_at,notnull,default:current_timestamp"` +} + +func a2aConversationToModel(c *a2a.Conversation) *a2aConversationModel { + l0, l1, l2, extra := scopeColumns(c.Scope) + return &a2aConversationModel{ + ID: c.ID.String(), + Protocol: c.Protocol, + InitiatorAgent: c.Initiator.Agent, + InitiatorNode: c.Initiator.Node, + Participants: mustJSON(c.Participants), + Status: c.Status, + HopCeiling: c.HopCeiling, + HopsUsed: c.HopsUsed, + Deadline: c.Deadline, + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: c.Scope.Canonical(), + CreatedAt: c.CreatedAt, + UpdatedAt: c.UpdatedAt, + } +} + +func a2aConversationFromModel(m *a2aConversationModel) (*a2a.Conversation, error) { + convID, err := id.ParseWithPrefix(m.ID, id.PrefixConversation) + if err != nil { + return nil, err + } + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a conversation %s: %w", convID, err) + } + c := &a2a.Conversation{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: convID, + Scope: scope, + Protocol: m.Protocol, + Initiator: a2a.Address{Agent: m.InitiatorAgent, Node: m.InitiatorNode}, + Status: m.Status, + HopCeiling: m.HopCeiling, + HopsUsed: m.HopsUsed, + Deadline: m.Deadline, + } + if err := unmarshalField("participants", m.Participants, &c.Participants); err != nil { + return nil, err + } + return c, nil +} + +type a2aDeliveryModel struct { + grove.BaseModel `grove:"table:cortex_a2a_deliveries"` + ID string `grove:"id,pk"` + MessageID string `grove:"message_id,notnull"` + ReceiverAgent string `grove:"receiver_agent,notnull"` + ReceiverNode string `grove:"receiver_node,notnull"` + State string `grove:"state,notnull"` + Error string `grove:"error,notnull"` + ClaimedAt *time.Time `grove:"claimed_at"` + DeliveredAt *time.Time `grove:"delivered_at"` + ReadAt *time.Time `grove:"read_at"` + RunID string `grove:"run_id,notnull"` + ScopeL0 string `grove:"scope_l0,notnull"` + ScopeL1 string `grove:"scope_l1,notnull"` + ScopeL2 string `grove:"scope_l2,notnull"` + ScopeExtra map[string]string `grove:"scope_extra,type:jsonb,notnull"` + ScopeCanon string `grove:"scope_canon,notnull"` + CreatedAt time.Time `grove:"created_at,notnull,default:current_timestamp"` + UpdatedAt time.Time `grove:"updated_at,notnull,default:current_timestamp"` +} + +func a2aDeliveryToModel(d *a2a.Delivery) *a2aDeliveryModel { + l0, l1, l2, extra := scopeColumns(d.Scope) + return &a2aDeliveryModel{ + ID: d.ID.String(), + MessageID: d.MessageID.String(), + ReceiverAgent: d.Receiver.Agent, + ReceiverNode: d.Receiver.Node, + State: d.State, + Error: d.Error, + DeliveredAt: d.DeliveredAt, + ReadAt: d.ReadAt, + RunID: d.RunID.String(), + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: d.Scope.Canonical(), + CreatedAt: d.CreatedAt, + UpdatedAt: d.UpdatedAt, + } +} + +func a2aDeliveryFromModel(m *a2aDeliveryModel) (*a2a.Delivery, error) { + dlvID, err := id.ParseWithPrefix(m.ID, id.PrefixDelivery) + if err != nil { + return nil, err + } + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a delivery %s: %w", dlvID, err) + } + msgID, err := id.ParseWithPrefix(m.MessageID, id.PrefixMessage) + if err != nil { + return nil, fmt.Errorf("a2a delivery %s: message id: %w", dlvID, err) + } + d := &a2a.Delivery{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: dlvID, + Scope: scope, + MessageID: msgID, + Receiver: a2a.Address{Agent: m.ReceiverAgent, Node: m.ReceiverNode}, + State: m.State, + Error: m.Error, + DeliveredAt: m.DeliveredAt, + ReadAt: m.ReadAt, + } + if m.RunID != "" { + runID, runErr := id.ParseWithPrefix(m.RunID, id.PrefixAgentRun) + if runErr != nil { + return nil, fmt.Errorf("a2a delivery %s: run id: %w", dlvID, runErr) + } + d.RunID = runID + } + return d, nil +} + +type a2aPendingAskModel struct { + grove.BaseModel `grove:"table:cortex_a2a_pending_asks"` + ReplyWith string `grove:"reply_with,pk"` + ConversationID string `grove:"conversation_id,notnull"` + MessageID string `grove:"message_id,notnull"` + AskerRunID string `grove:"asker_run_id,notnull"` + AskerAgent string `grove:"asker_agent,notnull"` + ToolCallID string `grove:"tool_call_id,notnull"` + ExpectedAgent string `grove:"expected_agent,notnull"` + ExpectedNode string `grove:"expected_node,notnull"` + Deadline *time.Time `grove:"deadline"` + ClaimedAt *time.Time `grove:"claimed_at"` + ScopeL0 string `grove:"scope_l0,notnull"` + ScopeL1 string `grove:"scope_l1,notnull"` + ScopeL2 string `grove:"scope_l2,notnull"` + ScopeExtra map[string]string `grove:"scope_extra,type:jsonb,notnull"` + ScopeCanon string `grove:"scope_canon,notnull"` + CreatedAt time.Time `grove:"created_at,notnull,default:current_timestamp"` + UpdatedAt time.Time `grove:"updated_at,notnull,default:current_timestamp"` +} + +func a2aPendingAskToModel(a *a2a.PendingAsk) *a2aPendingAskModel { + l0, l1, l2, extra := scopeColumns(a.Scope) + return &a2aPendingAskModel{ + ReplyWith: a.ReplyWith, + ConversationID: a.ConversationID.String(), + MessageID: a.MessageID.String(), + AskerRunID: a.AskerRunID.String(), + AskerAgent: a.AskerAgent, + ToolCallID: a.ToolCallID, + ExpectedAgent: a.Expected.Agent, + ExpectedNode: a.Expected.Node, + Deadline: a.Deadline, + ClaimedAt: a.ClaimedAt, + ScopeL0: l0, + ScopeL1: l1, + ScopeL2: l2, + ScopeExtra: extra, + ScopeCanon: a.Scope.Canonical(), + CreatedAt: a.CreatedAt, + UpdatedAt: a.UpdatedAt, + } +} + +func a2aPendingAskFromModel(m *a2aPendingAskModel) (*a2a.PendingAsk, error) { + scope, err := cortex.ParseCanonical(m.ScopeCanon) + if err != nil { + return nil, fmt.Errorf("a2a pending ask %s: %w", m.ReplyWith, err) + } + a := &a2a.PendingAsk{ + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + Scope: scope, + ReplyWith: m.ReplyWith, + AskerAgent: m.AskerAgent, + ToolCallID: m.ToolCallID, + Expected: a2a.Address{Agent: m.ExpectedAgent, Node: m.ExpectedNode}, + Deadline: m.Deadline, + ClaimedAt: m.ClaimedAt, + } + if m.ConversationID != "" { + convID, convErr := id.ParseWithPrefix(m.ConversationID, id.PrefixConversation) + if convErr != nil { + return nil, fmt.Errorf("a2a pending ask %s: conversation id: %w", m.ReplyWith, convErr) + } + a.ConversationID = convID + } + if m.MessageID != "" { + msgID, msgErr := id.ParseWithPrefix(m.MessageID, id.PrefixMessage) + if msgErr != nil { + return nil, fmt.Errorf("a2a pending ask %s: message id: %w", m.ReplyWith, msgErr) + } + a.MessageID = msgID + } + if m.AskerRunID != "" { + runID, runErr := id.ParseWithPrefix(m.AskerRunID, id.PrefixAgentRun) + if runErr != nil { + return nil, fmt.Errorf("a2a pending ask %s: asker run id: %w", m.ReplyWith, runErr) + } + a.AskerRunID = runID + } + return a, nil +} diff --git a/store/postgres/rescope.go b/store/postgres/rescope.go index ce0eb56..4348713 100644 --- a/store/postgres/rescope.go +++ b/store/postgres/rescope.go @@ -139,7 +139,7 @@ FROM information_schema.columns WHERE table_schema = current_schema() AND table_name LIKE 'cortex\_%' ESCAPE '\' AND column_name = ANY($1)`, - []string{"scope_canon", "name", "app_id", "tenant_id", "run_id", "agent_id", "kind", "key"}) + []string{"scope_canon", "id", "name", "app_id", "tenant_id", "run_id", "agent_id", "kind", "key"}) if err != nil { return nil, fmt.Errorf("list cortex table columns: %w", err) } @@ -165,6 +165,15 @@ WHERE table_schema = current_schema() if !c["scope_canon"] { continue } + // A scoped table with no id column is one this pass cannot key a + // row by, and also one that never needs to: the tables predating + // scope all carry a TypeID id, while a table keyed by something + // else (cortex_a2a_pending_asks, keyed by its reply-with token) + // was born with its scope columns and has no legacy rows to + // backfill. Skipping it is therefore not a gap. + if !c["id"] { + continue + } shapes[table] = tableShape{ hasName: c["name"], hasAppID: c["app_id"], From 64ee0718870e70f1a457ea1ffe99866a3ecb2a3c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:53:05 -0500 Subject: [PATCH 19/50] feat(engine): add the agent-reply suspension reason and gate its resume A run waiting on a peer is not the caller's to answer. The approved bool becomes a resumeSource, because two authorities fit in a boolean and three do not: not-approved would have had to mean both an ordinary caller and the message bus, which are the two that most need telling apart. --- engine/a2a_resume_test.go | 61 ++++++++++++++++++++++++++++++++++++ engine/engine.go | 2 +- engine/resume.go | 66 ++++++++++++++++++++++++++++++++------- suspension/suspension.go | 10 ++++++ 4 files changed, 126 insertions(+), 13 deletions(-) create mode 100644 engine/a2a_resume_test.go diff --git a/engine/a2a_resume_test.go b/engine/a2a_resume_test.go new file mode 100644 index 0000000..d550fa4 --- /dev/null +++ b/engine/a2a_resume_test.go @@ -0,0 +1,61 @@ +package engine + +import ( + "errors" + "testing" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/suspension" +) + +// The three resume sources have three different authorities, and the +// table is the whole contract: a public caller may answer an external +// tool and nothing else. +func TestCheckResumeAuthority(t *testing.T) { + cases := []struct { + name string + reason suspension.SuspendReason + source resumeSource + want error + }{ + {"public answers an external tool", suspension.ReasonExternalTool, resumeSourcePublic, nil}, + {"public may not answer an approval", suspension.ReasonApproval, resumeSourcePublic, cortex.ErrRequiresApproval}, + {"a checkpoint may answer an approval", suspension.ReasonApproval, resumeSourceApproval, nil}, + + // A run waiting on a peer is not the caller's to answer. Letting a + // host do it would forge a message the peer never sent, and the + // correlation ledger that decides a reply is genuine would become + // decoration. + {"public may not answer an agent reply", suspension.ReasonAgentReply, resumeSourcePublic, ErrNotAgentReplyResumable}, + {"a checkpoint may not answer an agent reply", suspension.ReasonAgentReply, resumeSourceApproval, ErrNotAgentReplyResumable}, + {"the bus may answer an agent reply", suspension.ReasonAgentReply, resumeSourceAgentReply, nil}, + + // The bus holds a ledger row for an agent-reply pause and nothing + // else, so it has no business answering the other two. + {"the bus may not answer an approval", suspension.ReasonApproval, resumeSourceAgentReply, cortex.ErrRequiresApproval}, + } + + for _, tc := range cases { + t.Run(tc.name, func(t *testing.T) { + err := checkResumeAuthority(tc.reason, tc.source) + switch { + case tc.want == nil && err != nil: + t.Fatalf("err = %v, want nil", err) + case tc.want != nil && !errors.Is(err, tc.want): + t.Fatalf("err = %v, want %v", err, tc.want) + } + }) + } +} + +func TestAgentReplyReasonIsItsOwnThing(t *testing.T) { + // External says the CALLER executes the call and reports back. + // Agent-reply says cortex is waiting on a peer. Sharing one value + // would tell a host to go execute something on the bus's behalf. + if suspension.ReasonAgentReply == suspension.ReasonExternalTool { + t.Fatal("agent-reply must not collapse into the external-tool reason") + } + if suspension.ReasonAgentReply == "" { + t.Fatal("the reason needs a stored value") + } +} diff --git a/engine/engine.go b/engine/engine.go index d64dba5..a9d44fe 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -649,7 +649,7 @@ func (e *Engine) resumeApproved(ctx context.Context, cp *checkpoint.Checkpoint) // resume rather than Resume: this is the one caller allowed to say a // checkpoint approved these calls, and it says so having just read a // pending checkpoint for this run. - if _, err := e.resume(ctx, cp.RunID, in, true); err != nil { + if _, err := e.resume(ctx, cp.RunID, in, resumeSourceApproval); err != nil { return fmt.Errorf("resume approved run %s: %w", cp.RunID, err) } return nil diff --git a/engine/resume.go b/engine/resume.go index df05ed4..2cfed37 100644 --- a/engine/resume.go +++ b/engine/resume.go @@ -16,6 +16,10 @@ import ( "github.com/xraph/cortex/suspension" ) +// ErrNotAgentReplyResumable is returned when something other than the +// messaging bus tries to answer a run that is waiting on a peer agent. +var ErrNotAgentReplyResumable = errors.New("cortex: run is waiting on an agent reply") + // ToolResult is one pending call's outcome, reported by whoever executed // it while the run was paused. // @@ -81,14 +85,34 @@ type resumption struct { // or a rebuilt message list are three different ways to corrupt a run // that looked fine at the call site and went wrong several turns later. func (e *Engine) Resume(ctx context.Context, runID id.AgentRunID, in ResumeInput) (*run.Run, error) { - return e.resume(ctx, runID, in, false) + return e.resume(ctx, runID, in, resumeSourcePublic) } +// resumeSource is who is answering a paused run, which is the thing a +// public caller must not be able to claim for itself. +// +// It replaced an `approved bool` when agent-reply pauses arrived. Two +// callers with two authorities was expressible as a boolean; three is +// where a boolean starts lying, because "not approved" would have to mean +// both "an ordinary caller" and "the message bus", which are the two +// things that most need telling apart. +type resumeSource int + +const ( + // resumeSourcePublic is an ordinary caller through Resume. + resumeSourcePublic resumeSource = iota + // resumeSourceApproval is ResolveCheckpoint, reaching here having + // read a pending checkpoint for the run. + resumeSourceApproval + // resumeSourceAgentReply is the messaging bus, reaching here having + // claimed the pending-ask row that proves the reply is genuine. + resumeSourceAgentReply +) + // resume is Resume with the one thing a public caller must not be able to -// say: whether a checkpoint approved this. Only ResolveCheckpoint passes -// true, and it does so having just read a pending checkpoint for the run. -func (e *Engine) resume(ctx context.Context, runID id.AgentRunID, in ResumeInput, approved bool) (*run.Run, error) { - ctx, rz, err := e.claimForResume(ctx, runID, in, approved) +// say: which authority this is arriving under. +func (e *Engine) resume(ctx context.Context, runID id.AgentRunID, in ResumeInput, source resumeSource) (*run.Run, error) { + ctx, rz, err := e.claimForResume(ctx, runID, in, source) if err != nil { return nil, err } @@ -98,7 +122,7 @@ func (e *Engine) resume(ctx context.Context, runID id.AgentRunID, in ResumeInput // ResumeStream is Resume over the streaming loop. The channel is closed // when execution completes, same as StreamAgent's. func (e *Engine) ResumeStream(ctx context.Context, runID id.AgentRunID, in ResumeInput, events chan<- StreamEvent) error { - ctx, rz, err := e.claimForResume(ctx, runID, in, false) + ctx, rz, err := e.claimForResume(ctx, runID, in, resumeSourcePublic) if err != nil { close(events) return err @@ -132,7 +156,7 @@ func (e *Engine) ResumeStream(ctx context.Context, runID id.AgentRunID, in Resum // the run. Returning an error with the run left running would be the // wedge the claim's expiry guard exists to prevent, reached from the // other side. -func (e *Engine) claimForResume(ctx context.Context, runID id.AgentRunID, in ResumeInput, approved bool) (context.Context, *resumption, error) { +func (e *Engine) claimForResume(ctx context.Context, runID id.AgentRunID, in ResumeInput, source resumeSource) (context.Context, *resumption, error) { if e.store == nil { return nil, nil, cortex.ErrNoStore } @@ -151,7 +175,7 @@ func (e *Engine) claimForResume(ctx context.Context, runID id.AgentRunID, in Res if vErr := validateResults(susp.Pending, in.ToolResults); vErr != nil { return nil, nil, vErr } - if aErr := checkResumeAuthority(susp.Reason, approved); aErr != nil { + if aErr := checkResumeAuthority(susp.Reason, source); aErr != nil { return nil, nil, aErr } } @@ -192,7 +216,7 @@ func (e *Engine) claimForResume(ctx context.Context, runID id.AgentRunID, in Res if err := validateResults(susp.Pending, in.ToolResults); err != nil { return nil, nil, e.failResume(ctx, r, ag.ID, err) } - if err := checkResumeAuthority(susp.Reason, approved); err != nil { + if err := checkResumeAuthority(susp.Reason, source); err != nil { return nil, nil, e.failResume(ctx, r, ag.ID, err) } @@ -448,11 +472,29 @@ func (e *Engine) stepForPendingCalls(ctx context.Context, runID id.AgentRunID, n // other pause is unaffected: an external-tool suspension never went to a // human in the first place, and Resume is exactly how it is meant to be // answered. -func checkResumeAuthority(reason suspension.SuspendReason, approved bool) error { - if approved || reason != suspension.ReasonApproval { +func checkResumeAuthority(reason suspension.SuspendReason, source resumeSource) error { + switch reason { + case suspension.ReasonApproval: + if source == resumeSourceApproval { + return nil + } + return fmt.Errorf("%w: this run is waiting on a checkpoint decision, so resolve the checkpoint rather than resuming the run", cortex.ErrRequiresApproval) + + case suspension.ReasonAgentReply: + // The same reasoning as an approval pause, with a different + // authority behind it. A run waiting on a peer is answered by + // whoever holds the ledger row proving the reply is genuine, and + // that is the bus. A caller answering it here would feed the + // model an answer no agent gave, and the run would carry on with + // nobody the wiser. + if source == resumeSourceAgentReply { + return nil + } + return fmt.Errorf("%w: this run is waiting on another agent's reply, which only the messaging bus can deliver", ErrNotAgentReplyResumable) + + default: return nil } - return fmt.Errorf("%w: this run is waiting on a checkpoint decision, so resolve the checkpoint rather than resuming the run", cortex.ErrRequiresApproval) } // validateResults enforces the bijection between pending calls and the diff --git a/suspension/suspension.go b/suspension/suspension.go index 6a927ce..7c074c9 100644 --- a/suspension/suspension.go +++ b/suspension/suspension.go @@ -23,6 +23,16 @@ const ( // ReasonExternalTool means the caller, not the engine, executes the // pending tool call and reports the result back. ReasonExternalTool SuspendReason = "external_tool" + // ReasonAgentReply means the run is waiting on another agent's answer. + // + // It is not ReasonExternalTool even though both wait on something + // outside the loop, because the two say different things about who + // acts next. External says the CALLER executes the call and reports + // back. Agent-reply says cortex itself is waiting on a peer, and a + // caller answering it would be forging a message that peer never + // sent. The messaging bus resumes these, off its own correlation + // ledger; nobody else may. + ReasonAgentReply SuspendReason = "agent_reply" ) // PendingCall is one tool call awaiting a result from outside the engine. From ca479f3fe544dba47ef9abb883f6e7128f43b1eb Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 19:58:55 -0500 Subject: [PATCH 20/50] feat(engine): let a builtin pend, and add the three a2a tools executeBuiltinTool could only report a completed call, which is fine for knowledge_search and wrong for agent_ask: the ask sends a question and the step has to suspend around it. The builtin contract grows an outcome and the loop picks the suspend reason from what pended. The bus also grew a resolver seam. Routability only ever said a transport knew the shape of an address, so an ask addressed to a typo suspended the asking run against a recipient that would never answer. Now it comes back as an error the model can act on. --- a2a/bus.go | 22 +++ a2a/seams.go | 14 ++ engine/a2a_tools.go | 316 +++++++++++++++++++++++++++++++++++++++ engine/a2a_tools_test.go | 245 ++++++++++++++++++++++++++++++ engine/a2a_wiring.go | 178 ++++++++++++++++++++++ engine/engine.go | 20 ++- engine/react.go | 19 ++- engine/tools.go | 25 +++- plugin/plugin.go | 28 ++++ plugin/registry.go | 55 +++++++ 10 files changed, 912 insertions(+), 10 deletions(-) create mode 100644 engine/a2a_tools.go create mode 100644 engine/a2a_tools_test.go create mode 100644 engine/a2a_wiring.go diff --git a/a2a/bus.go b/a2a/bus.go index 5b2cc32..d9449dd 100644 --- a/a2a/bus.go +++ b/a2a/bus.go @@ -24,6 +24,9 @@ var ( ErrHopCeiling = errors.New("cortex: a2a: conversation hop ceiling exceeded") // ErrUnroutable means no transport handles the receiver's address. ErrUnroutable = errors.New("cortex: a2a: no transport handles that address") + // ErrUnknownReceiver means the address is routable in shape but names + // no agent the sender can reach. + ErrUnknownReceiver = errors.New("cortex: a2a: receiver does not resolve") ) // BusConfig builds a Bus. Store and Runner are required; everything else @@ -32,6 +35,7 @@ type BusConfig struct { Store Store Runner cortex.AgentRunner Resumer Resumer + Resolver Resolver Hooks HookEmitter Clock Clock Transports []Transport @@ -47,6 +51,7 @@ type Bus struct { store Store runner cortex.AgentRunner resumer Resumer + resolver Resolver hooks HookEmitter clock Clock transports []Transport @@ -72,6 +77,7 @@ func NewBus(cfg BusConfig) (*Bus, error) { store: cfg.Store, runner: cfg.Runner, resumer: cfg.Resumer, + resolver: cfg.Resolver, hooks: cfg.Hooks, clock: cfg.Clock, transports: cfg.Transports, @@ -165,6 +171,9 @@ func (b *Bus) prepare(ctx context.Context, p SendParams) (*Envelope, *Conversati if !b.routable(r) { return nil, nil, fmt.Errorf("%w: %s", ErrUnroutable, r) } + if resolveErr := b.resolve(ctx, r); resolveErr != nil { + return nil, nil, resolveErr + } } conv, err := b.resolveConversation(ctx, p, scope) @@ -254,6 +263,19 @@ func (b *Bus) submit(ctx context.Context, e *Envelope, conv *Conversation) (*Sen return res, nil } +// resolve asks the host whether the receiver exists. A bus with no +// resolver skips the question, which is what the package's own tests do: +// they have no agents, only a fake runner that answers to any name. +func (b *Bus) resolve(ctx context.Context, addr Address) error { + if b.resolver == nil { + return nil + } + if err := b.resolver.ResolveAddress(ctx, addr); err != nil { + return fmt.Errorf("%w: %s: %w", ErrUnknownReceiver, addr, err) + } + return nil +} + func (b *Bus) routable(addr Address) bool { for _, t := range b.transports { if t.Handles(addr) { diff --git a/a2a/seams.go b/a2a/seams.go index e6b9bfc..23f6f21 100644 --- a/a2a/seams.go +++ b/a2a/seams.go @@ -35,6 +35,20 @@ type Transport interface { Handles(addr Address) bool } +// Resolver answers whether an address names an agent that actually +// exists in the caller's scope. The engine implements it with an agent +// lookup; a remote transport's node would answer for its own peers. +// +// It is separate from Transport.Handles, and the difference matters: a +// transport says "I know how to reach addresses of this shape", while a +// resolver says "this particular agent is there". Without the second +// question, an ask addressed to a typo suspends the asking run against a +// recipient that will never answer, and the run sits until its deadline +// to learn what could have been said immediately. +type Resolver interface { + ResolveAddress(ctx context.Context, addr Address) error +} + // HookEmitter receives messaging lifecycle events. The engine adapts // plugin.Registry to it; tests pass a recorder. type HookEmitter interface { diff --git a/engine/a2a_tools.go b/engine/a2a_tools.go new file mode 100644 index 0000000..8933d47 --- /dev/null +++ b/engine/a2a_tools.go @@ -0,0 +1,316 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/llm" +) + +// The three agent-facing messaging tools. They exist only when a host +// configured messaging with WithA2A, the same way knowledge_search exists +// only when a knowledge provider is set. +const ( + toolAgentSend = "agent_send" + toolAgentAsk = "agent_ask" + toolAgentInbox = "agent_inbox" +) + +// a2aTools returns the messaging tool definitions, or nothing when +// messaging is off. +func (e *Engine) a2aTools() []llm.Tool { + if e.a2a == nil { + return nil + } + return []llm.Tool{ + { + Name: toolAgentSend, + Description: "Send a message to one or more other agents and continue working. " + + "Nothing waits for a reply: use this to inform, confirm, refuse, or hand off. " + + "Use agent_ask instead when you need an answer before you can carry on.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "to": map[string]any{ + "type": "array", + "items": map[string]any{"type": "string"}, + "description": "Names of the agents to send to", + }, + "content": map[string]any{ + "type": "string", + "description": "What you are telling them", + }, + "performative": map[string]any{ + "type": "string", + "description": "The FIPA-ACL speech act. Defaults to inform. " + + "Use inform to tell, confirm/disconfirm to answer a query, " + + "refuse to decline, failure to report that something went wrong, " + + "cancel to end a conversation.", + }, + "conversation_id": map[string]any{ + "type": "string", + "description": "Continue an existing conversation instead of starting one", + }, + "ontology": map[string]any{ + "type": "string", + "description": "Optional subject-matter label the recipient can key on", + }, + }, + "required": []string{"to", "content"}, + }, + }, + { + Name: toolAgentAsk, + Description: "Ask another agent something and wait for the answer. " + + "Your run pauses until they reply, and their answer comes back as this tool's result. " + + "The wait survives a restart, so use this whenever you genuinely need their answer.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "to": map[string]any{ + "type": "string", + "description": "Name of the agent to ask", + }, + "content": map[string]any{ + "type": "string", + "description": "What you are asking them to do or answer", + }, + "performative": map[string]any{ + "type": "string", + "description": "The FIPA-ACL speech act. Defaults to request. " + + "Use request to ask for work, query-if to ask whether something holds, " + + "query-ref to ask for a value, cfp to invite proposals, propose to offer one.", + }, + "conversation_id": map[string]any{ + "type": "string", + "description": "Continue an existing conversation instead of starting one", + }, + "ontology": map[string]any{ + "type": "string", + "description": "Optional subject-matter label the recipient can key on", + }, + }, + "required": []string{"to", "content"}, + }, + }, + { + Name: toolAgentInbox, + Description: "Read messages other agents sent you while you were busy. " + + "Reading marks them read, so each message comes back once.", + Parameters: map[string]any{ + "type": "object", + "properties": map[string]any{ + "limit": map[string]any{ + "type": "integer", + "description": "Maximum number of messages to read", + }, + "conversation_id": map[string]any{ + "type": "string", + "description": "Only read messages on one conversation", + }, + }, + }, + }, + } +} + +// executeA2ATool runs one messaging tool. The outcome matters as much as +// the result: agent_ask does not complete, it pends, and the loop suspends +// the step around it. +func (e *Engine) executeA2ATool(ctx context.Context, inv cortex.Invocation) (string, toolOutcome, bool) { + if e.a2a == nil { + return "", outcomeCompleted, false + } + switch inv.Call.Name { + case toolAgentSend: + return e.executeAgentSend(ctx, inv), outcomeCompleted, true + case toolAgentInbox: + return e.executeAgentInbox(ctx, inv), outcomeCompleted, true + case toolAgentAsk: + return e.executeAgentAsk(ctx, inv) + default: + return "", outcomeCompleted, false + } +} + +type agentSendArgs struct { + To []string `json:"to"` + Content string `json:"content"` + Performative string `json:"performative"` + ConversationID string `json:"conversation_id"` + Ontology string `json:"ontology"` +} + +type agentAskArgs struct { + To string `json:"to"` + Content string `json:"content"` + Performative string `json:"performative"` + ConversationID string `json:"conversation_id"` + Ontology string `json:"ontology"` +} + +type agentInboxArgs struct { + Limit int `json:"limit"` + ConversationID string `json:"conversation_id"` +} + +func (e *Engine) executeAgentSend(ctx context.Context, inv cortex.Invocation) string { + var args agentSendArgs + if err := json.Unmarshal([]byte(inv.Call.Arguments), &args); err != nil { + return jsonResult("error", "invalid arguments: "+err.Error()) + } + if len(args.To) == 0 || args.Content == "" { + return jsonResult("error", "to and content are required") + } + + sender, err := e.a2aSelf(ctx, inv) + if err != nil { + return jsonResult("error", err.Error()) + } + params := a2a.SendParams{ + Sender: sender, + Receivers: addressesOf(args.To), + Performative: performativeOr(args.Performative, a2a.Inform), + Content: args.Content, + Ontology: args.Ontology, + OriginRunID: inv.RunID, + } + if convID, convErr := parseConversationID(args.ConversationID); convErr != nil { + return jsonResult("error", convErr.Error()) + } else if !convID.IsNil() { + params.ConversationID = convID + } + + res, err := e.a2a.Send(ctx, params) + if err != nil { + return jsonResult("error", err.Error()) + } + out, err := json.Marshal(res) + if err != nil { + return jsonResult("error", err.Error()) + } + return string(out) +} + +// executeAgentAsk sends the question and reports the call as pending, so +// the step suspends around it. The answer arrives later, as this same +// call's result, when the bus resumes the run. +// +// Everything that can refuse the ask happens before the pend: a run +// suspended on a message that was never sent is a run nothing can resume. +func (e *Engine) executeAgentAsk(ctx context.Context, inv cortex.Invocation) (string, toolOutcome, bool) { + var args agentAskArgs + if err := json.Unmarshal([]byte(inv.Call.Arguments), &args); err != nil { + return jsonResult("error", "invalid arguments: "+err.Error()), outcomeFailed, true + } + if args.To == "" || args.Content == "" { + return jsonResult("error", "to and content are required"), outcomeFailed, true + } + + sender, err := e.a2aSelf(ctx, inv) + if err != nil { + return jsonResult("error", err.Error()), outcomeFailed, true + } + params := a2a.AskParams{ + SendParams: a2a.SendParams{ + Sender: sender, + Receivers: []a2a.Address{{Agent: args.To}}, + Performative: performativeOr(args.Performative, a2a.Request), + Content: args.Content, + Ontology: args.Ontology, + OriginRunID: inv.RunID, + }, + AskerRunID: inv.RunID, + ToolCallID: inv.Call.ID, + } + if convID, convErr := parseConversationID(args.ConversationID); convErr != nil { + return jsonResult("error", convErr.Error()), outcomeFailed, true + } else if !convID.IsNil() { + params.ConversationID = convID + } + + if _, err := e.a2a.Ask(ctx, params); err != nil { + // A refused ask goes back to the model as an ordinary tool error. + // It can pick another agent, or answer without one. + return jsonResult("error", err.Error()), outcomeFailed, true + } + return "", outcomePending, true +} + +func (e *Engine) executeAgentInbox(ctx context.Context, inv cortex.Invocation) string { + var args agentInboxArgs + if err := json.Unmarshal([]byte(inv.Call.Arguments), &args); err != nil { + return jsonResult("error", "invalid arguments: "+err.Error()) + } + self, err := e.a2aSelf(ctx, inv) + if err != nil { + return jsonResult("error", err.Error()) + } + + filter := a2a.InboxFilter{UnreadOnly: true, Limit: args.Limit} + if convID, convErr := parseConversationID(args.ConversationID); convErr != nil { + return jsonResult("error", convErr.Error()) + } else if !convID.IsNil() { + filter.ConversationID = convID + } + + items, err := e.a2a.Inbox(ctx, self.Agent, filter) + if err != nil { + return jsonResult("error", err.Error()) + } + if len(items) == 0 { + return jsonResult("status", "no new messages") + } + out, err := json.Marshal(map[string]any{"messages": items}) + if err != nil { + return jsonResult("error", err.Error()) + } + return string(out) +} + +// a2aSelf resolves the address of the agent making the call. It reads the +// agent's own name from the store rather than trusting an argument: an +// agent that could name its own sender could impersonate any peer. +func (e *Engine) a2aSelf(ctx context.Context, inv cortex.Invocation) (a2a.Address, error) { + if e.store == nil { + return a2a.Address{}, cortex.ErrNoStore + } + ag, err := e.store.Get(ctx, inv.AgentID) + if err != nil { + return a2a.Address{}, fmt.Errorf("resolve sending agent: %w", err) + } + return a2a.Address{Agent: ag.Name}, nil +} + +func addressesOf(names []string) []a2a.Address { + out := make([]a2a.Address, 0, len(names)) + for _, n := range names { + out = append(out, a2a.Address{Agent: n}) + } + return out +} + +// performativeOr falls back to a default when the model named none, and +// leaves an unrecognised one alone so envelope validation reports it +// rather than this quietly substituting something the agent did not mean. +func performativeOr(named string, fallback a2a.Performative) a2a.Performative { + if named == "" { + return fallback + } + return a2a.Performative(named) +} + +func parseConversationID(s string) (id.ConversationID, error) { + if s == "" { + return id.ConversationID{}, nil + } + convID, err := id.ParseWithPrefix(s, id.PrefixConversation) + if err != nil { + return id.ConversationID{}, fmt.Errorf("invalid conversation_id: %w", err) + } + return convID, nil +} diff --git a/engine/a2a_tools_test.go b/engine/a2a_tools_test.go new file mode 100644 index 0000000..d788895 --- /dev/null +++ b/engine/a2a_tools_test.go @@ -0,0 +1,245 @@ +package engine + +import ( + "context" + "errors" + "strings" + "testing" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/llm" + "github.com/xraph/cortex/run" + "github.com/xraph/cortex/store" + "github.com/xraph/cortex/suspension" +) + +// a2aEngine builds an engine with messaging on, a real sqlite store +// behind it, and two agents that can talk to each other. The LLM is +// scripted, so the tool call under test is the only thing that varies. +func a2aEngine(t *testing.T, calls []llm.ToolCall) (*Engine, store.Store, context.Context) { + t.Helper() + ctx := cortex.WithScope(context.Background(), cortex.Scope{ + Levels: []cortex.Level{{Key: "workspace", Value: "ws_x"}}, + }) + st := newApprovalStore(ctx, t) + + base := []Option{ + WithStore(st), + WithLLM(&scriptedLLM{toolCalls: calls}), + WithA2A(a2a.Options{HopCeiling: 4, Workers: 1}), + } + e, err := New(base...) + if err != nil { + t.Fatalf("New: %v", err) + } + for _, name := range []string{"planner", "worker"} { + if createErr := e.CreateAgent(ctx, &agent.Config{ + ID: id.NewAgentID(), Name: name, SystemPrompt: "you are " + name, Model: "test-model", MaxSteps: 4, + }); createErr != nil { + t.Fatalf("CreateAgent %s: %v", name, createErr) + } + } + return e, st, ctx +} + +func toolNames(tools []llm.Tool) map[string]bool { + out := make(map[string]bool, len(tools)) + for _, tool := range tools { + out[tool.Name] = true + } + return out +} + +func TestA2AToolsAppearOnlyWhenConfigured(t *testing.T) { + off, err := New(WithLLM(&scriptedLLM{})) + if err != nil { + t.Fatalf("New: %v", err) + } + names := toolNames(off.builtinTools()) + for _, name := range []string{toolAgentSend, toolAgentAsk, toolAgentInbox} { + if names[name] { + t.Errorf("%s is offered to a host that never configured messaging", name) + } + } + + on, _, _ := a2aEngine(t, nil) + names = toolNames(on.builtinTools()) + for _, name := range []string{toolAgentSend, toolAgentAsk, toolAgentInbox} { + if !names[name] { + t.Errorf("%s is missing after WithA2A", name) + } + } +} + +func TestWithA2ANeedsAStore(t *testing.T) { + _, err := New(WithLLM(&scriptedLLM{}), WithA2A(a2a.Options{})) + if !errors.Is(err, cortex.ErrNoStore) { + t.Fatalf("err = %v, want ErrNoStore", err) + } +} + +func TestAgentSendDoesNotSuspendTheRun(t *testing.T) { + e, st, ctx := a2aEngine(t, []llm.ToolCall{{ + ID: "call-1", Name: toolAgentSend, + Arguments: `{"to":["worker"],"performative":"inform","content":"the build is green"}`, + }}) + + r, err := e.RunAgent(ctx, "planner", "tell the worker", nil) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + if r.State == run.StatePaused { + t.Fatal("a fire-and-forget send must not pause the run") + } + + msgs, err := st.ListMessages(ctx, &a2a.MessageListFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if len(msgs) != 1 || msgs[0].Content != "the build is green" { + t.Fatalf("stored messages = %+v, want the one that was sent", msgs) + } + if msgs[0].Sender.Agent != "planner" { + t.Fatalf("sender = %s, want the running agent", msgs[0].Sender.Agent) + } +} + +func TestAgentAskSuspendsWithTheAgentReplyReason(t *testing.T) { + e, st, ctx := a2aEngine(t, []llm.ToolCall{{ + ID: "call-1", Name: toolAgentAsk, + Arguments: `{"to":"worker","content":"what is the status?"}`, + }}) + + r, err := e.RunAgent(ctx, "planner", "ask the worker", nil) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + if r.State != run.StatePaused { + t.Fatalf("State = %s, want paused", r.State) + } + + susp, err := st.GetSuspension(ctx, r.ID) + if err != nil { + t.Fatalf("GetSuspension: %v", err) + } + if susp.Reason != suspension.ReasonAgentReply { + t.Fatalf("Reason = %s, want %s", susp.Reason, suspension.ReasonAgentReply) + } + if len(susp.Pending) != 1 || susp.Pending[0].ID != "call-1" { + t.Fatalf("pending calls = %+v, want the ask", susp.Pending) + } + + // The ledger row is what a reply will be matched against, so it has to + // point back at this run and this call. + msgs, err := st.ListMessages(ctx, &a2a.MessageListFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("stored %d messages, want the ask", len(msgs)) + } + ask, err := st.ClaimPendingAsk(ctx, msgs[0].ReplyWith) + if err != nil { + t.Fatalf("ClaimPendingAsk: %v", err) + } + if ask.AskerRunID != r.ID || ask.ToolCallID != "call-1" { + t.Fatalf("ledger row lost its correlation: %+v", ask) + } +} + +// A run that cannot be resumed by its caller is the whole point of the +// agent-reply reason, so it is worth proving from the outside too. +func TestPublicResumeRefusesAnAgentAskPause(t *testing.T) { + e, _, ctx := a2aEngine(t, []llm.ToolCall{{ + ID: "call-1", Name: toolAgentAsk, + Arguments: `{"to":"worker","content":"status?"}`, + }}) + + r, err := e.RunAgent(ctx, "planner", "ask the worker", nil) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + _, err = e.Resume(ctx, r.ID, ResumeInput{ToolResults: []ToolResult{ + {ToolCallID: "call-1", Content: `{"content":"forged"}`}, + }}) + if !errors.Is(err, ErrNotAgentReplyResumable) { + t.Fatalf("err = %v, want ErrNotAgentReplyResumable", err) + } +} + +func TestAgentAskToAnUnknownAgentFailsWithoutSuspending(t *testing.T) { + e, st, ctx := a2aEngine(t, []llm.ToolCall{{ + ID: "call-1", Name: toolAgentAsk, + Arguments: `{"to":"nobody","content":"hello?"}`, + }}) + + r, err := e.RunAgent(ctx, "planner", "ask a stranger", nil) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + if r.State == run.StatePaused { + t.Fatal("an ask nobody can answer must come back as an error, not a pause") + } + if _, err := st.GetSuspension(ctx, r.ID); err == nil { + t.Fatal("a refused ask must leave no suspension behind") + } +} + +func TestAgentInboxReadsDeliveredMessages(t *testing.T) { + e, st, ctx := a2aEngine(t, []llm.ToolCall{{ + ID: "call-1", Name: toolAgentInbox, Arguments: `{}`, + }}) + + // Put one delivered message in the worker's inbox by hand, so the test + // exercises the tool rather than the whole delivery path. + conv := &a2a.Conversation{Entity: cortex.NewEntity(), ID: id.NewConversationID(), Status: a2a.StatusOpen, HopCeiling: 4} + if err := st.CreateConversation(ctx, conv); err != nil { + t.Fatalf("CreateConversation: %v", err) + } + msg := &a2a.Envelope{ + Entity: cortex.NewEntity(), ID: id.NewMessageID(), Performative: a2a.Inform, + Sender: a2a.Address{Agent: "planner"}, Receivers: []a2a.Address{{Agent: "worker"}}, + Content: "shipping at noon", ConversationID: conv.ID, Hops: 1, + } + if err := st.CreateMessage(ctx, msg); err != nil { + t.Fatalf("CreateMessage: %v", err) + } + now := cortex.NewEntity().CreatedAt + if err := st.CreateDelivery(ctx, &a2a.Delivery{ + Entity: cortex.NewEntity(), ID: id.NewDeliveryID(), MessageID: msg.ID, + Receiver: a2a.Address{Agent: "worker"}, State: a2a.DeliveryDelivered, DeliveredAt: &now, + }); err != nil { + t.Fatalf("CreateDelivery: %v", err) + } + + r, err := e.RunAgent(ctx, "worker", "check your mail", nil) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + if r.State == run.StatePaused { + t.Fatal("reading an inbox must not pause a run") + } + + steps, err := st.ListSteps(ctx, r.ID) + if err != nil { + t.Fatalf("ListSteps: %v", err) + } + var sawContent bool + for _, s := range steps { + calls, callErr := st.ListToolCalls(ctx, s.ID) + if callErr != nil { + t.Fatalf("ListToolCalls: %v", callErr) + } + for _, c := range calls { + if c.ToolName == toolAgentInbox && strings.Contains(c.Result, "shipping at noon") { + sawContent = true + } + } + } + if !sawContent { + t.Fatal("the inbox tool result never carried the message to the model") + } +} diff --git a/engine/a2a_wiring.go b/engine/a2a_wiring.go new file mode 100644 index 0000000..8a7d724 --- /dev/null +++ b/engine/a2a_wiring.go @@ -0,0 +1,178 @@ +package engine + +import ( + "context" + "time" + + log "github.com/xraph/go-utils/log" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" +) + +// a2aConfig is what WithA2A recorded, held until New has finished running +// every option and can actually build the bus. +type a2aConfig struct { + opts a2a.Options +} + +// WithA2A turns on agent-to-agent messaging. +// +// The three tools (agent_send, agent_ask, agent_inbox) appear in an +// agent's tool list only when this is set, so a host that does not +// configure it sees no new tools, no new behaviour, and no rows in the +// four a2a tables. +// +// Messaging needs a store: the ask that suspends a run is durable, and a +// pending ask nobody can read back is a run nothing could ever resume. +func WithA2A(opts a2a.Options) Option { + return func(e *Engine) error { + e.a2aCfg = &a2aConfig{opts: opts} + return nil + } +} + +// buildA2A constructs the bus once every option has run. It is called from +// New rather than from WithA2A, because the bus needs the store and the +// plugin registry and an option cannot know what order the caller put +// them in. +func (e *Engine) buildA2A() error { + if e.a2aCfg == nil { + return nil + } + if e.store == nil { + return cortex.ErrNoStore + } + bus, err := a2a.NewBus(a2a.BusConfig{ + Store: e.store, + Runner: agentRunnerAdapter{eng: e}, + Resumer: a2aResumer{eng: e}, + Resolver: a2aResolver{eng: e}, + Hooks: a2aHooks{eng: e}, + Options: e.a2aCfg.opts, + }) + if err != nil { + return err + } + e.a2a = bus + return nil +} + +// A2A returns the message bus, or nil when messaging is off. A host needs +// it to inject a message from outside a run: an operator answering an +// agent, or an HTTP handler carrying one in. +func (e *Engine) A2A() *a2a.Bus { return e.a2a } + +// a2aResumer is the bus's way back into a paused run. It is deliberately +// the only thing that reaches resumeSourceAgentReply: the bus gets here +// having claimed the ledger row that proves the reply is genuine, and a +// public caller has no such row to show. +type a2aResumer struct{ eng *Engine } + +func (a a2aResumer) ResumeAgentReply(ctx context.Context, runID id.AgentRunID, callID, result string) error { + in := ResumeInput{ToolResults: []ToolResult{{ToolCallID: callID, Content: result}}} + _, err := a.eng.resume(ctx, runID, in, resumeSourceAgentReply) + return err +} + +// a2aResolver answers the bus's "does this agent exist" question with an +// agent lookup in the caller's own scope. It is what turns a message to a +// typo into an error the sending model can read, instead of a run that +// pauses against a recipient that will never answer. +type a2aResolver struct{ eng *Engine } + +func (a a2aResolver) ResolveAddress(ctx context.Context, addr a2a.Address) error { + if !addr.IsLocal() { + // A remote address is the remote transport's to resolve, and + // there is no remote transport yet. Refusing here would be this + // resolver overruling a transport that said it could reach it. + return nil + } + if a.eng.store == nil { + return cortex.ErrNoStore + } + if _, err := a.eng.store.GetByName(ctx, addr.Agent); err != nil { + return err + } + return nil +} + +// a2aHooks adapts the plugin registry to the bus's emitter. +type a2aHooks struct{ eng *Engine } + +func (h a2aHooks) MessageSent(ctx context.Context, msgID id.MessageID, from, to, performative string) { + h.eng.extensions.EmitMessageSent(ctx, msgID, from, to, performative) +} + +func (h a2aHooks) MessageDelivered(ctx context.Context, msgID id.MessageID, to string) { + h.eng.extensions.EmitMessageDelivered(ctx, msgID, to) +} + +func (h a2aHooks) MessageRefused(ctx context.Context, msgID id.MessageID, to, reason string) { + h.eng.extensions.EmitMessageRefused(ctx, msgID, to, reason) +} + +// startA2A brings the bus up with the engine: orphaned deliveries from a +// previous process are redriven first, then the workers start. +func (e *Engine) startA2A(ctx context.Context) { + if e.a2a == nil { + return + } + if n, err := e.a2a.Redrive(ctx); err != nil { + e.logger.Warn("cortex: a2a redrive failed", log.Error(err)) + } else if n > 0 { + e.logger.Info("cortex: a2a redrove orphaned deliveries", log.Int("count", n)) + } + if err := e.a2a.Start(ctx); err != nil { + e.logger.Warn("cortex: a2a dispatcher failed to start", log.Error(err)) + return + } + e.startAskSweep(ctx) +} + +// startAskSweep resolves overdue asks into failures on the bus's own +// interval. It runs AHEAD of the engine's suspension sweep on purpose: +// that sweep fails a run nobody answered in time, which is the wrong verb +// for a peer that did not reply. An agent that learns its peer went quiet +// can do something about it. +func (e *Engine) startAskSweep(ctx context.Context) { + interval := e.a2aCfg.opts.SweepInterval + if interval <= 0 { + interval = a2a.DefaultSweepInterval + } + sweepCtx, cancel := context.WithCancel(context.WithoutCancel(ctx)) + e.a2aSweepCancel = cancel + e.a2aSweepDone = make(chan struct{}) + + go func() { + defer close(e.a2aSweepDone) + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-sweepCtx.Done(): + return + case <-ticker.C: + if _, err := e.a2a.SweepExpiredAsks(sweepCtx); err != nil { + e.logger.Warn("cortex: a2a ask sweep failed", log.Error(err)) + } + } + } + }() +} + +// stopA2A stops the dispatcher and the ask sweep and WAITS for both. +// Signalling without waiting would let Stop return while a delivery was +// still writing, which is the same hazard stopSweeper exists to avoid. +func (e *Engine) stopA2A() { + if e.a2a == nil { + return + } + if e.a2aSweepCancel != nil { + e.a2aSweepCancel() + <-e.a2aSweepDone + e.a2aSweepCancel, e.a2aSweepDone = nil, nil + } + e.a2a.Stop() +} diff --git a/engine/engine.go b/engine/engine.go index a9d44fe..3c148c0 100644 --- a/engine/engine.go +++ b/engine/engine.go @@ -9,6 +9,7 @@ import ( log "github.com/xraph/go-utils/log" "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" "github.com/xraph/cortex/agent" "github.com/xraph/cortex/behavior" "github.com/xraph/cortex/checkpoint" @@ -54,6 +55,14 @@ type Engine struct { sweepInterval time.Duration sweepLimit int sweep sweeper + + // a2a is the agent-to-agent message bus, nil unless a host asked for + // messaging with WithA2A. Its nil-ness is what gates the three + // messaging tools out of the list a model ever sees. + a2a *a2a.Bus + a2aCfg *a2aConfig + a2aSweepCancel context.CancelFunc + a2aSweepDone chan struct{} } // LLM returns the configured LLM client, or nil if none is set. @@ -106,6 +115,13 @@ func New(opts ...Option) (*Engine, error) { } e.pendingExts = nil + // The bus is built after every option has run, not inside WithA2A: + // it needs the store and the registry, and an option cannot know + // whether WithStore comes before or after it in the caller's list. + if err := e.buildA2A(); err != nil { + return nil, err + } + return e, nil } @@ -123,8 +139,9 @@ func (e *Engine) Health(ctx context.Context) error { // every other lifecycle hook in the ecosystem, but a caller's start // context is not the engine's lifetime: the expiry sweeper it launches // here runs on a handle of its own and is stopped by Stop. -func (e *Engine) Start(_ context.Context) error { +func (e *Engine) Start(ctx context.Context) error { e.startSweeper() + e.startA2A(ctx) e.logger.Info("cortex engine started") return nil } @@ -136,6 +153,7 @@ func (e *Engine) Start(_ context.Context) error { // while a sweep is still failing runs has been told something untrue. func (e *Engine) Stop(ctx context.Context) error { e.stopSweeper() + e.stopA2A() e.extensions.EmitShutdown(ctx) e.logger.Info("cortex engine stopped") return nil diff --git a/engine/react.go b/engine/react.go index fd28918..ec9fc41 100644 --- a/engine/react.go +++ b/engine/react.go @@ -939,11 +939,21 @@ func (e *Engine) executeTool(ctx context.Context, s cortex.Subject, tc llm.ToolC e.extensions.EmitToolFailed(ctx, s.RunID, tc.Name, failErr) } if outcome == outcomePending { - return result, outcome, suspension.ReasonExternalTool + return result, outcome, pendingReason(tc.Name) } return result, outcome, "" } +// pendingReason says what a pending call is waiting on. External tools +// wait on the caller; agent_ask waits on a peer agent, and telling a host +// to go execute that one would be asking it to forge the peer's reply. +func pendingReason(toolName string) suspension.SuspendReason { + if toolName == toolAgentAsk { + return suspension.ReasonAgentReply + } + return suspension.ReasonExternalTool +} + // dispatchTool runs a call that has already cleared authorization, and it // is where the actual tool lives: builtin, registered, or external and // therefore not runnable here at all. @@ -956,8 +966,11 @@ func (e *Engine) executeTool(ctx context.Context, s cortex.Subject, tc llm.ToolC func (e *Engine) dispatchTool(ctx context.Context, s cortex.Subject, tc llm.ToolCall) (string, toolOutcome, error) { inv := cortex.Invocation{Subject: s, Call: tc} - if result, handled := e.executeBuiltinTool(ctx, inv); handled { - return result, outcomeCompleted, nil + if result, outcome, handled := e.executeBuiltinTool(ctx, inv); handled { + if outcome == outcomeFailed { + return result, outcome, fmt.Errorf("builtin tool %q failed", tc.Name) + } + return result, outcome, nil } for _, rt := range e.tools { if rt.def.Name == tc.Name { diff --git a/engine/tools.go b/engine/tools.go index 8b5a8c3..a3d336e 100644 --- a/engine/tools.go +++ b/engine/tools.go @@ -34,18 +34,31 @@ func (e *Engine) builtinTools() []llm.Tool { }) } + tools = append(tools, e.a2aTools()...) + return tools } -// executeBuiltinTool attempts to execute a built-in tool. Returns (result, true) if handled. -// It takes the same Invocation shape as a host-registered ToolHandler — builtins are not a -// separate dispatch contract, just tools the engine happens to implement itself. -func (e *Engine) executeBuiltinTool(ctx context.Context, inv cortex.Invocation) (string, bool) { +// executeBuiltinTool attempts to execute a built-in tool. The last return +// says whether this call was handled here at all; the outcome says how it +// ended. +// +// The outcome exists because of agent_ask, which does not complete: it +// sends a question and reports the call pending, and the loop suspends the +// step around it. Every other builtin completes, so they all return +// outcomeCompleted and nothing about them changed. +// +// It takes the same Invocation shape as a host-registered ToolHandler — +// builtins are not a separate dispatch contract, just tools the engine +// happens to implement itself. +func (e *Engine) executeBuiltinTool(ctx context.Context, inv cortex.Invocation) (string, toolOutcome, bool) { switch inv.Call.Name { case "knowledge_search": - return e.executeKnowledgeSearch(ctx, inv), true + return e.executeKnowledgeSearch(ctx, inv), outcomeCompleted, true + case toolAgentSend, toolAgentAsk, toolAgentInbox: + return e.executeA2ATool(ctx, inv) default: - return "", false + return "", outcomeCompleted, false } } diff --git a/plugin/plugin.go b/plugin/plugin.go index 54a9d63..ab8d977 100644 --- a/plugin/plugin.go +++ b/plugin/plugin.go @@ -134,6 +134,34 @@ type AgentHandoff interface { OnAgentHandoff(ctx context.Context, orchID id.OrchestrationID, fromAgent, toAgent string, payload string) error } +// ────────────────────────────────────────────────── +// Messaging hooks +// ────────────────────────────────────────────────── +// +// These are separate from AgentHandoff on purpose. A handoff is one +// orchestration strategy passing work along inside a run it controls; a +// message is an agent addressing a peer of its own accord, possibly with +// no orchestration anywhere. Collapsing them would tell every existing +// AgentHandoff subscriber that orchestrations it never started are +// running. + +// MessageSent is called when an envelope is accepted and queued. +type MessageSent interface { + OnMessageSent(ctx context.Context, msgID id.MessageID, from, to, performative string) error +} + +// MessageDelivered is called when an envelope reaches a receiver: an +// inbox row for an informative, a started run for a directive. +type MessageDelivered interface { + OnMessageDelivered(ctx context.Context, msgID id.MessageID, to string) error +} + +// MessageRefused is called when delivery is refused or fails: an +// exhausted hop budget, an unroutable address, a recipient that broke. +type MessageRefused interface { + OnMessageRefused(ctx context.Context, msgID id.MessageID, to, reason string) error +} + // ────────────────────────────────────────────────── // Shutdown hook // ────────────────────────────────────────────────── diff --git a/plugin/registry.go b/plugin/registry.go index 746a194..f26d46e 100644 --- a/plugin/registry.go +++ b/plugin/registry.go @@ -96,6 +96,21 @@ type agentHandoffEntry struct { hook AgentHandoff } +type messageSentEntry struct { + name string + hook MessageSent +} + +type messageDeliveredEntry struct { + name string + hook MessageDelivered +} + +type messageRefusedEntry struct { + name string + hook MessageRefused +} + type shutdownEntry struct { name string hook Shutdown @@ -125,6 +140,9 @@ type Registry struct { orchestrationStarted []orchestrationStartedEntry orchestrationCompleted []orchestrationCompletedEntry agentHandoff []agentHandoffEntry + messageSent []messageSentEntry + messageDelivered []messageDeliveredEntry + messageRefused []messageRefusedEntry shutdown []shutdownEntry } @@ -190,6 +208,15 @@ func (r *Registry) Register(e Extension) { if h, ok := e.(AgentHandoff); ok { r.agentHandoff = append(r.agentHandoff, agentHandoffEntry{name, h}) } + if h, ok := e.(MessageSent); ok { + r.messageSent = append(r.messageSent, messageSentEntry{name, h}) + } + if h, ok := e.(MessageDelivered); ok { + r.messageDelivered = append(r.messageDelivered, messageDeliveredEntry{name, h}) + } + if h, ok := e.(MessageRefused); ok { + r.messageRefused = append(r.messageRefused, messageRefusedEntry{name, h}) + } if h, ok := e.(Shutdown); ok { r.shutdown = append(r.shutdown, shutdownEntry{name, h}) } @@ -361,6 +388,34 @@ func (r *Registry) EmitAgentHandoff(ctx context.Context, orchID id.Orchestration } } +// ────────────────────────────────────────────────── +// Messaging event emitters +// ────────────────────────────────────────────────── + +func (r *Registry) EmitMessageSent(ctx context.Context, msgID id.MessageID, from, to, performative string) { + for _, e := range r.messageSent { + if err := e.hook.OnMessageSent(ctx, msgID, from, to, performative); err != nil { + r.logHookError("OnMessageSent", e.name, err) + } + } +} + +func (r *Registry) EmitMessageDelivered(ctx context.Context, msgID id.MessageID, to string) { + for _, e := range r.messageDelivered { + if err := e.hook.OnMessageDelivered(ctx, msgID, to); err != nil { + r.logHookError("OnMessageDelivered", e.name, err) + } + } +} + +func (r *Registry) EmitMessageRefused(ctx context.Context, msgID id.MessageID, to, reason string) { + for _, e := range r.messageRefused { + if err := e.hook.OnMessageRefused(ctx, msgID, to, reason); err != nil { + r.logHookError("OnMessageRefused", e.name, err) + } + } +} + // ────────────────────────────────────────────────── // Shutdown event emitter // ────────────────────────────────────────────────── From 72b0e64c079ce2f2b6039b558eabd19ac92bb6c8 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:01:58 -0500 Subject: [PATCH 21/50] test(engine): prove the ask, run, reply, resume loop end to end Every piece under this test is covered somewhere else. This one is for the wiring between them, which is what no unit test can see: the planner asks and stops, the dispatcher runs the worker, and the planner comes back with the worker's words as its tool result. --- engine/a2a_e2e_test.go | 235 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 235 insertions(+) create mode 100644 engine/a2a_e2e_test.go diff --git a/engine/a2a_e2e_test.go b/engine/a2a_e2e_test.go new file mode 100644 index 0000000..edbef33 --- /dev/null +++ b/engine/a2a_e2e_test.go @@ -0,0 +1,235 @@ +package engine + +import ( + "context" + "errors" + "strings" + "sync" + "testing" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/llm" + "github.com/xraph/cortex/run" + "github.com/xraph/cortex/store" +) + +// routingLLM answers as whichever agent is calling, which is what lets +// one engine drive both sides of a conversation. It keys on the system +// prompt because that is the only thing in a request that says who is +// asking. +type routingLLM struct { + mu sync.Mutex + asked bool + prompts []string + resumed chan struct{} + closeOne sync.Once +} + +func (l *routingLLM) Complete(_ context.Context, req *llm.Request) (*llm.Response, error) { + l.mu.Lock() + defer l.mu.Unlock() + + var system string + for _, m := range req.Messages { + if m.Role == "system" { + system = m.Content + } + } + if req.System != "" { + system = req.System + } + l.prompts = append(l.prompts, system) + + if strings.Contains(system, "worker") { + return &llm.Response{Content: "all clear, nothing burning"}, nil + } + if !l.asked { + l.asked = true + return &llm.Response{ToolCalls: []llm.ToolCall{{ + ID: "call-1", + Name: toolAgentAsk, + Arguments: `{"to":"worker","content":"what is the status?"}`, + }}}, nil + } + // The second turn is the resumed one: whatever the model says here it + // says having seen the worker's answer as the tool result. + if l.resumed != nil { + l.closeOne.Do(func() { close(l.resumed) }) + } + return &llm.Response{Content: "the worker says it is all clear"}, nil +} + +func (l *routingLLM) CompleteStream(_ context.Context, _ *llm.Request) (llm.Stream, error) { + return nil, errors.New("routingLLM: CompleteStream not supported") +} + +// TestAskRunReplyResume is the whole feature in one test: A asks, A's run +// is suspended on disk, B runs and answers, and A comes back with B's +// words as its tool result. +// +// Every piece under this is covered by a unit test somewhere. This is +// here for the wiring between them, which is what no unit test can see. +func TestAskRunReplyResume(t *testing.T) { + ctx := cortex.WithScope(context.Background(), cortex.Scope{ + Levels: []cortex.Level{{Key: "workspace", Value: "ws_x"}}, + }) + st := newApprovalStore(ctx, t) + model := &routingLLM{} + + e, err := New( + WithStore(st), + WithLLM(model), + WithA2A(a2a.Options{HopCeiling: 6, Workers: 1}), + ) + if err != nil { + t.Fatalf("New: %v", err) + } + for _, name := range []string{"planner", "worker"} { + if createErr := e.CreateAgent(ctx, &agent.Config{ + ID: id.NewAgentID(), Name: name, SystemPrompt: "you are " + name, + Model: "test-model", MaxSteps: 4, + }); createErr != nil { + t.Fatalf("CreateAgent %s: %v", name, createErr) + } + } + + // 1. The planner asks and stops. Nothing is holding a goroutine open: + // the run is a row, and the question is a queued delivery. + paused, err := e.RunAgent(ctx, "planner", "find out how the worker is doing", nil) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + if paused.State != run.StatePaused { + t.Fatalf("State = %s, want paused", paused.State) + } + + // 2. The dispatcher carries the question, runs the worker, turns its + // output into a reply, and resumes the planner off the reply. Drain is + // synchronous so the test never has to guess when that finished. + if _, drainErr := e.A2A().Drain(ctx); drainErr != nil { + t.Fatalf("Drain: %v", drainErr) + } + + // 3. The planner finished, on the answer it was waiting for. + final, err := st.GetRun(ctx, paused.ID) + if err != nil { + t.Fatalf("GetRun: %v", err) + } + if final.State != run.StateCompleted { + t.Fatalf("State = %s, want completed (error: %q)", final.State, final.Error) + } + if !strings.Contains(final.Output, "all clear") { + t.Fatalf("Output = %q, want it to reflect the worker's answer", final.Output) + } + + assertAskResultReachedTheModel(ctx, t, st, paused.ID) + assertConversationTranscript(ctx, t, st) +} + +// assertAskResultReachedTheModel checks the resumed tool call carries the +// worker's words, which is the difference between a run that continued +// and a run that continued knowing something. +func assertAskResultReachedTheModel(ctx context.Context, t *testing.T, st store.Store, runID id.AgentRunID) { + t.Helper() + steps, err := st.ListSteps(ctx, runID) + if err != nil { + t.Fatalf("ListSteps: %v", err) + } + for _, s := range steps { + calls, callErr := st.ListToolCalls(ctx, s.ID) + if callErr != nil { + t.Fatalf("ListToolCalls: %v", callErr) + } + for _, c := range calls { + if c.ToolName == toolAgentAsk && strings.Contains(c.Result, "all clear") { + return + } + } + } + t.Fatal("the ask never came back carrying the worker's answer") +} + +// assertConversationTranscript checks both halves were persisted on one +// conversation, which is what a later reader (an operator, the API) sees. +func assertConversationTranscript(ctx context.Context, t *testing.T, st store.Store) { + t.Helper() + convs, err := st.ListConversations(ctx, &a2a.ConversationListFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListConversations: %v", err) + } + if len(convs) != 1 { + t.Fatalf("got %d conversations, want 1", len(convs)) + } + + msgs, err := st.ListMessages(ctx, &a2a.MessageListFilter{ConversationID: convs[0].ID, Limit: 10}) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if len(msgs) != 2 { + t.Fatalf("got %d messages, want the question and the answer", len(msgs)) + } + if msgs[0].Performative != a2a.Request || msgs[0].Sender.Agent != "planner" { + t.Fatalf("first message is wrong: %+v", msgs[0]) + } + if msgs[1].Performative != a2a.Inform || msgs[1].Sender.Agent != "worker" { + t.Fatalf("second message is wrong: %+v", msgs[1]) + } + if msgs[1].InReplyTo != msgs[0].ReplyWith { + t.Fatal("the reply does not point back at the question it answers") + } + if msgs[1].Hops != 2 { + t.Fatalf("Hops = %d, want 2: the reply is one hop past the question", msgs[1].Hops) + } +} + +// TestEngineStartCarriesMessagesWithoutADrain proves the lifecycle wiring: +// with the engine started, the dispatcher does the carrying itself and +// nothing in the caller's code has to know delivery exists. +func TestEngineStartCarriesMessagesWithoutADrain(t *testing.T) { + ctx := cortex.WithScope(context.Background(), cortex.Scope{ + Levels: []cortex.Level{{Key: "workspace", Value: "ws_x"}}, + }) + st := newApprovalStore(ctx, t) + model := &routingLLM{resumed: make(chan struct{})} + + e, err := New( + WithStore(st), + WithLLM(model), + WithA2A(a2a.Options{HopCeiling: 6, Workers: 2}), + ) + if err != nil { + t.Fatalf("New: %v", err) + } + for _, name := range []string{"planner", "worker"} { + if createErr := e.CreateAgent(ctx, &agent.Config{ + ID: id.NewAgentID(), Name: name, SystemPrompt: "you are " + name, + Model: "test-model", MaxSteps: 4, + }); createErr != nil { + t.Fatalf("CreateAgent %s: %v", name, createErr) + } + } + + if startErr := e.Start(ctx); startErr != nil { + t.Fatalf("Start: %v", startErr) + } + defer func() { + if stopErr := e.Stop(ctx); stopErr != nil { + t.Fatalf("Stop: %v", stopErr) + } + }() + + paused, err := e.RunAgent(ctx, "planner", "ask the worker", nil) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + if paused.State != run.StatePaused { + t.Fatalf("State = %s, want paused", paused.State) + } + + // The workers pick the delivery up on their own. Waiting on the + // model's own signal keeps this deterministic: no sleep, no polling. + <-model.resumed +} From fa8afdbd0ebc1d0c722b2b4d6900fda1c81b4c10 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:04:03 -0500 Subject: [PATCH 22/50] feat(api): expose conversations, inboxes and a way to message an agent Three reads and one write. The write is the interesting one: a message carrying in_reply_to resumes the run waiting on it, so a person can answer an agent that asked a question, and a remote peer will terminate into the same path. --- api/a2a_handler.go | 152 +++++++++++++++++++++++++++++++++++++++++++++ api/api.go | 3 + api/requests.go | 42 +++++++++++++ api/responses.go | 18 ++++++ engine/a2a_crud.go | 69 ++++++++++++++++++++ 5 files changed, 284 insertions(+) create mode 100644 api/a2a_handler.go create mode 100644 engine/a2a_crud.go diff --git a/api/a2a_handler.go b/api/a2a_handler.go new file mode 100644 index 0000000..b72a0d7 --- /dev/null +++ b/api/a2a_handler.go @@ -0,0 +1,152 @@ +package api + +import ( + "fmt" + "net/http" + + "github.com/xraph/forge" + + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" +) + +// registerA2ARoutes wires the messaging endpoints. Three of them are +// observability: what conversations exist, what was said, what is waiting +// in an agent's inbox. The fourth is the way in from outside a run, and +// it is the same path a remote peer will terminate into. +func (a *API) registerA2ARoutes(router forge.Router) error { + g := router.Group("/v1", forge.WithGroupTags("messaging")) + + if err := g.GET("/a2a/conversations", a.listA2AConversations, + forge.WithSummary("List conversations"), + forge.WithDescription("Lists agent-to-agent conversations in the caller's scope."), + forge.WithOperationID("listA2AConversations"), + forge.WithRequestSchema(ListA2AConversationsRequest{}), + forge.WithResponseSchema(http.StatusOK, "Conversation list", &ListA2AConversationsResponse{}), + forge.WithErrorResponses(), + ); err != nil { + return fmt.Errorf("register a2a routes: %w", err) + } + + if err := g.GET("/a2a/conversations/:id", a.getA2AConversation, + forge.WithSummary("Get conversation"), + forge.WithDescription("Returns one conversation together with its messages, oldest first."), + forge.WithOperationID("getA2AConversation"), + forge.WithRequestSchema(GetA2AConversationRequest{}), + forge.WithResponseSchema(http.StatusOK, "Conversation", &A2AConversationResponse{}), + forge.WithErrorResponses(), + ); err != nil { + return fmt.Errorf("register a2a routes: %w", err) + } + + if err := g.GET("/agents/:name/inbox", a.getAgentInbox, + forge.WithSummary("Read an agent's inbox"), + forge.WithDescription("Returns messages delivered to an agent. Reading marks them read."), + forge.WithOperationID("getAgentInbox"), + forge.WithRequestSchema(AgentInboxRequest{}), + forge.WithResponseSchema(http.StatusOK, "Inbox", &AgentInboxResponse{}), + forge.WithErrorResponses(), + ); err != nil { + return fmt.Errorf("register a2a routes: %w", err) + } + + if err := g.POST("/agents/:name/messages", a.sendAgentMessage, + forge.WithSummary("Send a message to an agent"), + forge.WithDescription( + "Sends a message from outside a run: an operator answering an agent, or a host "+ + "injecting work. A reply carrying in_reply_to resumes the run waiting on it, "+ + "which is how a human answers an agent that asked."), + forge.WithOperationID("sendAgentMessage"), + forge.WithRequestSchema(SendMessageRequest{}), + forge.WithCreatedResponse(&a2a.SendResult{}), + forge.WithErrorResponses(), + ); err != nil { + return fmt.Errorf("register a2a routes: %w", err) + } + + return nil +} + +func (a *API) listA2AConversations(ctx forge.Context, req *ListA2AConversationsRequest) (*ListA2AConversationsResponse, error) { + items, err := a.eng.ListConversations(ctx.Context(), &a2a.ConversationListFilter{ + Status: req.Status, + Limit: defaultLimit(req.Limit), + Offset: req.Offset, + }) + if err != nil { + return nil, mapStoreError(err) + } + resp := &ListA2AConversationsResponse{Items: items} + return resp, ctx.JSON(http.StatusOK, resp) +} + +func (a *API) getA2AConversation(ctx forge.Context, _ *GetA2AConversationRequest) (*A2AConversationResponse, error) { + convID, err := id.ParseWithPrefix(ctx.Param("id"), id.PrefixConversation) + if err != nil { + return nil, forge.BadRequest("invalid conversation id") + } + + conv, err := a.eng.GetConversation(ctx.Context(), convID) + if err != nil { + return nil, mapStoreError(err) + } + msgs, err := a.eng.ListMessages(ctx.Context(), &a2a.MessageListFilter{ConversationID: convID}) + if err != nil { + return nil, mapStoreError(err) + } + + resp := &A2AConversationResponse{Conversation: conv, Messages: msgs} + return resp, ctx.JSON(http.StatusOK, resp) +} + +func (a *API) getAgentInbox(ctx forge.Context, req *AgentInboxRequest) (*AgentInboxResponse, error) { + filter := a2a.InboxFilter{UnreadOnly: !req.IncludeRead, Limit: defaultLimit(req.Limit)} + if req.ConversationID != "" { + convID, err := id.ParseWithPrefix(req.ConversationID, id.PrefixConversation) + if err != nil { + return nil, forge.BadRequest("invalid conversation_id") + } + filter.ConversationID = convID + } + + items, err := a.eng.AgentInbox(ctx.Context(), ctx.Param("name"), filter) + if err != nil { + return nil, mapStoreError(err) + } + resp := &AgentInboxResponse{Items: items} + return resp, ctx.JSON(http.StatusOK, resp) +} + +func (a *API) sendAgentMessage(ctx forge.Context, req *SendMessageRequest) (*a2a.SendResult, error) { + if req.From == "" { + return nil, forge.BadRequest("from is required: a message with no sender cannot be replied to") + } + if req.Content == "" { + return nil, forge.BadRequest("content is required") + } + + params := a2a.SendParams{ + Sender: a2a.Address{Agent: req.From}, + Receivers: []a2a.Address{{Agent: ctx.Param("name")}}, + Performative: a2a.Performative(req.Performative), + Content: req.Content, + Ontology: req.Ontology, + InReplyTo: req.InReplyTo, + } + if params.Performative == "" { + params.Performative = a2a.Inform + } + if req.ConversationID != "" { + convID, err := id.ParseWithPrefix(req.ConversationID, id.PrefixConversation) + if err != nil { + return nil, forge.BadRequest("invalid conversation_id") + } + params.ConversationID = convID + } + + res, err := a.eng.SendMessage(ctx.Context(), params) + if err != nil { + return nil, mapStoreError(err) + } + return res, ctx.JSON(http.StatusCreated, res) +} diff --git a/api/api.go b/api/api.go index 538390a..9c91fbc 100644 --- a/api/api.go +++ b/api/api.go @@ -68,5 +68,8 @@ func (a *API) RegisterRoutes(router forge.Router) error { if err := a.registerOrchestrationRoutes(router); err != nil { return err } + if err := a.registerA2ARoutes(router); err != nil { + return err + } return a.registerConfigRoutes(router) } diff --git a/api/requests.go b/api/requests.go index d499363..b2a0273 100644 --- a/api/requests.go +++ b/api/requests.go @@ -491,3 +491,45 @@ type ClonePersonaRequest struct { Name string `path:"name" description:"Source persona name"` NewName string `json:"new_name,omitempty" description:"Name for the clone; auto-generated if omitted"` } + +// ────────────────────────────────────────────────── +// Agent-to-agent messaging +// ────────────────────────────────────────────────── + +// ListA2AConversationsRequest paginates and filters messaging +// conversations. The a2a prefix is not decoration: "conversation" already +// means an agent's chat history elsewhere in this API, and these are the +// other kind. +type ListA2AConversationsRequest struct { + Status string `query:"status" description:"Filter by status: open|closed|expired"` + Limit int `query:"limit" description:"Max results (default: 50)"` + Offset int `query:"offset" description:"Results to skip"` +} + +// GetA2AConversationRequest addresses one messaging conversation by ID. +type GetA2AConversationRequest struct { + ID string `path:"id" description:"Conversation ID"` +} + +// AgentInboxRequest reads one agent's delivered messages. +type AgentInboxRequest struct { + Name string `path:"name" description:"Agent name"` + ConversationID string `query:"conversation_id" description:"Only messages on this conversation"` + IncludeRead bool `query:"include_read" description:"Include messages already read (default: unread only)"` + Limit int `query:"limit" description:"Max results (default: 50)"` +} + +// SendMessageRequest injects a message from outside a run. +// +// InReplyTo is what makes this more than an inbox write: a reply carrying +// the reply-with token of a waiting ask resumes the run behind it, which +// is how a person answers an agent that asked a question. +type SendMessageRequest struct { + Name string `path:"name" description:"Recipient agent name"` + From string `json:"from" description:"Sender name, as it should appear to the recipient"` + Content string `json:"content" description:"The message body"` + Performative string `json:"performative,omitempty" description:"FIPA-ACL speech act (default: inform)"` + ConversationID string `json:"conversation_id,omitempty" description:"Continue an existing conversation"` + InReplyTo string `json:"in_reply_to,omitempty" description:"Reply-with token of the ask this answers"` + Ontology string `json:"ontology,omitempty" description:"Optional subject-matter label"` +} diff --git a/api/responses.go b/api/responses.go index 11f0fbd..d334624 100644 --- a/api/responses.go +++ b/api/responses.go @@ -1,6 +1,7 @@ package api import ( + "github.com/xraph/cortex/a2a" "github.com/xraph/cortex/agent" "github.com/xraph/cortex/behavior" "github.com/xraph/cortex/checkpoint" @@ -112,3 +113,20 @@ type RunOrchestrationResponse struct { Output string `json:"output"` DurationMs int64 `json:"duration_ms"` } + +// ListA2AConversationsResponse is a page of messaging conversations. +type ListA2AConversationsResponse struct { + Items []*a2a.Conversation `json:"items"` +} + +// A2AConversationResponse is one messaging conversation with its +// transcript, oldest message first. +type A2AConversationResponse struct { + Conversation *a2a.Conversation `json:"conversation"` + Messages []*a2a.Envelope `json:"messages"` +} + +// AgentInboxResponse is what an agent has waiting for it. +type AgentInboxResponse struct { + Items []a2a.InboxItem `json:"items"` +} diff --git a/engine/a2a_crud.go b/engine/a2a_crud.go new file mode 100644 index 0000000..e3be2bf --- /dev/null +++ b/engine/a2a_crud.go @@ -0,0 +1,69 @@ +package engine + +import ( + "context" + "errors" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" +) + +// ErrNoA2A is returned when a caller reaches for messaging on an engine +// that was never configured with WithA2A. It is distinct from ErrNoStore: +// the store may be perfectly fine and messaging simply switched off. +var ErrNoA2A = errors.New("cortex: agent-to-agent messaging is not configured") + +// The read passthroughs below mirror the orchestration CRUD ones: the API +// layer talks to the engine, never to the store directly, so a host that +// swaps the store keeps one seam to think about. + +// ListConversations returns messaging conversations in the caller's scope. +func (e *Engine) ListConversations(ctx context.Context, filter *a2a.ConversationListFilter) ([]*a2a.Conversation, error) { + if e.store == nil { + return nil, cortex.ErrNoStore + } + return e.store.ListConversations(ctx, filter) +} + +// GetConversation returns one conversation in the caller's scope. +func (e *Engine) GetConversation(ctx context.Context, convID id.ConversationID) (*a2a.Conversation, error) { + if e.store == nil { + return nil, cortex.ErrNoStore + } + return e.store.GetConversation(ctx, convID) +} + +// ListMessages returns messages, optionally narrowed to one conversation. +func (e *Engine) ListMessages(ctx context.Context, filter *a2a.MessageListFilter) ([]*a2a.Envelope, error) { + if e.store == nil { + return nil, cortex.ErrNoStore + } + return e.store.ListMessages(ctx, filter) +} + +// AgentInbox returns an agent's delivered messages. +// +// It goes through the bus rather than the store because reading an inbox +// marks what it returns as read, and that is the bus's rule rather than a +// storage detail. +func (e *Engine) AgentInbox(ctx context.Context, agentName string, filter a2a.InboxFilter) ([]a2a.InboxItem, error) { + if e.a2a == nil { + return nil, ErrNoA2A + } + return e.a2a.Inbox(ctx, agentName, filter) +} + +// SendMessage injects a message from outside a run: an operator answering +// an agent, an HTTP handler carrying one in, or (later) a remote peer. +// +// It is the same path an agent's own agent_send takes, so a message from +// outside is delivered, hop-counted and correlated exactly like one from +// inside. A reply carrying in_reply_to therefore resumes a waiting run +// here too, which is what lets a human answer an agent that asked. +func (e *Engine) SendMessage(ctx context.Context, params a2a.SendParams) (*a2a.SendResult, error) { + if e.a2a == nil { + return nil, ErrNoA2A + } + return e.a2a.Send(ctx, params) +} From 16eb96c8ade8f6745d9d56b4e7ac03698cf8c592 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:05:38 -0500 Subject: [PATCH 23/50] docs: document agent messaging --- README.md | 8 +- docs/content/docs/execution/messaging.mdx | 133 ++++++++++++++++++++++ docs/content/docs/execution/meta.json | 8 +- 3 files changed, 145 insertions(+), 4 deletions(-) create mode 100644 docs/content/docs/execution/messaging.mdx diff --git a/README.md b/README.md index ea57c6c..172f709 100644 --- a/README.md +++ b/README.md @@ -10,9 +10,10 @@ Cortex is a Go framework for building AI agents with human-like traits. Instead - **Execution Tracking** — Runs, Steps, and Tool Calls with full observability - **Memory** — Conversation history, working memory, and summaries per agent, scoped to the host's own hierarchy - **Checkpoints** — Human-in-the-loop approval gates that pause runs for review -- **Plugin System** — 16 lifecycle hooks with type-cached dispatch (zero-cost for unimplemented hooks) +- **Agent Messaging**: FIPA-ACL messages between agents, with fire-and-forget sends, a durable ask that suspends the caller until a peer answers and survives a restart, and a mailbox for everything else +- **Plugin System** — 19 lifecycle hooks with type-cached dispatch (zero-cost for unimplemented hooks) - **Host-Defined Scope** — Context-based scope and app isolation across all operations; the host declares its own levels (workspace, org, tenant, whatever it needs) and cortex enforces them structurally -- **55 REST Endpoints** — Full CRUD for all entities, agent execution, streaming, sessions, orchestration, and tools +- **59 REST Endpoints** — Full CRUD for all entities, agent execution, streaming, sessions, orchestration, messaging, and tools - **Forge Integration** — First-class extension for the Forge application framework - **TypeID Identifiers** — 12 type-prefixed, UUIDv7-based, K-sortable IDs @@ -97,10 +98,11 @@ cortex (root) — Config, context helpers, errors, Entity base type ├── cognitive — Cognitive processing styles, phases, strategies ├── communication — Communication styles (tone, formality, verbosity) ├── perception — Attention filters, context windows +├── a2a Agent-to-agent messaging: ACL envelopes, conversations, mailboxes, durable ask ├── run — Run/Step/ToolCall tracking, state machine ├── memory — Conversation, working memory, summaries ├── checkpoint — Human-in-the-loop approval gates -├── id — 12 TypeID types (agt_, skl_, trt_, bhv_, prs_, arun_, ...) +├── id — 15 TypeID types (agt_, skl_, trt_, bhv_, prs_, arun_, msg_, conv_, dlv_, ...) ├── store — Composite store interface (13 sub-interfaces, 89 methods) │ ├── postgres — Production PostgreSQL store │ ├── sqlite — SQLite store diff --git a/docs/content/docs/execution/messaging.mdx b/docs/content/docs/execution/messaging.mdx new file mode 100644 index 0000000..576f117 --- /dev/null +++ b/docs/content/docs/execution/messaging.mdx @@ -0,0 +1,133 @@ +--- +title: Agent Messaging +description: Agents addressing each other directly with FIPA-ACL messages, a durable ask that survives a restart, and a mailbox for everything else. +--- + +Orchestration coordinates agents through shared state: a blackboard every +participant reads and writes, inside one run somebody started. Messaging is the +other half. An agent addresses a peer by name, says what kind of thing it is +saying, and either carries on or waits for the answer. No orchestration has to +exist for any of it. + +Turn it on with `engine.WithA2A`. Nothing appears without it: no tools in any +agent's list, no rows in any messaging table, no behaviour you did not ask for. + +```go +eng, err := engine.New( + engine.WithStore(st), + engine.WithA2A(a2a.Options{ + HopCeiling: 8, + Workers: 4, + DefaultReplyBy: 5 * time.Minute, + SweepInterval: 30 * time.Second, + }), +) +``` + +## Three tools + +Your agents get three new tools, and the whole feature is what they do. + +`agent_send` posts a message and returns immediately. Use it to tell somebody +something, confirm, refuse, or hand work along. Nothing waits. + +`agent_ask` asks a peer a question and suspends the asking run until the answer +comes back. The answer arrives as that tool call's result, so from the model's +side it reads like any other tool that took a while. The wait is a row in your +database rather than a goroutine, so it survives a restart. + +`agent_inbox` drains the messages that arrived while the agent was busy. +Reading marks them read, so each message comes back once. + +## Speech acts + +Every message carries a FIPA-ACL performative, and cortex routes on it. There +are 22 of them, and they fall into three groups. + +**Directives start a run.** `request`, `request-when`, `request-whenever`, +`query-if`, `query-ref`, `cfp`, `propose` and `accept-proposal` all demand an +answer, so the recipient runs and its output becomes the reply. +`accept-proposal` is in this group rather than with the informatives because in +Contract Net it is the message that makes the contractor do the work. + +**Informatives land in the inbox.** `inform`, `inform-if`, `inform-ref`, +`confirm`, `disconfirm`, `agree`, `refuse`, `failure`, `not-understood`, +`reject-proposal`, `subscribe`, `proxy` and `propagate` queue for the recipient +to read on its own time. Nobody spawns a run to be told something. + +Two of those are worth knowing about. `agree` means "I took the job and I am +still working", so it does not un-pause a waiting ask. `refuse` and `failure` +do. And `proxy` and `propagate` are carried and delivered, but cortex will not +forward them on an agent's behalf: forwarding is a policy decision with an +obvious abuse shape, so build it over the inbox if you want it. + +**`cancel` is control.** It closes the conversation and un-pauses everyone +waiting on it with a failure they can read, rather than leaving them to sit +until their deadline. + +## Who can talk to whom + +An address is an agent name inside the sender's scope. Cortex enforces the +scope boundary structurally, the same way it does everywhere else, and it +refuses an address that names no agent you can reach. + +Whether two agents that share a scope may talk is your policy, and it goes +through the seam you already have. `agent_send`, `agent_ask` and `agent_inbox` +are ordinary tool calls, so your `ToolAuthorizer` sees them like any other: + +```go +func (p policy) Authorize(ctx context.Context, s cortex.Subject, call llm.ToolCall) error { + if call.Name == "agent_ask" && !p.mayAsk(s.AgentID, call) { + return errors.New("this agent may not ask others for work") + } + return nil +} +``` + +Returning `cortex.ErrRequiresApproval` works here too, which gives you +agent-to-agent messaging that pauses for a person to approve. + +## What stops a runaway + +Two agents can message each other forever, so every conversation carries a hop +budget. Each derived message increments the count, and delivery past the +ceiling is refused with a `failure` back to the sender. `HopCeiling` defaults +to 8. + +Asks also carry a deadline. If nobody answers in time, the ask resolves into a +timeout failure and the asking run continues. That is deliberate: cortex's own +suspension sweep fails a run nobody answered in time, which is the wrong verb +here. A peer that went quiet is something the asking agent can react to, and +killing the run throws that away. + +## When things go wrong + +A peer's problem reaches the asker as something it can read, never as a crash. +The recipient's run failed, the hop budget ran out, the deadline passed, the +conversation was cancelled: each of those un-pauses the asker with a `failure` +carrying the reason. + +Anything cortex can refuse, it refuses before suspending: an unknown recipient, +an agent addressing itself, a closed conversation, a store write that did not +land. Those come back as a tool error and the run carries on. A run suspended +on a message that was never sent would be a run nothing could ever resume. + +## Reading it back + +``` +GET /v1/a2a/conversations every conversation in scope +GET /v1/a2a/conversations/:id one conversation with its transcript +GET /v1/agents/:name/inbox what is waiting for an agent +POST /v1/agents/:name/messages send an agent a message +``` + +That last one is how a person answers an agent. Post a message carrying +`in_reply_to` set to the waiting ask's reply-with token and the run behind it +resumes, exactly as it would have on a peer's reply. + +## Hooks + +Three plugin hooks fire alongside the ones you already have: `MessageSent`, +`MessageDelivered` and `MessageRefused`. `AgentHandoff` is untouched and still +means what it always did, because an orchestration handoff and an agent +addressing a peer are different events. diff --git a/docs/content/docs/execution/meta.json b/docs/content/docs/execution/meta.json index 695e8b6..345fb7e 100644 --- a/docs/content/docs/execution/meta.json +++ b/docs/content/docs/execution/meta.json @@ -1,4 +1,10 @@ { "title": "Execution", - "pages": ["runs", "orchestration", "memory", "checkpoints"] + "pages": [ + "runs", + "orchestration", + "messaging", + "memory", + "checkpoints" + ] } From 7768de522513e56070cadce2c357f940a2ea2662 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:06:25 -0500 Subject: [PATCH 24/50] docs(engine): add compile-checked messaging examples An example under _examples never compiles with the package, so the wiring in the docs can drift from the wiring the code accepts. These sit in a test file instead: no Output comment, so they are built and never run. --- engine/example_a2a_test.go | 117 +++++++++++++++++++++++++++++++++++++ 1 file changed, 117 insertions(+) create mode 100644 engine/example_a2a_test.go diff --git a/engine/example_a2a_test.go b/engine/example_a2a_test.go new file mode 100644 index 0000000..d8f3794 --- /dev/null +++ b/engine/example_a2a_test.go @@ -0,0 +1,117 @@ +package engine_test + +import ( + "context" + "log" + "time" + + "github.com/xraph/grove" + "github.com/xraph/grove/drivers/sqlitedriver" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/engine" + sqlitestore "github.com/xraph/cortex/store/sqlite" +) + +// ExampleWithA2A wires two agents that can talk to each other. +// +// There is no Output comment, so this compiles with the package and is +// never executed: it needs a real model behind it to do anything. What it +// is here for is to stop the wiring in the docs from drifting away from +// the wiring the code actually accepts. +func ExampleWithA2A() { + ctx := cortex.WithScope(context.Background(), cortex.Scope{ + Levels: []cortex.Level{{Key: "tenant", Value: "acme"}}, + }) + + drv := sqlitedriver.New() + if err := drv.Open(ctx, "cortex.db"); err != nil { + log.Fatal(err) + } + db, err := grove.Open(drv) + if err != nil { + log.Fatal(err) + } + st := sqlitestore.New(db) + if migrateErr := st.Migrate(ctx); migrateErr != nil { + log.Fatal(migrateErr) + } + + eng, err := engine.New( + engine.WithStore(st), + // Messaging is opt-in. Without this, the three tools below never + // appear in any agent's tool list. + engine.WithA2A(a2a.Options{ + HopCeiling: 8, + Workers: 4, + DefaultReplyBy: 5 * time.Minute, + }), + ) + if err != nil { + log.Fatal(err) + } + + // Start brings the dispatcher up: it carries queued messages, runs the + // agents they are addressed to, and resumes whoever was waiting. It + // also redrives anything a previous process queued and never carried. + if startErr := eng.Start(ctx); startErr != nil { + log.Fatal(startErr) + } + defer func() { _ = eng.Stop(ctx) }() + + for _, cfg := range []*agent.Config{ + { + Name: "planner", + Model: "gpt-4o", + SystemPrompt: "You plan work and delegate. When you need something you cannot " + + "determine yourself, use agent_ask to ask the specialist who can.", + MaxSteps: 10, + }, + { + Name: "db-expert", + Model: "gpt-4o", + SystemPrompt: "You answer questions about the production database.", + MaxSteps: 10, + }, + } { + if createErr := eng.CreateAgent(ctx, cfg); createErr != nil { + log.Fatal(createErr) + } + } + + // The planner's model can now call agent_ask("db-expert", ...). That + // run suspends on a row in the database rather than a goroutine, the + // db-expert runs, and its answer comes back as the planner's tool + // result. If this process dies in the middle, the next one redrives it. + r, err := eng.RunAgent(ctx, "planner", "Is the orders table safe to migrate tonight?", nil) + if err != nil { + log.Fatal(err) + } + log.Println(r.State, r.Output) +} + +// ExampleEngine_SendMessage answers an agent that asked a question. +// +// This is the path behind POST /v1/agents/:name/messages: the reply +// carries the waiting ask's reply-with token, so posting it resumes the +// run behind it exactly as a peer's answer would have. +func ExampleEngine_SendMessage() { + var ( + eng *engine.Engine + ctx context.Context + replyWith string // read from the pending ask, or from the ask message + ) + + _, err := eng.SendMessage(ctx, a2a.SendParams{ + Sender: a2a.Address{Agent: "on-call-human"}, + Receivers: []a2a.Address{{Agent: "planner"}}, + Performative: a2a.Inform, + Content: "Yes, the migration window is approved for 02:00 UTC.", + InReplyTo: replyWith, + }) + if err != nil { + log.Fatal(err) + } +} From e3ce8ceace3bebde4e112c9bee5b932539bfc019 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:07:07 -0500 Subject: [PATCH 25/50] docs: changelog for agent messaging --- CHANGELOG.md | 113 +++++++++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 113 insertions(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 43352b6..a381eb8 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -4,6 +4,119 @@ All notable changes to this project are documented in this file. The format follows [Keep a Changelog](https://keepachangelog.com/en/1.0.0/). +## [1.13.0] - Unreleased + +Agents can address each other now. Not through a blackboard inside an +orchestration somebody started, which is what v1.11.0 gave you, but +directly: an agent names a peer, says what kind of thing it is saying, +and either carries on or waits for the answer. + +The vocabulary is FIPA-ACL, all 22 performatives, because it is the one +agent-communication standard with thirty years of use behind it and +because the task-allocation protocols worth building next are defined in +its terms. Cortex routes on the speech act. A `request` or a `cfp` starts +a run for the recipient and its output becomes the reply. An `inform` or +a `refuse` lands in a mailbox, because nobody should spend an LLM call +being told something. A `cancel` closes the conversation and un-pauses +everyone waiting on it. + +Three tools show up in your agents' tool lists, and only if you asked for +them with `engine.WithA2A`. `agent_send` posts and returns. `agent_inbox` +drains what arrived while the agent was busy. `agent_ask` is the +interesting one: it suspends the asking run until a peer answers, and the +answer comes back as that tool call's result. + +That wait is a row in your database, not a goroutine. The orchestration +spec parked message-bus comms in June because true agent-to-agent +messaging needed interruptible agents; durable suspend and resume landed +in v1.10.0, so the blocker was already gone. An asking agent can wait +minutes for a peer that is itself waiting on a third agent, the process +can die in the middle, and the next one picks it all up. + +**Containment is not optional.** Every conversation carries a hop budget, +default 8, and delivery past the ceiling is refused with a `failure` back +to the sender. Asks carry a deadline, and an overdue one resolves into a +timeout failure that lets the asking run continue. That last part is a +deliberate departure from the engine's own suspension sweep, which fails +a run nobody answered in time: for a peer that went quiet, killing the +run throws away something the agent could have acted on. + +Who may talk to whom is your decision, not cortex's. The three tools are +ordinary tool calls, so your `ToolAuthorizer` sees them like any other, +and `cortex.ErrRequiresApproval` gives you messaging that pauses for a +person. What cortex enforces structurally is the scope boundary and the +existence of the recipient: an address that names no agent you can reach +comes back as an error the model can read, rather than a run suspended +against somebody who will never answer. + +Four endpoints come with it: `GET /v1/a2a/conversations`, +`GET /v1/a2a/conversations/:id`, `GET /v1/agents/:name/inbox`, and +`POST /v1/agents/:name/messages`. That last one is how a person answers +an agent. Post a message carrying `in_reply_to` set to the waiting ask's +reply-with token and the run behind it resumes, exactly as it would have +on a peer's reply. It is also where a remote transport will terminate +when cross-process messaging lands. + +### Breaking changes + +- **`store.Store` now embeds `a2a.Store`**, sixteen additional methods on + top of everything the composite already required. A custom + `store.Store` implementation (see + `docs/content/docs/guides/custom-store.mdx`) no longer satisfies the + interface until it implements them. The three bundled backends already + do, and `store/storetest` has conformance cases that will tell you + whether yours is right, including a raced claim. + +- **`suspension.SuspendReason` gained a third value**, `agent_reply`. A + host that switches on the reason and assumed two cases now has a third + to handle. It is not resumable through the public `Resume`: only the + message bus can answer it, and a caller that tries gets + `engine.ErrNotAgentReplyResumable`. That is the same shape approval + pauses already had, and for the same reason, since a caller answering + one would be forging a message the peer never sent. + +### Added + +- `a2a` package: the ACL envelope, conversations, deliveries, the pending + ask ledger, the bus, and a dispatcher that can be drained synchronously + in tests and runs workers in production. It is a leaf package, and a + test enforces that: it may import `cortex` and `id` and nothing else in + this module. +- Four new tables per backend (`cortex_a2a_messages`, + `cortex_a2a_conversations`, `cortex_a2a_deliveries`, + `cortex_a2a_pending_asks`) across sqlite, postgres and mongo. +- `engine.WithA2A`, `Engine.A2A`, `Engine.SendMessage`, + `Engine.AgentInbox`, `Engine.ListConversations`, + `Engine.GetConversation`, `Engine.ListMessages`. +- Three plugin hooks: `MessageSent`, `MessageDelivered`, + `MessageRefused`. `AgentHandoff` is untouched and still means what it + always did, because an orchestration handoff and an agent addressing a + peer are different events. +- Three TypeID prefixes: `msg`, `conv`, `dlv`. + +### Changed + +- `RunOpts`, `AgentResult` and `AgentRunner` moved from `orchestration` + to the root `cortex` package, where `a2a` can reach them too. The + orchestration names are aliases, so every existing caller and the + engine's own adapter compile unchanged. +- Builtin tools can now report a pending call rather than only a + completed one, which is what lets `agent_ask` suspend a step. This is + internal to the engine; a host-registered tool's contract is unchanged. + +### Known gaps + +- A delivery claimed by a process that then dies stays marked + `delivering` and is not redriven. Nothing wedges, because an ask + resolves on its deadline either way, but an informative message caught + in that window is lost. The delivery row already carries `claimed_at` + for the reclaim to key on. +- Postgres and mongo were written against the conformance suite but not + executed: the environment this landed in could not start database + containers. Sqlite is exercised, including the raced claim. Run + `go test ./store/postgres/ ./store/mongo/` with docker up before + trusting either. + ## [1.12.0] - Unreleased A system prompt stops being one opaque string. It's an ordered set of From 401f3e0fb35db8c48f65f45e5f72cdbe47add527 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:11:18 -0500 Subject: [PATCH 26/50] docs: correct the untested-backend note Postgres containers do start here, so that backend runs the full conformance suite including the raced claim. Mongo is the one that cannot start, and it could not before this branch either. --- CHANGELOG.md | 11 ++++++----- .../2026-08-26-cortex-a2a-2-persistence-and-engine.md | 2 +- 2 files changed, 7 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index a381eb8..234191b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -111,11 +111,12 @@ when cross-process messaging lands. resolves on its deadline either way, but an informative message caught in that window is lost. The delivery row already carries `claimed_at` for the reclaim to key on. -- Postgres and mongo were written against the conformance suite but not - executed: the environment this landed in could not start database - containers. Sqlite is exercised, including the raced claim. Run - `go test ./store/postgres/ ./store/mongo/` with docker up before - trusting either. +- Mongo was written against the conformance suite but never executed: + the environment this landed in could not start a mongo container, and + could not before this branch either. Sqlite and postgres both run the + full suite, including the raced claim on each. Run + `go test ./store/mongo/` with a working mongo container before + trusting that backend. ## [1.12.0] - Unreleased diff --git a/docs/superpowers/plans/2026-08-26-cortex-a2a-2-persistence-and-engine.md b/docs/superpowers/plans/2026-08-26-cortex-a2a-2-persistence-and-engine.md index 32bc88d..517f118 100644 --- a/docs/superpowers/plans/2026-08-26-cortex-a2a-2-persistence-and-engine.md +++ b/docs/superpowers/plans/2026-08-26-cortex-a2a-2-persistence-and-engine.md @@ -171,7 +171,7 @@ Same as Task 1, against [store/postgres/orchestration.go](../../../store/postgre - JSON columns are `jsonb`. - `ClaimPendingAsk` can use `UPDATE ... RETURNING`, which collapses the claim and the read into one statement. Prefer it. -Run: `go test ./store/postgres/ -race -run TestConformance`. **If Docker is unavailable, this cannot be verified. Say so plainly rather than reporting a pass.** +Run: `go test ./store/postgres/ -race -run TestConformance`. Postgres containers start fine in this environment, so this one is genuinely verifiable. --- From 524bfc352b7554dd610bdf359c87614dfb02855b Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:22:47 -0500 Subject: [PATCH 27/50] docs(a2a): design the remote transport Checked against the normative proto rather than memory, and the protocol had moved: A2A is at 1.0.0 under the Linux Foundation, method names are PascalCase, and the agent card path changed. The useful find is AgentInterface.tenant, which is the spec's own answer to serving many agents behind one endpoint, so a cortex agent name maps straight onto it. --- ...8-26-cortex-a2a-remote-transport-design.md | 383 ++++++++++++++++++ 1 file changed, 383 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-26-cortex-a2a-remote-transport-design.md diff --git a/docs/superpowers/specs/2026-08-26-cortex-a2a-remote-transport-design.md b/docs/superpowers/specs/2026-08-26-cortex-a2a-remote-transport-design.md new file mode 100644 index 0000000..87d3b02 --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-cortex-a2a-remote-transport-design.md @@ -0,0 +1,383 @@ +# Cortex A2A: Remote Transport (spec 2) + +- **Status:** Approved (brainstorming), ready for implementation planning +- **Date:** 2026-08-26 +- **Author:** Rex Raphael +- **Repos touched:** `github.com/xraph/cortex`, plus a new module `github.com/xraph/cortex/a2aremote`. +- **Related:** Second of the three specs decomposed in + [2026-08-26-cortex-a2a-messaging-design.md](2026-08-26-cortex-a2a-messaging-design.md) §3. Plugs into the `Transport` seam that spec defined. +- **Method:** Test-driven. Failing test first, every unit. + +--- + +## 1. Goal + +Let cortex agents talk to agents that are not cortex agents, in both directions, +over the Agent2Agent protocol. + +After this lands: + +1. A remote A2A client can address any cortex agent, and gets back a Task it can + poll, cancel and subscribe to. +2. A cortex agent can `agent_ask` a peer at `worker@peer.example` and the answer + comes back through the same suspension and resume path a local peer uses. +3. Both sides speak all three protocol bindings: JSON-RPC 2.0, gRPC, and + HTTP+JSON. +4. The FIPA-ACL semantics from spec 1 survive the hop, as a declared A2A + extension rather than a private convention. + +### The protocol, as it actually is today + +Checked against the normative sources rather than memory, because the protocol +moved: A2A is at **1.0.0**, governed by the Linux Foundation, and the canonical +data model is [`specification/a2a.proto`](https://github.com/a2aproject/A2A) with +the three bindings derived from it. + +- **Method names are PascalCase**: `SendMessage`, `SendStreamingMessage`, + `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, + `CreateTaskPushNotificationConfig` and siblings, `GetExtendedAgentCard`. The + `message/send` style is 0.x and a 1.0 server should not answer to it. +- **Agent Cards live at `/.well-known/agent-card.json`.** 0.x used + `/.well-known/agent.json`, and a 1.0 client never looks there. +- **`AgentInterface` carries `url`, `protocol_binding` (`JSONRPC`, `GRPC`, + `HTTP+JSON`) and `tenant`.** `tenant` is "an opaque string used for routing + requests to a specific agent or tenant when multiple agents are served behind + a single A2A endpoint", and every request message carries a `tenant` field. +- **Task states**: `SUBMITTED`, `WORKING`, `COMPLETED`, `FAILED`, `CANCELED`, + `REJECTED`, `INPUT_REQUIRED`, `AUTH_REQUIRED`. +- **Errors** are `-32001` through `-32009`, listed in §5. + +### Non-goals + +- **Push notifications.** The four config methods plus webhook delivery are a + large surface, and `AgentCapabilities.push_notifications: false` is an honest + declaration rather than a gap. A host that needs callbacks has the plugin + hooks from spec 1. +- **Agent card signatures.** `AgentCardSignature` earns its keep in a public + registry. Peers you configured by hand are already authenticated by the + credentials you configured with them. +- **`GetExtendedAgentCard`.** Declared unsupported, and it returns + `-32007 ExtendedAgentCardNotConfiguredError`. +- **Interaction protocols.** Contract Net is spec 3, and it rides on this + unchanged. + +--- + +## 2. Architecture + +### 2.1 A new module + +`a2aremote/`, module `github.com/xraph/cortex/a2aremote`, with a `replace` back +to the root the way [api/go.mod](../../../api/go.mod) already does. + +It is a separate module for one concrete reason: gRPC pulls in `grpc-go` and +`protobuf`, and a host that only ever wanted in-process messaging should not +inherit that dependency graph. The core module's dependencies do not move at all. + +``` +a2aremote/ + service.go the semantics: every binding funnels through this + seams.go Gateway, PeerResolver, PeerRegistry + card.go AgentCard types, building one from an agent, serving, fetching + mapping.go Envelope <-> Message, Run -> Task, the FIPA extension + types.go Message, Part, Task, TaskStatus, Artifact + errors.go the A2A error codes and their mapping + jsonrpc.go the JSONRPC binding + rest.go the HTTP+JSON binding + client.go outbound: implements a2a.Transport + grpcbind/ the GRPC binding, in a subpackage so its deps stay contained + a2apb/ generated from the normative proto +``` + +### 2.2 Seams + +| Seam | Purpose | +|---|---| +| `Gateway` | The cortex surface the service needs: `SendMessage`, `GetRun`, `ListRuns`, `CancelRun`, `ListMessages`, `GetAgentByName`, `ListAgents`. The engine satisfies it; tests pass a fake. | +| `PeerResolver` | Authenticates an inbound caller and returns the `Peer` it is, including the scope its messages land in. Host-implemented. Cortex ships no authentication of its own. | +| `PeerRegistry` | Outbound: maps a `Node` to a base URL and credentials, from `WithA2APeer` config. | + +```go +// Credentials is what a binding could learn about a caller. It is +// transport-neutral on purpose: headers cover HTTP, gRPC metadata lands in +// the same map, and TLS covers mutual-TLS peers. +type Credentials struct { + Headers map[string][]string + RemoteAddr string + TLS *tls.ConnectionState +} + +// Peer is who the caller turned out to be. +type Peer struct { + // Node is how this caller appears as a2a.Address.Node. Every sender + // name a peer claims is namespaced by it, which is what stops a peer + // presenting itself as a local agent. + Node string + Scope cortex.Scope +} + +type PeerResolver interface { + ResolvePeer(ctx context.Context, cred Credentials) (Peer, error) +} +``` + +### 2.3 Two changes to the core module + +Both are small, and the first is a bug spec 1 shipped. + +**`deliverOne` never consulted the transports.** It routes by performative and +calls the local runner, so an envelope addressed to `worker@peer.example` would +have been "delivered" by running a local agent called `worker`. Routability was +checked at send time and then ignored at delivery time. Delivery now asks +whether the receiver is local, and hands a remote one to the transport that +handles it. + +**`Bus.AddTransport`.** The outbound client needs the bus (a remote reply is fed +back in through `Send` so it can resolve a waiting ask), and the bus needs the +client. Construction cannot be circular, so the engine builds the bus, builds +the client with it, then registers the transport. + +--- + +## 3. The wire mapping + +### 3.1 Addressing: tenant is the agent + +Cortex hosts many agents behind one endpoint, which is exactly the case +`AgentInterface.tenant` exists for. **The tenant string is the cortex agent +name.** Each agent gets its own Agent Card, whose `supported_interfaces` all +carry `tenant: ""`, and an inbound request's `tenant` field selects +the recipient. + +Cards are served per agent: + +``` +GET /{prefix}/agents/{name}/.well-known/agent-card.json +``` + +and, when a host names a default agent with `WithA2ADefaultAgent`, that agent's +card is also served at the root `/.well-known/agent-card.json` so plain +discovery finds something. + +### 3.2 Identifiers + +| A2A | cortex | Why | +|---|---|---| +| `Task.id` | `id.AgentRunID` (`arun_…`) verbatim | A task is a view over a run, so the run's id IS the task's id. A peer quoting one back names a row we can read. | +| `Message.context_id` | `id.ConversationID` (`conv_…`) | A2A's own words for `contextId` are "logically groups multiple related Task and Message objects", which is what a conversation is. | +| `Message.message_id` | `id.MessageID` (`msg_…`) | Same idea, one hop down. | + +TypeID prefixes make all three self-describing on the wire, which is a pleasant +accident of the identity scheme rather than a design goal. + +### 3.3 Task is a projection, never a stored entity + +```go +func taskState(r *run.Run) TaskState { + switch r.State { + case run.StateCreated: return TaskStateSubmitted + case run.StateRunning: return TaskStateWorking + case run.StateCompleted: return TaskStateCompleted + case run.StateFailed: return TaskStateFailed + case run.StateCancelled: return TaskStateCanceled + case run.StatePaused: return TaskStateInputRequired + } +} +``` + +`INPUT_REQUIRED` is the interesting row. A paused run is waiting on something +outside itself, which is precisely what that state means, and it is true whether +the run paused on an approval checkpoint, an external tool, or an ask to a third +agent. The peer learns "this is waiting on somebody" without learning whose +internal business it is waiting on. + +There is no task table and no task lifecycle of cortex's own. Two state machines +over one piece of work drift, and a task claiming `WORKING` for a run that failed +an hour ago is a lie the peer has no way to detect. + +`Task.artifacts` carries the run's output as one `TextPart` artifact. +`Task.history` carries the conversation's messages when the caller asks for them. + +### 3.4 Messages, and where the performative goes + +A2A's `Message` has `role` and `parts` and no speech act. It also has +`extensions` ("the URIs of extensions that are present or contributed to this +Message") and `metadata`, which is the designed-in place for exactly this. + +The extension URI is: + +``` +https://cortex.xraph.dev/a2a/extensions/fipa-acl/v1 +``` + +It is declared in each card under `AgentCapabilities.extensions` as an +`AgentExtension{uri, description, required: false}`. **`required: false` is +deliberate**: a peer that has never heard of FIPA still receives a valid A2A +message and reads the text, and a peer that has gets the whole ACL envelope. +Declaring it required would refuse conversations we can perfectly well hold. + +The ACL parameters ride in `Message.metadata` under one namespaced object: + +```json +{ + "https://cortex.xraph.dev/a2a/extensions/fipa-acl/v1": { + "performative": "request", + "replyWith": "msg_01j...", + "inReplyTo": "", + "ontology": "ops", + "protocol": "fipa-request", + "language": "", + "encoding": "", + "replyBy": "2026-08-26T12:05:00Z" + } +} +``` + +`conversationId` is absent from that object on purpose: it is `context_id`, a +native A2A field, and duplicating it would create two places to disagree. + +An inbound message with no FIPA metadata is treated as `request` when it expects +an answer and `inform` when it does not, which is the reading that makes a +non-cortex peer work without knowing anything about us. + +### 3.5 Errors + +| A2A error | Code | Raised when | +|---|---|---| +| `TaskNotFoundError` | `-32001` | No run with that id in the peer's scope. | +| `TaskNotCancelableError` | `-32002` | The run is already terminal. | +| `PushNotificationNotSupportedError` | `-32003` | Any push-notification method. | +| `UnsupportedOperationError` | `-32004` | A method this binding does not implement. | +| `ContentTypeNotSupportedError` | `-32005` | A part type we cannot read (file or data parts, for now). | +| `InvalidAgentResponseError` | `-32006` | Outbound: a peer answered with something unparseable. | +| `ExtendedAgentCardNotConfiguredError` | `-32007` | `GetExtendedAgentCard`. | +| `ExtensionSupportRequiredError` | `-32008` | A peer demanded an extension we do not implement. | +| `VersionNotSupportedError` | `-32009` | An `A2A-Version` we do not speak. | + +Standard `-32600` / `-32601` / `-32602` / `-32603` cover malformed requests, +unknown methods, bad params and internal failures. + +--- + +## 4. Flow + +### 4.1 Inbound + +1. The binding parses the request into `Credentials`, a `tenant` and a `Message`. +2. `PeerResolver.ResolvePeer` authenticates and returns the `Peer`. A resolver + error is `-32600` with no detail: an unauthenticated caller learns nothing + about what exists. +3. The service puts **the resolver's scope** on the context. Not the message's, + not a header's, not a field a caller can set. +4. The message maps to an `a2a.SendParams` whose sender is + `{Agent: , Node: peer.Node}` and whose receiver is + `{Agent: tenant}`, then goes through `Gateway.SendMessage`, which is the same + bus path a local `agent_send` takes. Hop budget, conversation status and + recipient resolution all apply exactly as they do locally. +5. An informative returns a `Message` acknowledgement. A directive returns a + `Task` projected from the run the delivery started. + +### 4.2 Outbound + +1. An agent calls `agent_ask` with `to: "worker@peer.example"`. The address + parses into `{Agent: "worker", Node: "peer.example"}`. +2. `Client.Handles` claims any non-local address whose `Node` is a registered + peer. Anything else stays unroutable, so an agent cannot invent a hostname + and have cortex call it. +3. `Client.Deliver` fetches the peer's Agent Card (cached, honouring + `Cache-Control`), picks the first `supported_interfaces` entry whose + `protocol_binding` we speak, preferring `JSONRPC`, and calls `SendMessage` + with the tenant that interface declares. +4. A `Message` response is the answer. A `Task` response in a terminal state + carries the answer in its artifacts. A non-terminal `Task` is polled with + backoff until the ask's own deadline, which spec 1 already enforces and + already turns into a readable failure. +5. Either way the answer re-enters through `Bus.Send` carrying `InReplyTo`, + which resolves the pending ask and resumes the waiting run. **The local and + remote paths converge here**, so a resumed agent cannot tell whether its peer + was in this process or another company's data centre. + +--- + +## 5. Security + +**Scope comes from the resolver and nowhere else.** A message body cannot name +its own scope, and a header cannot either. This is the single load-bearing rule +of the inbound path. + +**A peer cannot impersonate a local agent.** Every sender a peer claims is +namespaced with the `Node` the resolver assigned, so the best a hostile peer can +do is claim to be a different agent at its own node. + +**Cortex authenticates nothing itself.** It ships the `PeerResolver` seam and a +documented bearer-token reference implementation in the docs. Hosts already have +identity; a second, weaker identity system inside cortex would be a liability +rather than a convenience. + +**Outbound trust is configuration, not data.** Peers come from +`WithA2APeer(node, endpoint, credentials)` at construction. An agent's own output +cannot introduce a new peer, which means a prompt-injected agent can at worst +misuse a peer you already trusted. + +**Agent cards are public.** A card names an agent, its description and its +skills, so anything in an agent's description or skill list is disclosed to +anyone who can reach the endpoint. Card serving is opt-in per agent through +`WithA2AExposed(names...)`, defaulting to none. + +--- + +## 6. Testing + +**Mapping tests, no network.** Table-driven over every run state to task state, +envelope to message and back, and an inbound message with no FIPA metadata +landing on the right default performative. + +**Service tests with fakes.** A fake `Gateway` and a fake `PeerResolver` cover: +the resolver's scope reaching the gateway call, a resolver error refusing before +anything is written, an informative returning a `Message` while a directive +returns a `Task`, and every error code being raised by the condition that names +it. + +**Binding tests.** Golden request and response bodies per binding, asserting the +three produce identical outcomes for identical semantics. That is the property +the shared `Service` exists to give, so it is worth a test rather than a comment. + +**The loopback test.** Two engines, each with its own sqlite store, one served +over `httptest`. An agent on engine A asks an agent on engine B, over the wire, +and A's run resumes with B's answer. Nothing about this can be proven by unit +tests, and everything about the feature depends on it. + +**Interop check, by hand.** The card and one `SendMessage` exchange validated +against the published A2A JSON schema, so a mistyped field name is caught by the +spec's own artifact rather than by a peer. + +--- + +## 7. Implementation phases + +**Plan 1, the core.** The module, `Service`, seams, types, mapping, the FIPA +extension, cards, the JSONRPC binding, the outbound client, and the two core +module changes. Ends with the loopback test passing over JSON-RPC. + +**Plan 2, the other two bindings.** `HTTP+JSON` handlers and the gRPC service +generated from the normative proto, both over the same `Service`, plus the +binding-equivalence tests. + +**Plan 3, streaming.** `SendStreamingMessage` and `SubscribeToTask` over SSE, +mapped from `Engine.StreamAgent` events, and `AgentCapabilities.streaming: true` +once they work. + +--- + +## 8. Out of scope and follow-ups + +- Push notifications, card signatures and the extended card, all declared + unsupported rather than silently missing. +- **Contract Net** (spec 3), which needs nothing from here beyond the + performatives already carried. +- A peer registry with CRUD. Trust stays in configuration for now; making it + runtime-mutable data is a change in blast radius that deserves its own + decision. +- Cross-process delivery redrive. A delivery to a peer that is down fails and is + answered by the ask deadline; it is not retried with backoff. That is worth + revisiting once there is operational evidence about how peers actually fail. From 4b1182afd47a2d7aabb5fb1cd6d94075442a2785 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:25:40 -0500 Subject: [PATCH 28/50] docs(a2a): plan the remote transport core --- .../2026-08-26-cortex-a2a-remote-1-core.md | 911 ++++++++++++++++++ 1 file changed, 911 insertions(+) create mode 100644 docs/superpowers/plans/2026-08-26-cortex-a2a-remote-1-core.md diff --git a/docs/superpowers/plans/2026-08-26-cortex-a2a-remote-1-core.md b/docs/superpowers/plans/2026-08-26-cortex-a2a-remote-1-core.md new file mode 100644 index 0000000..676bd18 --- /dev/null +++ b/docs/superpowers/plans/2026-08-26-cortex-a2a-remote-1-core.md @@ -0,0 +1,911 @@ +# Cortex A2A Remote, Plan 1: core, cards, JSON-RPC and the client + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Cortex agents reachable by remote A2A clients and able to call remote peers, over JSON-RPC, with FIPA-ACL semantics surviving the hop. + +**Architecture:** A new module `a2aremote` holds one `Service` with every semantic decision in it; bindings are format translation over that service. Outbound is a `Client` implementing the `a2a.Transport` interface spec 1 already defined. The core module gains two small things: transports actually consulted at delivery time, and a way to register one after the bus exists. + +**Tech Stack:** Go 1.26, standard library `net/http` and `encoding/json`. No new dependency in the core module. The new module depends only on the root module. + +**Spec:** [docs/superpowers/specs/2026-08-26-cortex-a2a-remote-transport-design.md](../specs/2026-08-26-cortex-a2a-remote-transport-design.md) + +**Depends on:** spec 1, complete and merged on this branch. + +## Deviation from the fully-worked format + +Like the second plan of spec 1, this carries full code where a wrong choice is expensive (the security rules, the mapping tables, the transport hand-off) and interfaces plus test names where the work is mechanical. The reference implementations are in this repo already. + +## Global Constraints + +- **TDD**, failing test first, every unit. +- **The core module gains no dependency.** `a2aremote` may import the root module; nothing in the root module may import `a2aremote`. +- **Scope comes from the `PeerResolver` and nowhere else.** No message field, no header, no query parameter may influence which cortex scope an inbound request acts in. A test asserts this directly. +- **Wire names come from the normative proto**, `github.com/a2aproject/A2A/specification/a2a.proto`: JSON field names are `lowerCamelCase` of the proto field names (`messageId`, `contextId`, `taskId`, `referenceTaskIds`, `protocolBinding`, `supportedInterfaces`, `defaultInputModes`). Do not invent casing. +- **Method strings are PascalCase**: `SendMessage`, `GetTask`, `ListTasks`, `CancelTask`, `SubscribeToTask`, `GetExtendedAgentCard`. +- **Agent cards are served at `/.well-known/agent-card.json`**, never `/.well-known/agent.json`. +- `make lint` clean, `gofmt` clean, conventional commits, no AI attribution. + +--- + +### Task 1: Delivery consults the transports + +Spec 1 shipped a `Transport` seam that `deliverOne` never asked. A remote address was checked for routability at send time and then delivered by running a local agent of the same name. + +**Files:** +- Modify: `a2a/deliver.go`, `a2a/bus.go` +- Test: `a2a/deliver_remote_test.go`, `a2a/fakes_test.go` + +**Interfaces:** +- Produces: `(*Bus).AddTransport(t Transport)`, and `deliverOne` routing a non-local receiver to the transport that handles it. + +- [ ] **Step 1: Write the failing test** + +```go +// fakeTransport records what it was asked to carry. +type fakeTransport struct { + mu sync.Mutex + node string + carried []*Envelope + err error +} + +func (f *fakeTransport) Handles(addr Address) bool { return addr.Node == f.node } + +func (f *fakeTransport) Deliver(_ context.Context, e *Envelope, _ Address) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return f.err + } + f.carried = append(f.carried, e) + return nil +} + +func TestRemoteReceiverGoesToTheTransport(t *testing.T) { + b, st, runner, _, _, _ := newTestBus(t) + ctx := testCtx() + tr := &fakeTransport{node: "peer.example"} + b.AddTransport(tr) + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "worker", Node: "peer.example"}}, + Performative: Request, Content: "over there please", + }); err != nil { + t.Fatalf("Send: %v", err) + } + if _, err := b.Drain(ctx); err != nil { + t.Fatalf("Drain: %v", err) + } + + // The local runner must not have been used. A remote address that ran + // a local agent of the same name is the bug this test exists for. + if runner.callCount() != 0 { + t.Fatalf("the local runner ran %d times for a remote address", runner.callCount()) + } + if len(tr.carried) != 1 || tr.carried[0].Content != "over there please" { + t.Fatalf("transport carried %+v, want the message", tr.carried) + } + _ = st +} + +func TestRemoteDeliveryFailureDoesNotRunLocally(t *testing.T) { + b, st, runner, _, hooks, _ := newTestBus(t) + ctx := testCtx() + tr := &fakeTransport{node: "peer.example", err: errors.New("peer unreachable")} + b.AddTransport(tr) + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "worker", Node: "peer.example"}}, + Performative: Request, Content: "hello?", + }); err != nil { + t.Fatalf("Send: %v", err) + } + if _, err := b.Drain(ctx); err != nil { + t.Fatalf("Drain: %v", err) + } + + if runner.callCount() != 0 { + t.Fatal("a failed remote delivery must never fall back to a local agent") + } + if hooks.refused() != 1 { + t.Fatalf("MessageRefused fired %d times, want 1", hooks.refused()) + } + rows, _ := st.ListQueuedDeliveries(ctx, 10) + if len(rows) != 0 { + t.Fatalf("%d rows left queued after a failed delivery", len(rows)) + } +} + +// A remote ask must still be answerable: the transport carries the +// question, and the answer arrives later through Send. +func TestRemoteAskIsResolvedByAReplyThroughTheBus(t *testing.T) { + b, _, _, resumer, _, _ := newTestBus(t) + ctx := testCtx() + b.AddTransport(&fakeTransport{node: "peer.example"}) + + ask, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "worker", Node: "peer.example"}}, + Content: "status?", + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + if _, err := b.Drain(ctx); err != nil { + t.Fatalf("Drain: %v", err) + } + + // The remote peer answers, and the client feeds it back in here. + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "worker", Node: "peer.example"}, Receivers: []Address{{Agent: "planner"}}, + Performative: Inform, Content: "all clear over here", + ConversationID: ask.ConversationID, InReplyTo: ask.ReplyWith, + }); err != nil { + t.Fatalf("reply Send: %v", err) + } + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want 1", resumer.count()) + } +} +``` + +- [ ] **Step 2: Run to verify it fails** + +Run: `go test ./a2a/ -run 'TestRemote'` +Expected: FAIL, `b.AddTransport undefined`, and once that compiles, the local runner is called for a remote address. + +- [ ] **Step 3: Implement** + +In `a2a/bus.go`: + +```go +// AddTransport registers a transport after the bus exists. +// +// It exists because construction cannot be circular: a remote transport +// needs the bus (a peer's reply is fed back through Send so it can +// resolve a waiting ask), and the bus needs the transport. The host +// builds the bus, builds the transport with it, and registers it here. +func (b *Bus) AddTransport(t Transport) { + b.mu.Lock() + defer b.mu.Unlock() + b.transports = append(b.transports, t) +} +``` + +`Bus` gains a `mu sync.RWMutex` guarding `transports`, and `routable` and the new `transportFor` take the read lock. Registration happens at startup and reads happen per delivery, so a plain mutex is right. + +In `a2a/deliver.go`, `deliverOne` asks about the receiver before it asks about the performative: + +```go + // A remote receiver is carried by a transport, whatever the + // performative says. Routing by class first would run a LOCAL agent + // named like the remote one, which is how a message addressed to + // somebody else's system gets answered by yours. + if !d.Receiver.IsLocal() { + return b.deliverRemote(ctx, d, e) + } +``` + +and: + +```go +// deliverRemote hands one delivery to whichever transport claims the +// address. A transport failure is a failed delivery, never a fallback: +// falling back to a local agent of the same name would answer another +// system's question with your own agent. +func (b *Bus) deliverRemote(ctx context.Context, d *Delivery, e *Envelope) error { + t := b.transportFor(d.Receiver) + if t == nil { + return b.failDelivery(ctx, d, fmt.Errorf("%w: %s", ErrUnroutable, d.Receiver)) + } + if err := t.Deliver(ctx, e, d.Receiver); err != nil { + if askErr := b.resolveAskWithFailure(ctx, e.ReplyWith, err.Error()); askErr != nil { + return askErr + } + return b.failDelivery(ctx, d, err) + } + return b.finishDelivery(ctx, d, e, id.AgentRunID{}) +} +``` + +Note what `deliverRemote` does NOT do: it does not wait for a reply. The transport's job is to get the envelope there; the answer comes back later as an ordinary inbound message carrying `InReplyTo`, which is the same path a local reply takes. + +- [ ] **Step 4: Verify** + +Run: `go test ./a2a/ -race -count=2` +Expected: PASS. + +- [ ] **Step 5: Commit** + +```bash +git add a2a/ +git commit -m "fix(a2a): carry remote receivers through their transport + +The transport seam existed and the delivery path never asked it, so an +envelope addressed to another system would have been answered by a local +agent that happened to share the name." +``` + +--- + +### Task 2: The module, the wire types, and the errors + +**Files:** +- Create: `a2aremote/go.mod`, `a2aremote/doc.go`, `a2aremote/types.go`, `a2aremote/errors.go` +- Test: `a2aremote/types_test.go`, `a2aremote/errors_test.go` + +**Interfaces:** +- Produces: `Message`, `Part`, `Role`, `Task`, `TaskStatus`, `TaskState` (+ the eight constants), `Artifact`, `SendMessageResult`; `Error` with `Code`/`Message`/`Data`, the nine A2A codes, and `func ErrTaskNotFound(id string) *Error` style constructors. + +Module file: + +``` +module github.com/xraph/cortex/a2aremote + +go 1.26.0 + +replace github.com/xraph/cortex => ../ + +require github.com/xraph/cortex v1.6.1 +``` + +- [ ] **Step 1: Write the failing test** + +```go +// The JSON names are the protocol's, not ours. A field renamed by a +// careless refactor is a peer that silently stops understanding us, so +// the wire shape is pinned here rather than trusted. +func TestMessageJSONNames(t *testing.T) { + m := Message{ + MessageID: "msg_1", ContextID: "conv_1", TaskID: "arun_1", + Role: RoleAgent, Parts: []Part{{Text: "hello"}}, + Extensions: []string{FIPAExtensionURI}, ReferenceTaskIDs: []string{"arun_2"}, + Metadata: map[string]any{"k": "v"}, + } + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, key := range []string{"messageId", "contextId", "taskId", "role", "parts", "extensions", "referenceTaskIds", "metadata"} { + if _, ok := raw[key]; !ok { + t.Errorf("missing wire field %q in %s", key, b) + } + } +} + +func TestTaskStateStringsAreTheProtocolsOwn(t *testing.T) { + want := map[TaskState]string{ + TaskStateSubmitted: "TASK_STATE_SUBMITTED", + TaskStateWorking: "TASK_STATE_WORKING", + TaskStateCompleted: "TASK_STATE_COMPLETED", + TaskStateFailed: "TASK_STATE_FAILED", + TaskStateCanceled: "TASK_STATE_CANCELED", + TaskStateRejected: "TASK_STATE_REJECTED", + TaskStateInputRequired: "TASK_STATE_INPUT_REQUIRED", + TaskStateAuthRequired: "TASK_STATE_AUTH_REQUIRED", + } + if len(want) != 8 { + t.Fatalf("the protocol defines 8 task states, table has %d", len(want)) + } + for state, s := range want { + if string(state) != s { + t.Errorf("state = %q, want %q", state, s) + } + } +} + +func TestErrorCodes(t *testing.T) { + cases := []struct { + err *Error + code int + }{ + {ErrTaskNotFound("arun_1"), -32001}, + {ErrTaskNotCancelable("arun_1"), -32002}, + {ErrPushNotificationNotSupported(), -32003}, + {ErrUnsupportedOperation("GetExtendedAgentCard"), -32004}, + {ErrContentTypeNotSupported("file"), -32005}, + {ErrInvalidAgentResponse("no parts"), -32006}, + {ErrExtendedCardNotConfigured(), -32007}, + {ErrExtensionSupportRequired("urn:x"), -32008}, + {ErrVersionNotSupported("0.1"), -32009}, + } + for _, tc := range cases { + if tc.err.Code != tc.code { + t.Errorf("%s: code = %d, want %d", tc.err.Message, tc.err.Code, tc.code) + } + } +} +``` + +- [ ] **Step 2: Run to verify it fails.** `cd a2aremote && go test ./...` + +- [ ] **Step 3: Implement** the types with exact JSON tags, and the errors as a `*Error` implementing `error` with a `Code int`, `Message string`, `Data []map[string]any`. Constructors name the offending value in the message, because a peer debugging an integration reads that string. + +- [ ] **Step 4: Verify.** `cd a2aremote && go test ./... -v` + +- [ ] **Step 5: Commit** + +```bash +git add a2aremote/ +git commit -m "feat(a2aremote): add the A2A wire types and error codes" +``` + +--- + +### Task 3: Mapping + +The heart of the interop: cortex shapes in, protocol shapes out, and back. + +**Files:** +- Create: `a2aremote/mapping.go` +- Test: `a2aremote/mapping_test.go` + +**Interfaces:** +- Consumes: the types from Task 2, `a2a.Envelope`, `run.Run`. +- Produces: + - `const FIPAExtensionURI = "https://cortex.xraph.dev/a2a/extensions/fipa-acl/v1"` + - `func MessageFromEnvelope(e *a2a.Envelope) Message` + - `func EnvelopeParamsFromMessage(m Message, sender a2a.Address, receiver a2a.Address) (a2a.SendParams, error)` + - `func TaskFromRun(r *run.Run, contextID string) Task` + - `func taskState(s run.State) TaskState` + +- [ ] **Step 1: Write the failing test** + +```go +func TestTaskStateProjection(t *testing.T) { + cases := map[run.State]TaskState{ + run.StateCreated: TaskStateSubmitted, + run.StateRunning: TaskStateWorking, + run.StateCompleted: TaskStateCompleted, + run.StateFailed: TaskStateFailed, + run.StateCancelled: TaskStateCanceled, + // A paused run is waiting on something outside itself, which is + // exactly what INPUT_REQUIRED means. The peer learns that it is + // waiting without learning whose business it is waiting on. + run.StatePaused: TaskStateInputRequired, + } + for state, want := range cases { + if got := taskState(state); got != want { + t.Errorf("%s -> %s, want %s", state, got, want) + } + } +} + +func TestEnvelopeRoundTripsThroughAMessage(t *testing.T) { + deadline := time.Date(2026, 8, 26, 12, 0, 0, 0, time.UTC) + e := &a2a.Envelope{ + ID: id.NewMessageID(), ConversationID: id.NewConversationID(), + Performative: a2a.CFP, Sender: a2a.Address{Agent: "planner"}, + Receivers: []a2a.Address{{Agent: "worker", Node: "peer.example"}}, + Content: "who can take this?", Ontology: "ops", Protocol: "fipa-contract-net", + ReplyWith: "rw-1", ReplyBy: &deadline, + } + m := MessageFromEnvelope(e) + + if m.ContextID != e.ConversationID.String() { + t.Errorf("contextId = %q, want the conversation id", m.ContextID) + } + if len(m.Parts) != 1 || m.Parts[0].Text != "who can take this?" { + t.Errorf("parts lost the content: %+v", m.Parts) + } + if len(m.Extensions) != 1 || m.Extensions[0] != FIPAExtensionURI { + t.Errorf("the message must declare the extension it used: %+v", m.Extensions) + } + + params, err := EnvelopeParamsFromMessage(m, a2a.Address{Agent: "planner", Node: "peer.example"}, a2a.Address{Agent: "worker"}) + if err != nil { + t.Fatalf("EnvelopeParamsFromMessage: %v", err) + } + if params.Performative != a2a.CFP { + t.Errorf("performative = %s, want cfp", params.Performative) + } + if params.Ontology != "ops" || params.Protocol != "fipa-contract-net" || params.ReplyWith != "rw-1" { + t.Errorf("ACL parameters did not survive: %+v", params) + } + if params.ReplyBy == nil || !params.ReplyBy.Equal(deadline) { + t.Errorf("replyBy did not survive: %v", params.ReplyBy) + } +} + +// A peer that has never heard of FIPA still has to work. This is the +// test that keeps the extension optional in practice and not just in +// the card. +func TestMessageWithoutFIPAMetadataGetsASensibleDefault(t *testing.T) { + plain := Message{MessageID: "m1", Role: RoleUser, Parts: []Part{{Text: "do the thing"}}} + + params, err := EnvelopeParamsFromMessage(plain, a2a.Address{Agent: "someone", Node: "peer.example"}, a2a.Address{Agent: "worker"}) + if err != nil { + t.Fatalf("EnvelopeParamsFromMessage: %v", err) + } + if params.Performative != a2a.Request { + t.Fatalf("performative = %s, want request for a plain inbound message", params.Performative) + } + if params.Content != "do the thing" { + t.Fatalf("content = %q", params.Content) + } +} + +func TestMessageWithSeveralTextPartsIsJoined(t *testing.T) { + m := Message{MessageID: "m1", Role: RoleUser, Parts: []Part{{Text: "first"}, {Text: "second"}}} + params, err := EnvelopeParamsFromMessage(m, a2a.Address{Agent: "s", Node: "n"}, a2a.Address{Agent: "w"}) + if err != nil { + t.Fatalf("EnvelopeParamsFromMessage: %v", err) + } + if params.Content != "first\n\nsecond" { + t.Fatalf("content = %q, want the parts joined", params.Content) + } +} + +func TestUnsupportedPartTypeIsRefused(t *testing.T) { + m := Message{MessageID: "m1", Role: RoleUser, Parts: []Part{{File: &FilePart{URL: "https://x/y.png"}}}} + _, err := EnvelopeParamsFromMessage(m, a2a.Address{Agent: "s", Node: "n"}, a2a.Address{Agent: "w"}) + var aerr *Error + if !errors.As(err, &aerr) || aerr.Code != -32005 { + t.Fatalf("err = %v, want ContentTypeNotSupportedError", err) + } +} + +func TestTaskFromRun(t *testing.T) { + r := &run.Run{ID: id.NewAgentRunID(), State: run.StateCompleted, Output: "done and dusted"} + task := TaskFromRun(r, "conv_1") + + if task.ID != r.ID.String() { + t.Errorf("task id = %q, want the run id verbatim", task.ID) + } + if task.ContextID != "conv_1" { + t.Errorf("contextId = %q", task.ContextID) + } + if task.Status.State != TaskStateCompleted { + t.Errorf("state = %s", task.Status.State) + } + if len(task.Artifacts) != 1 || task.Artifacts[0].Parts[0].Text != "done and dusted" { + t.Errorf("the run output must arrive as an artifact: %+v", task.Artifacts) + } +} + +func TestFailedRunCarriesItsErrorIntoTheStatus(t *testing.T) { + r := &run.Run{ID: id.NewAgentRunID(), State: run.StateFailed, Error: "the model refused"} + task := TaskFromRun(r, "") + if task.Status.State != TaskStateFailed { + t.Fatalf("state = %s, want failed", task.Status.State) + } + if task.Status.Message == nil || !strings.Contains(task.Status.Message.Parts[0].Text, "the model refused") { + t.Fatalf("a failed task must say why: %+v", task.Status) + } +} +``` + +- [ ] **Step 2: Run to verify it fails.** + +- [ ] **Step 3: Implement.** The FIPA metadata lives under one namespaced key so it cannot collide with a peer's own metadata: + +```go +// fipaMeta is the ACL parameter set as it travels in Message.metadata, +// under FIPAExtensionURI as its key. +// +// conversationId is deliberately absent: it is contextId, a native A2A +// field, and carrying it twice would create two places to disagree. +type fipaMeta struct { + Performative string `json:"performative,omitempty"` + ReplyWith string `json:"replyWith,omitempty"` + InReplyTo string `json:"inReplyTo,omitempty"` + Ontology string `json:"ontology,omitempty"` + Protocol string `json:"protocol,omitempty"` + Language string `json:"language,omitempty"` + Encoding string `json:"encoding,omitempty"` + ReplyBy string `json:"replyBy,omitempty"` +} +``` + +`EnvelopeParamsFromMessage` reads that key when present and falls back to `a2a.Request` when absent, and it **always** takes the sender from its argument rather than from anything in the message. The signature makes that hard to get wrong: the caller passes the sender the resolver assigned. + +- [ ] **Step 4: Verify.** `cd a2aremote && go test ./... -race` + +- [ ] **Step 5: Commit** + +```bash +git add a2aremote/ +git commit -m "feat(a2aremote): map envelopes to A2A messages and runs to tasks" +``` + +--- + +### Task 4: Agent cards + +**Files:** +- Create: `a2aremote/card.go` +- Test: `a2aremote/card_test.go` + +**Interfaces:** +- Produces: `AgentCard`, `AgentInterface`, `AgentCapabilities`, `AgentExtension`, `AgentSkill`, `AgentProvider`; `func BuildCard(a *agent.Config, skills []*skill.Skill, opts CardOptions) AgentCard`; `func (s *Service) CardHandler() http.Handler`; `func FetchCard(ctx context.Context, c *http.Client, baseURL string) (AgentCard, error)`. + +- [ ] **Step 1: Write the failing test** + +```go +func TestBuildCardDeclaresTheAgentAsATenant(t *testing.T) { + card := BuildCard(&agent.Config{Name: "db-expert", Description: "knows the database"}, nil, CardOptions{ + BaseURL: "https://cortex.example/a2a", Version: "1.0.0", + Provider: AgentProvider{Organization: "acme", URL: "https://acme.example"}, + }) + + if card.Name != "db-expert" { + t.Errorf("name = %q", card.Name) + } + if len(card.SupportedInterfaces) == 0 { + t.Fatal("a card with no interface tells a client nothing about how to reach it") + } + iface := card.SupportedInterfaces[0] + if iface.ProtocolBinding != "JSONRPC" { + t.Errorf("protocolBinding = %q, want JSONRPC", iface.ProtocolBinding) + } + // tenant is how one endpoint serves many agents, and it is the + // protocol's own mechanism rather than a cortex convention. + if iface.Tenant != "db-expert" { + t.Errorf("tenant = %q, want the agent name", iface.Tenant) + } +} + +func TestCardDeclaresTheFIPAExtensionAsOptional(t *testing.T) { + card := BuildCard(&agent.Config{Name: "a"}, nil, CardOptions{BaseURL: "https://x/a2a"}) + var found *AgentExtension + for i := range card.Capabilities.Extensions { + if card.Capabilities.Extensions[i].URI == FIPAExtensionURI { + found = &card.Capabilities.Extensions[i] + } + } + if found == nil { + t.Fatal("the card must declare the extension its messages use") + } + // Required would refuse conversations we can hold perfectly well: a + // peer that ignores the extension still gets valid A2A and reads the + // text. + if found.Required { + t.Fatal("the FIPA extension must be optional") + } +} + +func TestCardDeclaresWhatIsNotSupported(t *testing.T) { + card := BuildCard(&agent.Config{Name: "a"}, nil, CardOptions{BaseURL: "https://x/a2a"}) + if card.Capabilities.PushNotifications { + t.Error("push notifications are not implemented and must not be advertised") + } + if card.Capabilities.ExtendedAgentCard { + t.Error("the extended card is not implemented and must not be advertised") + } + if card.Capabilities.Streaming { + t.Error("streaming lands in plan 3, so it must not be advertised yet") + } +} + +func TestCardSkillsComeFromTheAgentsSkills(t *testing.T) { + card := BuildCard(&agent.Config{Name: "a"}, []*skill.Skill{ + {Name: "sql-review", Description: "reviews SQL migrations"}, + }, CardOptions{BaseURL: "https://x/a2a"}) + if len(card.Skills) != 1 || card.Skills[0].Name != "sql-review" { + t.Fatalf("skills = %+v", card.Skills) + } + // An agent with no skills still needs one entry: skills is REQUIRED + // in the schema, and an empty list makes the agent look useless. + bare := BuildCard(&agent.Config{Name: "a", Description: "does things"}, nil, CardOptions{BaseURL: "https://x/a2a"}) + if len(bare.Skills) != 1 { + t.Fatalf("a skill-less agent needs one synthesised skill, got %+v", bare.Skills) + } +} + +func TestCardHandlerServesTheWellKnownPath(t *testing.T) { + // Serve, fetch, and compare. The path is the one a 1.0 client looks + // at; 0.x used /.well-known/agent.json and nothing looks there now. + // (Full test in the file: httptest.Server + FetchCard round trip.) +} +``` + +- [ ] **Step 2: Run to verify it fails.** + +- [ ] **Step 3: Implement.** `CardHandler` serves `/{prefix}/agents/{name}/.well-known/agent-card.json` for every exposed agent, and the default agent's card at `/.well-known/agent-card.json`. It returns 404 for an agent that exists but was not exposed, because a card is public and exposure is opt-in. + +`FetchCard` sets `Accept: application/json`, honours `Cache-Control: max-age` for the caller's cache, and refuses a body over 1 MiB. + +- [ ] **Step 4: Verify.** + +- [ ] **Step 5: Commit** + +```bash +git add a2aremote/ +git commit -m "feat(a2aremote): build, serve and fetch agent cards" +``` + +--- + +### Task 5: The service + +Every semantic decision lives here, so that three bindings can share one implementation and one set of security rules. + +**Files:** +- Create: `a2aremote/seams.go`, `a2aremote/service.go` +- Test: `a2aremote/service_test.go`, `a2aremote/fakes_test.go` + +**Interfaces:** +- Produces: `Gateway`, `PeerResolver`, `Credentials`, `Peer`, `Options`, `NewService`, and the four methods `SendMessage`, `GetTask`, `ListTasks`, `CancelTask`, each taking `(ctx, Credentials, )`. + +```go +type Gateway interface { + SendMessage(ctx context.Context, p a2a.SendParams) (*a2a.SendResult, error) + GetRun(ctx context.Context, runID id.AgentRunID) (*run.Run, error) + ListRuns(ctx context.Context, f *run.ListFilter) ([]*run.Run, error) + CancelRun(ctx context.Context, runID id.AgentRunID) error + GetAgentByName(ctx context.Context, name string) (*agent.Config, error) +} +``` + +- [ ] **Step 1: Write the failing tests** + +```go +func TestSendMessageUsesTheResolversScopeAndNotTheMessages(t *testing.T) +func TestSendMessageRefusesAnUnauthenticatedCaller(t *testing.T) +func TestAResolverErrorWritesNothing(t *testing.T) +func TestSenderIsNamespacedByThePeersNode(t *testing.T) +func TestUnknownTenantIsTaskNotFoundNotAnInternalError(t *testing.T) +func TestInformativeReturnsAMessageAcknowledgement(t *testing.T) +func TestDirectiveReturnsATaskBackedByTheRun(t *testing.T) +func TestGetTaskOfAnotherPeersRunIsNotFound(t *testing.T) +func TestCancelTaskOfATerminalRunIsNotCancelable(t *testing.T) +``` + +The first and fourth are the security tests, and they are worth writing out in full: + +```go +func TestSendMessageUsesTheResolversScopeAndNotTheMessages(t *testing.T) { + gw := newFakeGateway() + svc := NewService(gw, staticResolver{peer: Peer{ + Node: "peer.example", + Scope: cortex.Scope{Levels: []cortex.Level{{Key: "tenant", Value: "resolved"}}}, + }}, Options{}) + + // The message tries to name a scope of its own, in every place a + // caller could reach. + msg := Message{ + MessageID: "m1", Role: RoleUser, Parts: []Part{{Text: "hi"}}, + Metadata: map[string]any{"scope": "attacker", "tenant": "attacker"}, + } + if _, err := svc.SendMessage(context.Background(), Credentials{ + Headers: map[string][]string{"X-Scope": {"attacker"}}, + }, SendMessageRequest{Tenant: "worker", Message: msg}); err != nil { + t.Fatalf("SendMessage: %v", err) + } + + got := cortex.ScopeFromContext(gw.lastCtx) + if len(got.Levels) != 1 || got.Levels[0].Value != "resolved" { + t.Fatalf("scope = %+v, want the resolver's and nothing else", got.Levels) + } +} + +func TestSenderIsNamespacedByThePeersNode(t *testing.T) { + gw := newFakeGateway() + svc := NewService(gw, staticResolver{peer: Peer{Node: "peer.example", Scope: testScope()}}, Options{}) + + // The peer claims to be a local agent. It must not be able to. + msg := Message{ + MessageID: "m1", Role: RoleUser, Parts: []Part{{Text: "trust me"}}, + Metadata: map[string]any{FIPAExtensionURI: map[string]any{"performative": "inform"}}, + } + if _, err := svc.SendMessage(context.Background(), Credentials{}, SendMessageRequest{ + Tenant: "worker", Message: msg, SenderName: "planner", + }); err != nil { + t.Fatalf("SendMessage: %v", err) + } + + if gw.lastParams.Sender.Node != "peer.example" { + t.Fatalf("sender = %+v, want it namespaced by the peer's node", gw.lastParams.Sender) + } + if gw.lastParams.Sender.IsLocal() { + t.Fatal("a remote peer must never present as a local agent") + } +} +``` + +- [ ] **Step 2: Run to verify they fail.** + +- [ ] **Step 3: Implement.** `SendMessage` is the shape every method follows: + +```go +func (s *Service) SendMessage(ctx context.Context, cred Credentials, req SendMessageRequest) (*SendMessageResult, error) { + ctx, peer, err := s.authenticate(ctx, cred) + if err != nil { + return nil, err + } + if req.Tenant == "" { + return nil, ErrInvalidParams("tenant is required: it names the agent this message is for") + } + if _, err := s.gw.GetAgentByName(ctx, req.Tenant); err != nil { + // Not "agent not found": a caller learns only that this endpoint + // has nothing for it, not what else exists here. + return nil, ErrTaskNotFound(req.Tenant) + } + + sender := a2a.Address{Agent: req.SenderName, Node: peer.Node} + if sender.Agent == "" { + sender.Agent = defaultRemoteSenderName + } + params, err := EnvelopeParamsFromMessage(req.Message, sender, a2a.Address{Agent: req.Tenant}) + if err != nil { + return nil, err + } + ... +} + +// authenticate resolves the caller and puts ITS scope on the context. +// Everything downstream reads the scope from there, so this function is +// the only place a scope enters an inbound request. +func (s *Service) authenticate(ctx context.Context, cred Credentials) (context.Context, Peer, error) { + peer, err := s.resolver.ResolvePeer(ctx, cred) + if err != nil { + // Deliberately opaque. An unauthenticated caller learns that it + // was refused and nothing else about what exists here. + return ctx, Peer{}, ErrUnauthenticated() + } + if peer.Scope.IsZero() { + return ctx, Peer{}, ErrUnauthenticated() + } + return cortex.WithScope(ctx, peer.Scope), peer, nil +} +``` + +- [ ] **Step 4: Verify.** `cd a2aremote && go test ./... -race` + +- [ ] **Step 5: Commit** + +```bash +git add a2aremote/ +git commit -m "feat(a2aremote): add the service every binding shares" +``` + +--- + +### Task 6: The JSON-RPC binding + +**Files:** +- Create: `a2aremote/jsonrpc.go` +- Test: `a2aremote/jsonrpc_test.go` + +**Interfaces:** +- Produces: `func (s *Service) JSONRPCHandler() http.Handler`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestJSONRPCSendMessage(t *testing.T) // golden request and response body +func TestJSONRPCUnknownMethodIs32601(t *testing.T) +func TestJSONRPCMalformedBodyIs32700(t *testing.T) +func TestJSONRPCServiceErrorKeepsItsCode(t *testing.T) // -32001 survives the binding +func TestJSONRPCNotificationGetsNoResponse(t *testing.T) // id absent = notification +func TestJSONRPCRejectsAnUnknownA2AVersion(t *testing.T) // -32009 +``` + +- [ ] **Step 2: Run to verify they fail.** + +- [ ] **Step 3: Implement.** A `POST`-only handler that decodes `{jsonrpc, id, method, params}`, dispatches on the PascalCase method name, and encodes `{jsonrpc:"2.0", id, result}` or `{jsonrpc:"2.0", id, error:{code,message,data}}`. The `A2A-Version` header is checked when present; absent means the current version. + +The binding does no semantics. If a reviewer sees a policy decision in this file, it belongs in the service. + +- [ ] **Step 4: Verify.** + +- [ ] **Step 5: Commit** + +```bash +git add a2aremote/ +git commit -m "feat(a2aremote): add the JSON-RPC binding" +``` + +--- + +### Task 7: The outbound client + +**Files:** +- Create: `a2aremote/client.go`, `a2aremote/peers.go` +- Test: `a2aremote/client_test.go` + +**Interfaces:** +- Produces: `PeerConfig{Node, BaseURL, Header http.Header}`, `NewClient(peers []PeerConfig, sink ReplySink, opts ClientOptions) *Client`, and `Client` satisfying `a2a.Transport`. `ReplySink` is `func(ctx context.Context, p a2a.SendParams) error`, which the engine wires to `Bus.Send`. + +- [ ] **Step 1: Write the failing tests** + +```go +func TestClientHandlesOnlyRegisteredPeers(t *testing.T) // an invented hostname is not handled +func TestDeliverSendsAMessageAndFeedsTheReplyBack(t *testing.T) +func TestDeliverPollsANonTerminalTask(t *testing.T) +func TestDeliverSurfacesAPeerError(t *testing.T) +func TestClientCachesTheAgentCard(t *testing.T) // one fetch across two deliveries +func TestClientPrefersJSONRPCAmongInterfaces(t *testing.T) +``` + +`TestClientHandlesOnlyRegisteredPeers` is the security one: an agent's own output must not be able to make cortex call an arbitrary host. + +- [ ] **Step 2: Run to verify they fail.** + +- [ ] **Step 3: Implement.** `Deliver` fetches the card (cached per node), picks the interface, posts `SendMessage`, and turns the answer into a `ReplySink` call carrying `InReplyTo` set to the outbound envelope's `ReplyWith`. A `Task` that is not terminal is polled with `GetTask` on a backoff, and polling stops at the envelope's `ReplyBy`, because the ask's own deadline already turns silence into a readable failure. + +- [ ] **Step 4: Verify.** + +- [ ] **Step 5: Commit** + +```bash +git add a2aremote/ +git commit -m "feat(a2aremote): add the outbound client" +``` + +--- + +### Task 8: Engine wiring + +**Files:** +- Create: `a2aremote/gateway.go` (the engine adapter lives on this side of the seam, so the core module keeps knowing nothing about A2A) +- Test: `a2aremote/gateway_test.go` + +**Interfaces:** +- Produces: `func Gateway(eng *engine.Engine) Gateway` and `func Attach(eng *engine.Engine, opts AttachOptions) (*Service, error)`, which builds the service, builds the client from the configured peers, and registers it with `eng.A2A().AddTransport`. + +- [ ] **Step 1: Write the failing test** + +```go +func TestAttachRegistersTheTransport(t *testing.T) +func TestAttachRefusesAnEngineWithoutMessaging(t *testing.T) // WithA2A was never set +``` + +- [ ] **Step 2 to 5:** as before. + +```bash +git add a2aremote/ +git commit -m "feat(a2aremote): attach the remote transport to an engine" +``` + +--- + +### Task 9: The loopback test + +Two engines, over the wire. This is the test the whole plan exists to make pass. + +**Files:** +- Test: `a2aremote/loopback_test.go` + +- [ ] **Step 1: Write the failing test** + +```go +// TestTwoEnginesTalkOverTheWire stands up engine B behind an httptest +// server and has an agent on engine A ask it a question. A's run +// suspends, the answer travels back over JSON-RPC, and A resumes. +// +// Every piece of this is unit tested somewhere. What is not, and cannot +// be, is that the pieces line up: the ids, the extension metadata, the +// sender namespacing, the reply correlation, and the resume. +func TestTwoEnginesTalkOverTheWire(t *testing.T) { + // engine B: serves "specialist" over JSON-RPC + // engine A: has "planner", whose model calls agent_ask on + // specialist@peer-b, with peer-b registered at srv.URL + // assert: A's run completes carrying B's answer, and the ask's tool + // call result contains it +} +``` + +- [ ] **Step 2 to 5:** as before. + +```bash +git add a2aremote/ +git commit -m "test(a2aremote): prove two engines hold a conversation over the wire" +``` + +--- + +## Self-review + +**Spec coverage.** §2.1 to Task 2, §2.2 to Task 5, §2.3 to Task 1, §3.1 and §3.2 to Tasks 3 and 4, §3.3 and §3.4 to Task 3, §3.5 to Task 2, §4.1 to Tasks 5 and 6, §4.2 to Task 7, §5 to Tasks 5 and 7 (the two security tests are named and written out), §6 to every task plus Task 9. + +Spec §7's plans 2 and 3, the REST and gRPC bindings and streaming, are deliberately absent: they are their own plans and they change nothing here. + +**One thing this plan adds to the spec.** The spec says an unknown tenant is an error; the plan makes it `TaskNotFound` rather than a distinct "no such agent", so a caller cannot enumerate which agents exist by probing names. That is a security improvement, and it is worth carrying back into the spec if anyone reads the two together. From 4c304285f30d6be63033069fcd73b3e386baea88 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:26:38 -0500 Subject: [PATCH 29/50] fix(a2a): carry remote receivers through their transport The transport seam existed from the first spec and the delivery path never asked it, so an envelope addressed to another system would have been answered by whichever local agent happened to share the name. Delivery now asks whether the receiver is local before it asks what the performative wants. --- a2a/bus.go | 56 ++++++++---- a2a/deliver.go | 32 +++++++ a2a/deliver_remote_test.go | 179 +++++++++++++++++++++++++++++++++++++ 3 files changed, 251 insertions(+), 16 deletions(-) create mode 100644 a2a/deliver_remote_test.go diff --git a/a2a/bus.go b/a2a/bus.go index d9449dd..b32d318 100644 --- a/a2a/bus.go +++ b/a2a/bus.go @@ -6,6 +6,7 @@ import ( "errors" "fmt" "strings" + "sync" "time" "github.com/xraph/cortex" @@ -48,15 +49,45 @@ type BusConfig struct { // Bus routes envelopes between agents. type Bus struct { - store Store - runner cortex.AgentRunner - resumer Resumer - resolver Resolver - hooks HookEmitter - clock Clock + store Store + runner cortex.AgentRunner + resumer Resumer + resolver Resolver + hooks HookEmitter + clock Clock + opts Options + dispatch *dispatcher + + // mu guards transports, which AddTransport appends to after the bus + // exists. Registration happens at startup and reads happen once per + // delivery, so a plain mutex is the right weight here. + mu sync.RWMutex transports []Transport - opts Options - dispatch *dispatcher +} + +// AddTransport registers a transport after the bus has been built. +// +// It exists because construction cannot be circular. A remote transport +// needs the bus, since a peer's reply is fed back through Send so it can +// resolve a waiting ask, and the bus needs the transport to carry the +// question out. The host builds the bus, builds the transport with it, +// and registers it here. +func (b *Bus) AddTransport(t Transport) { + b.mu.Lock() + defer b.mu.Unlock() + b.transports = append(b.transports, t) +} + +// transportFor returns the transport that claims addr, or nil. +func (b *Bus) transportFor(addr Address) Transport { + b.mu.RLock() + defer b.mu.RUnlock() + for _, t := range b.transports { + if t.Handles(addr) { + return t + } + } + return nil } // NewBus builds a Bus from cfg. @@ -276,14 +307,7 @@ func (b *Bus) resolve(ctx context.Context, addr Address) error { return nil } -func (b *Bus) routable(addr Address) bool { - for _, t := range b.transports { - if t.Handles(addr) { - return true - } - } - return false -} +func (b *Bus) routable(addr Address) bool { return b.transportFor(addr) != nil } func addressList(addrs []Address) string { out := make([]string, len(addrs)) diff --git a/a2a/deliver.go b/a2a/deliver.go index 4f9ec16..80495eb 100644 --- a/a2a/deliver.go +++ b/a2a/deliver.go @@ -26,6 +26,14 @@ func (b *Bus) deliverOne(ctx context.Context, deliveryID id.DeliveryID) error { return b.failDelivery(ctx, d, err) } + // A remote receiver is carried by a transport, whatever the + // performative says. Routing by class first would run a LOCAL agent + // named like the remote one, which is how a question addressed to + // somebody else's system ends up answered by yours. + if !d.Receiver.IsLocal() { + return b.deliverRemote(ctx, d, e) + } + class, ok := e.Performative.Class() if !ok { return b.failDelivery(ctx, d, ErrInvalidPerformative) @@ -80,6 +88,30 @@ func (b *Bus) runDirective(ctx context.Context, d *Delivery, e *Envelope) error return nil } +// deliverRemote hands one delivery to whichever transport claims the +// address. A transport failure is a failed delivery and never a +// fallback: answering another system's question with a local agent of +// the same name would be worse than not answering it. +// +// Nothing waits here for a reply. The transport's job is to get the +// envelope there; the answer arrives later as an ordinary inbound +// message carrying InReplyTo, which is the path a local reply takes too. +func (b *Bus) deliverRemote(ctx context.Context, d *Delivery, e *Envelope) error { + t := b.transportFor(d.Receiver) + if t == nil { + return b.failDelivery(ctx, d, fmt.Errorf("%w: %s", ErrUnroutable, d.Receiver)) + } + if err := t.Deliver(ctx, e, d.Receiver); err != nil { + // An unreachable peer is something the asking agent can act on, + // so it hears about it now rather than at its deadline. + if askErr := b.resolveAskWithFailure(ctx, e.ReplyWith, err.Error()); askErr != nil { + return askErr + } + return b.failDelivery(ctx, d, err) + } + return b.finishDelivery(ctx, d, e, id.AgentRunID{}) +} + func (b *Bus) finishDelivery(ctx context.Context, d *Delivery, e *Envelope, runID id.AgentRunID) error { now := b.clock.Now() d.State = DeliveryDelivered diff --git a/a2a/deliver_remote_test.go b/a2a/deliver_remote_test.go new file mode 100644 index 0000000..b43ddb0 --- /dev/null +++ b/a2a/deliver_remote_test.go @@ -0,0 +1,179 @@ +package a2a + +import ( + "context" + "errors" + "sync" + "testing" + + "github.com/xraph/cortex/id" +) + +// fakeTransport records what it was asked to carry. +type fakeTransport struct { + mu sync.Mutex + node string + carried []*Envelope + err error +} + +func (f *fakeTransport) Handles(addr Address) bool { return addr.Node == f.node } + +func (f *fakeTransport) Deliver(_ context.Context, e *Envelope, _ Address) error { + f.mu.Lock() + defer f.mu.Unlock() + if f.err != nil { + return f.err + } + cp := *e + f.carried = append(f.carried, &cp) + return nil +} + +func (f *fakeTransport) count() int { + f.mu.Lock() + defer f.mu.Unlock() + return len(f.carried) +} + +func (f *fakeTransport) last() *Envelope { + f.mu.Lock() + defer f.mu.Unlock() + if len(f.carried) == 0 { + return nil + } + return f.carried[len(f.carried)-1] +} + +func TestRemoteReceiverGoesToTheTransport(t *testing.T) { + b, _, runner, _, _, _ := newTestBus(t) + ctx := testCtx() + tr := &fakeTransport{node: "peer.example"} + b.AddTransport(tr) + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "worker", Node: "peer.example"}}, + Performative: Request, Content: "over there please", + }); err != nil { + t.Fatalf("Send: %v", err) + } + if _, err := b.Drain(ctx); err != nil { + t.Fatalf("Drain: %v", err) + } + + // The local runner must not have been used. A remote address answered + // by a local agent of the same name is the bug this test exists for. + if runner.callCount() != 0 { + t.Fatalf("the local runner ran %d times for a remote address", runner.callCount()) + } + if tr.count() != 1 || tr.last().Content != "over there please" { + t.Fatalf("transport carried %d messages: %+v", tr.count(), tr.last()) + } +} + +func TestRemoteDeliveryFailureDoesNotRunLocally(t *testing.T) { + b, st, runner, _, hooks, _ := newTestBus(t) + ctx := testCtx() + b.AddTransport(&fakeTransport{node: "peer.example", err: errors.New("peer unreachable")}) + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "worker", Node: "peer.example"}}, + Performative: Request, Content: "hello?", + }); err != nil { + t.Fatalf("Send: %v", err) + } + if _, err := b.Drain(ctx); err != nil { + t.Fatalf("Drain: %v", err) + } + + if runner.callCount() != 0 { + t.Fatal("a failed remote delivery must never fall back to a local agent") + } + if hooks.refused() != 1 { + t.Fatalf("MessageRefused fired %d times, want 1", hooks.refused()) + } + rows, err := st.ListQueuedDeliveries(ctx, 10) + if err != nil { + t.Fatalf("ListQueuedDeliveries: %v", err) + } + if len(rows) != 0 { + t.Fatalf("%d rows left queued after a failed delivery", len(rows)) + } +} + +func TestRemoteAddressWithNoTransportIsRefusedAtSend(t *testing.T) { + b, _, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + // No transport registered for that node, so the send never happens: + // an agent cannot invent a hostname and have cortex reach for it. + _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "worker", Node: "nowhere.example"}}, + Performative: Request, Content: "hello?", + }) + if !errorsIs(err, ErrUnroutable) { + t.Fatalf("err = %v, want ErrUnroutable", err) + } +} + +// A remote ask is carried by the transport, and answered later by an +// ordinary inbound message. That is the same path a local reply takes, +// which is what makes a resumed agent unable to tell the difference. +func TestRemoteAskIsResolvedByAReplyThroughTheBus(t *testing.T) { + b, _, _, resumer, _, _ := newTestBus(t) + ctx := testCtx() + b.AddTransport(&fakeTransport{node: "peer.example"}) + + ask, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "worker", Node: "peer.example"}}, + Content: "status?", + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + if _, drainErr := b.Drain(ctx); drainErr != nil { + t.Fatalf("Drain: %v", drainErr) + } + if resumer.count() != 0 { + t.Fatal("carrying the question must not resume the asker") + } + + // The peer answers, and the client feeds it back in here. + if _, sendErr := b.Send(ctx, SendParams{ + Sender: Address{Agent: "worker", Node: "peer.example"}, Receivers: []Address{{Agent: "planner"}}, + Performative: Inform, Content: "all clear over here", + ConversationID: ask.ConversationID, InReplyTo: ask.ReplyWith, + }); sendErr != nil { + t.Fatalf("reply Send: %v", sendErr) + } + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want 1", resumer.count()) + } +} + +// A failed remote delivery has to reach a waiting asker, or the run sits +// until its deadline learning nothing. +func TestRemoteDeliveryFailureResolvesTheWaitingAsk(t *testing.T) { + b, _, _, resumer, _, _ := newTestBus(t) + ctx := testCtx() + b.AddTransport(&fakeTransport{node: "peer.example", err: errors.New("connection refused")}) + + if _, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "worker", Node: "peer.example"}}, + Content: "status?", + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }); err != nil { + t.Fatalf("Ask: %v", err) + } + if _, err := b.Drain(ctx); err != nil { + t.Fatalf("Drain: %v", err) + } + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want 1: an unreachable peer is something the asker can act on", resumer.count()) + } +} From 8eb3e17f6b63176302b2d59978f056384e7a25e5 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:28:38 -0500 Subject: [PATCH 30/50] feat(a2aremote): add the A2A wire types and error codes --- a2aremote/doc.go | 21 +++++ a2aremote/errors.go | 104 ++++++++++++++++++++++++ a2aremote/errors_test.go | 78 ++++++++++++++++++ a2aremote/go.mod | 5 ++ a2aremote/types.go | 168 +++++++++++++++++++++++++++++++++++++++ a2aremote/types_test.go | 99 +++++++++++++++++++++++ 6 files changed, 475 insertions(+) create mode 100644 a2aremote/doc.go create mode 100644 a2aremote/errors.go create mode 100644 a2aremote/errors_test.go create mode 100644 a2aremote/go.mod create mode 100644 a2aremote/types.go create mode 100644 a2aremote/types_test.go diff --git a/a2aremote/doc.go b/a2aremote/doc.go new file mode 100644 index 0000000..8c377ef --- /dev/null +++ b/a2aremote/doc.go @@ -0,0 +1,21 @@ +// Package a2aremote carries cortex messages over the Agent2Agent +// protocol, in both directions: remote A2A clients can address cortex +// agents, and cortex agents can address agents that are not cortex +// agents at all. +// +// It is a separate module from the root deliberately. gRPC pulls in +// grpc-go and protobuf, and a host that only ever wanted in-process +// messaging should not inherit that dependency graph. +// +// The wire shapes here follow A2A 1.0.0, whose canonical data model is +// specification/a2a.proto in github.com/a2aproject/A2A. Field names on +// the wire are the lowerCamelCase of the proto's field names, method +// names are PascalCase, and agent cards live at +// /.well-known/agent-card.json. The 0.x spellings (message/send, +// /.well-known/agent.json) are not served: a 1.0 client never asks for +// them. +// +// Every semantic decision lives in Service. The bindings translate +// formats and nothing else, which is what lets three of them share one +// implementation and, more importantly, one set of security rules. +package a2aremote diff --git a/a2aremote/errors.go b/a2aremote/errors.go new file mode 100644 index 0000000..db1f5d0 --- /dev/null +++ b/a2aremote/errors.go @@ -0,0 +1,104 @@ +package a2aremote + +import ( + "fmt" + "strings" +) + +// The A2A error codes, from the protocol's own error mapping table. +// A2A-specific errors occupy -32001 to -32099; everything at -32600 and +// below is standard JSON-RPC. +const ( + CodeTaskNotFound = -32001 + CodeTaskNotCancelable = -32002 + CodePushNotificationNotSupported = -32003 + CodeUnsupportedOperation = -32004 + CodeContentTypeNotSupported = -32005 + CodeInvalidAgentResponse = -32006 + CodeExtendedCardNotConfigured = -32007 + CodeExtensionSupportRequired = -32008 + CodeVersionNotSupported = -32009 + + CodeParse = -32700 + CodeInvalidRequest = -32600 + CodeMethodNotFound = -32601 + CodeInvalidParams = -32602 + CodeInternal = -32603 +) + +// Error is one protocol error. It carries the numeric code every binding +// maps to its own representation, so a service method can raise the +// right thing without knowing which binding is carrying it. +type Error struct { + Code int `json:"code"` + Message string `json:"message"` + Data []map[string]any `json:"data,omitempty"` +} + +func (e *Error) Error() string { return fmt.Sprintf("a2a error %d: %s", e.Code, e.Message) } + +func newError(code int, msg string) *Error { return &Error{Code: code, Message: msg} } + +// ErrTaskNotFound is also what an unknown tenant returns, deliberately. +// A distinct "no such agent" would let a caller enumerate which agents +// exist here by probing names. +func ErrTaskNotFound(id string) *Error { + return newError(CodeTaskNotFound, fmt.Sprintf("no task %q", id)) +} + +func ErrTaskNotCancelable(id string) *Error { + return newError(CodeTaskNotCancelable, fmt.Sprintf("task %q has already finished", id)) +} + +func ErrPushNotificationNotSupported() *Error { + return newError(CodePushNotificationNotSupported, "this agent does not support push notifications") +} + +func ErrUnsupportedOperation(method string) *Error { + return newError(CodeUnsupportedOperation, fmt.Sprintf("%s is not supported by this agent", method)) +} + +func ErrContentTypeNotSupported(kind string) *Error { + return newError(CodeContentTypeNotSupported, fmt.Sprintf("%s parts are not supported: send text", kind)) +} + +func ErrInvalidAgentResponse(why string) *Error { + return newError(CodeInvalidAgentResponse, "the agent answered with something unusable: "+why) +} + +func ErrExtendedCardNotConfigured() *Error { + return newError(CodeExtendedCardNotConfigured, "no extended agent card is configured") +} + +func ErrExtensionSupportRequired(uri string) *Error { + return newError(CodeExtensionSupportRequired, fmt.Sprintf("extension %q is required and not supported here", uri)) +} + +func ErrVersionNotSupported(v string) *Error { + return newError(CodeVersionNotSupported, fmt.Sprintf("protocol version %q is not supported", v)) +} + +func ErrParse() *Error { return newError(CodeParse, "the request body is not valid JSON") } + +func ErrInvalidRequest(why string) *Error { return newError(CodeInvalidRequest, why) } + +func ErrMethodNotFound(method string) *Error { + return newError(CodeMethodNotFound, fmt.Sprintf("unknown method %q", method)) +} + +func ErrInvalidParams(why string) *Error { return newError(CodeInvalidParams, why) } + +func ErrInternal(why string) *Error { return newError(CodeInternal, why) } + +// ErrUnauthenticated says only that the caller was refused. +// +// The dullness is the point: a caller that has not proved who it is +// learns nothing about what exists here, which agent names are real, or +// what it would have needed to present. +func ErrUnauthenticated() *Error { + return newError(CodeInvalidRequest, "request refused") +} + +func containsFold(s, sub string) bool { + return strings.Contains(strings.ToLower(s), strings.ToLower(sub)) +} diff --git a/a2aremote/errors_test.go b/a2aremote/errors_test.go new file mode 100644 index 0000000..7c6015a --- /dev/null +++ b/a2aremote/errors_test.go @@ -0,0 +1,78 @@ +package a2aremote + +import ( + "errors" + "testing" +) + +// The numeric codes are the protocol's, from its own error mapping +// table. A wrong number is a peer that cannot tell "no such task" from +// "you are not allowed", so they are pinned rather than remembered. +func TestErrorCodes(t *testing.T) { + cases := []struct { + err *Error + code int + name string + }{ + {ErrTaskNotFound("arun_1"), -32001, "TaskNotFoundError"}, + {ErrTaskNotCancelable("arun_1"), -32002, "TaskNotCancelableError"}, + {ErrPushNotificationNotSupported(), -32003, "PushNotificationNotSupportedError"}, + {ErrUnsupportedOperation("GetExtendedAgentCard"), -32004, "UnsupportedOperationError"}, + {ErrContentTypeNotSupported("file"), -32005, "ContentTypeNotSupportedError"}, + {ErrInvalidAgentResponse("no parts"), -32006, "InvalidAgentResponseError"}, + {ErrExtendedCardNotConfigured(), -32007, "ExtendedAgentCardNotConfiguredError"}, + {ErrExtensionSupportRequired("urn:x"), -32008, "ExtensionSupportRequiredError"}, + {ErrVersionNotSupported("0.1"), -32009, "VersionNotSupportedError"}, + } + for _, tc := range cases { + if tc.err.Code != tc.code { + t.Errorf("%s: code = %d, want %d", tc.name, tc.err.Code, tc.code) + } + if tc.err.Message == "" { + t.Errorf("%s: a peer debugging an integration reads this string, so it must say something", tc.name) + } + } +} + +func TestStandardJSONRPCCodes(t *testing.T) { + if ErrParse().Code != -32700 { + t.Errorf("parse error = %d, want -32700", ErrParse().Code) + } + if ErrInvalidRequest("x").Code != -32600 { + t.Errorf("invalid request = %d, want -32600", ErrInvalidRequest("x").Code) + } + if ErrMethodNotFound("Nope").Code != -32601 { + t.Errorf("method not found = %d, want -32601", ErrMethodNotFound("Nope").Code) + } + if ErrInvalidParams("x").Code != -32602 { + t.Errorf("invalid params = %d, want -32602", ErrInvalidParams("x").Code) + } + if ErrInternal("x").Code != -32603 { + t.Errorf("internal = %d, want -32603", ErrInternal("x").Code) + } +} + +// An unauthenticated caller must learn that it was refused and nothing +// else about what exists here, so the message is deliberately dull. +func TestUnauthenticatedSaysNothingUseful(t *testing.T) { + err := ErrUnauthenticated() + if err.Code != -32600 { + t.Errorf("code = %d, want -32600", err.Code) + } + for _, leak := range []string{"scope", "tenant", "agent", "peer"} { + if containsFold(err.Message, leak) { + t.Errorf("the refusal message leaks %q: %q", leak, err.Message) + } + } +} + +func TestErrorIsAnError(t *testing.T) { + var err error = ErrTaskNotFound("arun_1") + var target *Error + if !errors.As(err, &target) { + t.Fatal("*Error must satisfy error and be recoverable with errors.As") + } + if target.Error() == "" { + t.Fatal("Error() must render something") + } +} diff --git a/a2aremote/go.mod b/a2aremote/go.mod new file mode 100644 index 0000000..a8c3454 --- /dev/null +++ b/a2aremote/go.mod @@ -0,0 +1,5 @@ +module github.com/xraph/cortex/a2aremote + +go 1.26.0 + +replace github.com/xraph/cortex => ../ diff --git a/a2aremote/types.go b/a2aremote/types.go new file mode 100644 index 0000000..1dc5857 --- /dev/null +++ b/a2aremote/types.go @@ -0,0 +1,168 @@ +package a2aremote + +import "time" + +// FIPAExtensionURI identifies the extension that carries FIPA-ACL +// semantics over A2A. It is declared in every card this package serves, +// as an optional extension, and named in the extensions list of every +// message that uses it. +const FIPAExtensionURI = "https://cortex.xraph.dev/a2a/extensions/fipa-acl/v1" + +// Role identifies who sent a message. The values are the protocol's own +// enum names rather than friendlier spellings. +type Role string + +// The message roles. +const ( + RoleUser Role = "ROLE_USER" + RoleAgent Role = "ROLE_AGENT" +) + +// Part is one piece of a message's content. Exactly one of the three +// shapes is set. +// +// Only text is understood today. A file or data part is refused with +// ContentTypeNotSupportedError rather than dropped, because a peer whose +// attachment vanished silently has no way to find out why the answer +// made no sense. +type Part struct { + Text string `json:"text,omitempty"` + File *FilePart `json:"file,omitempty"` + Data *DataPart `json:"data,omitempty"` +} + +// FilePart carries a file, either inline or by reference. +type FilePart struct { + Raw []byte `json:"raw,omitempty"` + URL string `json:"url,omitempty"` + MIMEType string `json:"mimeType,omitempty"` + Name string `json:"name,omitempty"` +} + +// DataPart carries structured JSON. +type DataPart struct { + Data map[string]any `json:"data,omitempty"` +} + +// Message is one A2A message. +// +// ContextID is where a cortex conversation id travels, and TaskID is +// where a run id does. Both are native protocol fields, so nothing about +// them is a cortex convention a peer has to learn. +type Message struct { + MessageID string `json:"messageId"` + ContextID string `json:"contextId,omitempty"` + TaskID string `json:"taskId,omitempty"` + Role Role `json:"role"` + Parts []Part `json:"parts"` + Metadata map[string]any `json:"metadata,omitempty"` + Extensions []string `json:"extensions,omitempty"` + ReferenceTaskIDs []string `json:"referenceTaskIds,omitempty"` +} + +// TaskState is where a task is in its lifecycle. +type TaskState string + +// The eight task states. +const ( + TaskStateSubmitted TaskState = "TASK_STATE_SUBMITTED" + TaskStateWorking TaskState = "TASK_STATE_WORKING" + TaskStateCompleted TaskState = "TASK_STATE_COMPLETED" + TaskStateFailed TaskState = "TASK_STATE_FAILED" + TaskStateCanceled TaskState = "TASK_STATE_CANCELED" + TaskStateRejected TaskState = "TASK_STATE_REJECTED" + TaskStateInputRequired TaskState = "TASK_STATE_INPUT_REQUIRED" + TaskStateAuthRequired TaskState = "TASK_STATE_AUTH_REQUIRED" +) + +// Terminal reports whether the task has finished for good. +// +// The two interrupted states are deliberately not terminal. A client +// that stopped polling on input-required would abandon a task that is +// about to carry on the moment somebody answers it. +func (s TaskState) Terminal() bool { + switch s { + case TaskStateCompleted, TaskStateFailed, TaskStateCanceled, TaskStateRejected: + return true + default: + return false + } +} + +// TaskStatus is a task's current state, with an optional message saying +// something about it: why it failed, or what it is waiting for. +type TaskStatus struct { + State TaskState `json:"state"` + Message *Message `json:"message,omitempty"` + Timestamp string `json:"timestamp,omitempty"` +} + +// Artifact is a task output. +type Artifact struct { + ArtifactID string `json:"artifactId"` + Name string `json:"name,omitempty"` + Description string `json:"description,omitempty"` + Parts []Part `json:"parts"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// Task is a stateful unit of work, as a peer sees it. +// +// Cortex never stores one. It is a projection of a run, rebuilt on every +// read, so the task and the run cannot drift apart and claim different +// things about the same piece of work. +type Task struct { + ID string `json:"id"` + ContextID string `json:"contextId,omitempty"` + Status TaskStatus `json:"status"` + Artifacts []Artifact `json:"artifacts,omitempty"` + History []Message `json:"history,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// SendMessageRequest is what a peer sends to reach an agent. +// +// Tenant is the protocol's routing identifier for serving many agents +// behind one endpoint, and in cortex it is the agent's name. SenderName +// is a cortex addition carried in request metadata: a peer may say which +// of ITS agents is speaking, and the service namespaces that name under +// the node the resolver assigned, so it can never read as a local agent. +type SendMessageRequest struct { + Tenant string `json:"tenant,omitempty"` + Message Message `json:"message"` + SenderName string `json:"senderName,omitempty"` + Metadata map[string]any `json:"metadata,omitempty"` +} + +// SendMessageResult is either an acknowledgement or the task that the +// message started. Exactly one is set. +type SendMessageResult struct { + Message *Message `json:"message,omitempty"` + Task *Task `json:"task,omitempty"` +} + +// GetTaskRequest addresses one task. +type GetTaskRequest struct { + Tenant string `json:"tenant,omitempty"` + ID string `json:"id"` + HistoryLength int `json:"historyLength,omitempty"` +} + +// ListTasksRequest pages through an agent's tasks. +type ListTasksRequest struct { + Tenant string `json:"tenant,omitempty"` + PageSize int `json:"pageSize,omitempty"` +} + +// ListTasksResult is a page of tasks. +type ListTasksResult struct { + Tasks []Task `json:"tasks"` +} + +// CancelTaskRequest asks an agent to stop. +type CancelTaskRequest struct { + Tenant string `json:"tenant,omitempty"` + ID string `json:"id"` +} + +func timestamp(t time.Time) string { return t.UTC().Format(time.RFC3339) } diff --git a/a2aremote/types_test.go b/a2aremote/types_test.go new file mode 100644 index 0000000..689410f --- /dev/null +++ b/a2aremote/types_test.go @@ -0,0 +1,99 @@ +package a2aremote + +import ( + "encoding/json" + "testing" +) + +// The JSON names are the protocol's, not ours. A field renamed by a +// careless refactor is a peer that silently stops understanding us, so +// the wire shape is pinned here rather than trusted. +func TestMessageJSONNames(t *testing.T) { + m := Message{ + MessageID: "msg_1", ContextID: "conv_1", TaskID: "arun_1", + Role: RoleAgent, Parts: []Part{{Text: "hello"}}, + Extensions: []string{FIPAExtensionURI}, ReferenceTaskIDs: []string{"arun_2"}, + Metadata: map[string]any{"k": "v"}, + } + b, err := json.Marshal(m) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, key := range []string{"messageId", "contextId", "taskId", "role", "parts", "extensions", "referenceTaskIds", "metadata"} { + if _, ok := raw[key]; !ok { + t.Errorf("missing wire field %q in %s", key, b) + } + } +} + +func TestTaskJSONNames(t *testing.T) { + task := Task{ + ID: "arun_1", ContextID: "conv_1", + Status: TaskStatus{State: TaskStateWorking}, + Artifacts: []Artifact{{ArtifactID: "a1", Parts: []Part{{Text: "out"}}}}, + } + b, err := json.Marshal(task) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, key := range []string{"id", "contextId", "status", "artifacts"} { + if _, ok := raw[key]; !ok { + t.Errorf("missing wire field %q in %s", key, b) + } + } + artifacts, _ := raw["artifacts"].([]any) + first, _ := artifacts[0].(map[string]any) + if _, ok := first["artifactId"]; !ok { + t.Errorf("artifact is missing artifactId: %s", b) + } +} + +func TestTaskStateStringsAreTheProtocolsOwn(t *testing.T) { + want := map[TaskState]string{ + TaskStateSubmitted: "TASK_STATE_SUBMITTED", + TaskStateWorking: "TASK_STATE_WORKING", + TaskStateCompleted: "TASK_STATE_COMPLETED", + TaskStateFailed: "TASK_STATE_FAILED", + TaskStateCanceled: "TASK_STATE_CANCELED", + TaskStateRejected: "TASK_STATE_REJECTED", + TaskStateInputRequired: "TASK_STATE_INPUT_REQUIRED", + TaskStateAuthRequired: "TASK_STATE_AUTH_REQUIRED", + } + if len(want) != 8 { + t.Fatalf("the protocol defines 8 task states, table has %d", len(want)) + } + for state, s := range want { + if string(state) != s { + t.Errorf("state = %q, want %q", state, s) + } + } +} + +func TestTerminalStates(t *testing.T) { + for _, s := range []TaskState{TaskStateCompleted, TaskStateFailed, TaskStateCanceled, TaskStateRejected} { + if !s.Terminal() { + t.Errorf("%s should be terminal", s) + } + } + // An interrupted task is not finished: a client that stopped polling + // on input-required would abandon a task that is about to continue. + for _, s := range []TaskState{TaskStateSubmitted, TaskStateWorking, TaskStateInputRequired, TaskStateAuthRequired} { + if s.Terminal() { + t.Errorf("%s must not be terminal", s) + } + } +} + +func TestRoleStrings(t *testing.T) { + if RoleUser != "ROLE_USER" || RoleAgent != "ROLE_AGENT" { + t.Fatalf("roles = %q/%q, want the protocol's enum names", RoleUser, RoleAgent) + } +} From bd945f72e5051f0a1538ed74f8f196b27ef0a6af Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:29:23 -0500 Subject: [PATCH 31/50] feat(a2aremote): map envelopes to A2A messages and runs to tasks The sender is a parameter of EnvelopeParamsFromMessage rather than anything read out of the message, which is the signature enforcing the rule that no field a peer controls decides who a message is from. --- a2aremote/go.mod | 7 ++ a2aremote/go.sum | 14 +++ a2aremote/mapping.go | 209 ++++++++++++++++++++++++++++++++++++++ a2aremote/mapping_test.go | 192 ++++++++++++++++++++++++++++++++++ 4 files changed, 422 insertions(+) create mode 100644 a2aremote/go.sum create mode 100644 a2aremote/mapping.go create mode 100644 a2aremote/mapping_test.go diff --git a/a2aremote/go.mod b/a2aremote/go.mod index a8c3454..3331c76 100644 --- a/a2aremote/go.mod +++ b/a2aremote/go.mod @@ -3,3 +3,10 @@ module github.com/xraph/cortex/a2aremote go 1.26.0 replace github.com/xraph/cortex => ../ + +require github.com/xraph/cortex v0.0.0-00010101000000-000000000000 + +require ( + github.com/gofrs/uuid/v5 v5.3.2 // indirect + go.jetify.com/typeid/v2 v2.0.0-alpha.3 // indirect +) diff --git a/a2aremote/go.sum b/a2aremote/go.sum new file mode 100644 index 0000000..fdbbfcb --- /dev/null +++ b/a2aremote/go.sum @@ -0,0 +1,14 @@ +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= +github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.jetify.com/typeid/v2 v2.0.0-alpha.3 h1:T6RPx6bNl10lp0JN2Xz/XcgLZWSlVmL58Xqy9cgTCcc= +go.jetify.com/typeid/v2 v2.0.0-alpha.3/go.mod h1:zfD1ZDHDJNgXZANsO9jDOD81XRRQ0zAOnDBEHmIV/Gw= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/a2aremote/mapping.go b/a2aremote/mapping.go new file mode 100644 index 0000000..07bcdbe --- /dev/null +++ b/a2aremote/mapping.go @@ -0,0 +1,209 @@ +package a2aremote + +import ( + "encoding/json" + "strings" + "time" + + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" +) + +// fipaMeta is the ACL parameter set as it travels in Message.metadata, +// under FIPAExtensionURI as its key. +// +// conversationId is deliberately absent: it is contextId, a native A2A +// field, and carrying it in both places would create two things to +// disagree. Sender is absent for a harder reason: a peer does not get to +// name who is speaking. That comes from the credentials it presented. +type fipaMeta struct { + Performative string `json:"performative,omitempty"` + ReplyWith string `json:"replyWith,omitempty"` + InReplyTo string `json:"inReplyTo,omitempty"` + Ontology string `json:"ontology,omitempty"` + Protocol string `json:"protocol,omitempty"` + Language string `json:"language,omitempty"` + Encoding string `json:"encoding,omitempty"` + ReplyBy string `json:"replyBy,omitempty"` +} + +// MessageFromEnvelope renders a cortex envelope as an A2A message. +// +// The ACL parameters ride in metadata under the extension's URI, and the +// message declares that URI in its extensions list, which is what the +// protocol asks of a message an extension contributed to. +func MessageFromEnvelope(e *a2a.Envelope) Message { + meta := fipaMeta{ + Performative: string(e.Performative), + ReplyWith: e.ReplyWith, + InReplyTo: e.InReplyTo, + Ontology: e.Ontology, + Protocol: e.Protocol, + Language: e.Language, + Encoding: e.Encoding, + } + if e.ReplyBy != nil { + meta.ReplyBy = timestamp(*e.ReplyBy) + } + + m := Message{ + MessageID: e.ID.String(), + Role: RoleAgent, + Parts: []Part{{Text: e.Content}}, + Extensions: []string{FIPAExtensionURI}, + Metadata: map[string]any{FIPAExtensionURI: meta}, + } + if !e.ConversationID.IsNil() { + m.ContextID = e.ConversationID.String() + } + if !e.OriginRunID.IsNil() { + m.TaskID = e.OriginRunID.String() + } + return m +} + +// EnvelopeParamsFromMessage turns an inbound A2A message into the send +// params the bus takes. +// +// sender and receiver are arguments rather than anything read out of the +// message, and that is the security property this signature exists to +// enforce: the caller passes the sender its credentials earned, so no +// field a peer controls can change who the message appears to be from. +func EnvelopeParamsFromMessage(m Message, sender, receiver a2a.Address) (a2a.SendParams, error) { + content, err := contentOf(m.Parts) + if err != nil { + return a2a.SendParams{}, err + } + + meta := fipaFrom(m.Metadata) + perf := a2a.Performative(meta.Performative) + if perf == "" { + // A peer that knows nothing about FIPA is asking for something, + // which is what a request means. Reading it as an inform instead + // would file the message in a mailbox nobody is watching. + perf = a2a.Request + } + + params := a2a.SendParams{ + Sender: sender, + Receivers: []a2a.Address{receiver}, + Performative: perf, + Content: content, + Ontology: meta.Ontology, + Protocol: meta.Protocol, + Language: meta.Language, + Encoding: meta.Encoding, + ReplyWith: meta.ReplyWith, + InReplyTo: meta.InReplyTo, + } + if m.ContextID != "" { + convID, convErr := id.ParseWithPrefix(m.ContextID, id.PrefixConversation) + if convErr != nil { + return a2a.SendParams{}, ErrInvalidParams("contextId is not a conversation id: " + convErr.Error()) + } + params.ConversationID = convID + } + if meta.ReplyBy != "" { + by, byErr := time.Parse(time.RFC3339, meta.ReplyBy) + if byErr != nil { + return a2a.SendParams{}, ErrInvalidParams("replyBy is not an RFC3339 timestamp") + } + params.ReplyBy = &by + } + return params, nil +} + +// contentOf flattens the parts into text, refusing the shapes cortex +// cannot read rather than dropping them. A peer whose attachment +// vanished silently has no way to work out why the answer made no sense. +func contentOf(parts []Part) (string, error) { + var texts []string + for _, p := range parts { + switch { + case p.File != nil: + return "", ErrContentTypeNotSupported("file") + case p.Data != nil: + return "", ErrContentTypeNotSupported("data") + case p.Text != "": + texts = append(texts, p.Text) + } + } + if len(texts) == 0 { + return "", ErrInvalidParams("the message carries no text to act on") + } + return strings.Join(texts, "\n\n"), nil +} + +// fipaFrom pulls the ACL parameters out of a message's metadata. A +// message without them decodes to the zero value, which is what makes a +// FIPA-unaware peer work. +func fipaFrom(metadata map[string]any) fipaMeta { + raw, ok := metadata[FIPAExtensionURI] + if !ok { + return fipaMeta{} + } + // The value arrives as a decoded any, so it is round-tripped rather + // than type-asserted field by field. + encoded, err := json.Marshal(raw) + if err != nil { + return fipaMeta{} + } + var meta fipaMeta + if err := json.Unmarshal(encoded, &meta); err != nil { + return fipaMeta{} + } + return meta +} + +// TaskFromRun projects a run as a task. +// +// Nothing is stored. A task rebuilt on every read cannot drift from the +// run it describes, which is the failure mode a stored task table would +// have made possible. +func TaskFromRun(r *run.Run, contextID string) Task { + state := taskState(r.State) + task := Task{ + ID: r.ID.String(), + ContextID: contextID, + Status: TaskStatus{State: state, Timestamp: timestamp(time.Now())}, + } + + switch { + case state == TaskStateCompleted && r.Output != "": + task.Artifacts = []Artifact{{ + ArtifactID: r.ID.String() + "-output", + Name: "output", + Parts: []Part{{Text: r.Output}}, + }} + case r.Error != "": + task.Status.Message = &Message{ + MessageID: r.ID.String() + "-status", + Role: RoleAgent, + Parts: []Part{{Text: r.Error}}, + } + } + return task +} + +func taskState(s run.State) TaskState { + switch s { + case run.StateCreated: + return TaskStateSubmitted + case run.StateRunning: + return TaskStateWorking + case run.StateCompleted: + return TaskStateCompleted + case run.StateFailed: + return TaskStateFailed + case run.StateCancelled: + return TaskStateCanceled + case run.StatePaused: + // Waiting on something outside itself, which is what the state + // means. Whether that is a human approving, a host executing a + // tool, or another agent answering is not the peer's business. + return TaskStateInputRequired + default: + return TaskStateSubmitted + } +} diff --git a/a2aremote/mapping_test.go b/a2aremote/mapping_test.go new file mode 100644 index 0000000..a1de32e --- /dev/null +++ b/a2aremote/mapping_test.go @@ -0,0 +1,192 @@ +package a2aremote + +import ( + "errors" + "strings" + "testing" + "time" + + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" +) + +func TestTaskStateProjection(t *testing.T) { + cases := map[run.State]TaskState{ + run.StateCreated: TaskStateSubmitted, + run.StateRunning: TaskStateWorking, + run.StateCompleted: TaskStateCompleted, + run.StateFailed: TaskStateFailed, + run.StateCancelled: TaskStateCanceled, + // A paused run is waiting on something outside itself, which is + // exactly what INPUT_REQUIRED means. The peer learns that it is + // waiting without learning whose business it waits on. + run.StatePaused: TaskStateInputRequired, + } + for state, want := range cases { + if got := taskState(state); got != want { + t.Errorf("%s -> %s, want %s", state, got, want) + } + } +} + +func TestEnvelopeRoundTripsThroughAMessage(t *testing.T) { + deadline := time.Date(2026, 8, 26, 12, 0, 0, 0, time.UTC) + e := &a2a.Envelope{ + ID: id.NewMessageID(), ConversationID: id.NewConversationID(), + Performative: a2a.CFP, Sender: a2a.Address{Agent: "planner"}, + Receivers: []a2a.Address{{Agent: "worker", Node: "peer.example"}}, + Content: "who can take this?", Ontology: "ops", Protocol: "fipa-contract-net", + ReplyWith: "rw-1", ReplyBy: &deadline, + } + m := MessageFromEnvelope(e) + + if m.ContextID != e.ConversationID.String() { + t.Errorf("contextId = %q, want the conversation id", m.ContextID) + } + if len(m.Parts) != 1 || m.Parts[0].Text != "who can take this?" { + t.Errorf("parts lost the content: %+v", m.Parts) + } + if len(m.Extensions) != 1 || m.Extensions[0] != FIPAExtensionURI { + t.Errorf("a message using the extension must declare it: %+v", m.Extensions) + } + + params, err := EnvelopeParamsFromMessage(m, + a2a.Address{Agent: "planner", Node: "peer.example"}, + a2a.Address{Agent: "worker"}) + if err != nil { + t.Fatalf("EnvelopeParamsFromMessage: %v", err) + } + if params.Performative != a2a.CFP { + t.Errorf("performative = %s, want cfp", params.Performative) + } + if params.Ontology != "ops" || params.Protocol != "fipa-contract-net" || params.ReplyWith != "rw-1" { + t.Errorf("ACL parameters did not survive: %+v", params) + } + if params.ReplyBy == nil || !params.ReplyBy.Equal(deadline) { + t.Errorf("replyBy did not survive: %v", params.ReplyBy) + } + if params.ConversationID != e.ConversationID { + t.Errorf("the conversation did not survive as contextId: %v", params.ConversationID) + } +} + +// A peer that has never heard of FIPA still has to work. This is what +// keeps the extension optional in practice and not only in the card. +func TestMessageWithoutFIPAMetadataGetsASensibleDefault(t *testing.T) { + plain := Message{MessageID: "m1", Role: RoleUser, Parts: []Part{{Text: "do the thing"}}} + + params, err := EnvelopeParamsFromMessage(plain, + a2a.Address{Agent: "someone", Node: "peer.example"}, + a2a.Address{Agent: "worker"}) + if err != nil { + t.Fatalf("EnvelopeParamsFromMessage: %v", err) + } + if params.Performative != a2a.Request { + t.Fatalf("performative = %s, want request for a plain inbound message", params.Performative) + } + if params.Content != "do the thing" { + t.Fatalf("content = %q", params.Content) + } +} + +func TestSenderAlwaysComesFromTheArgument(t *testing.T) { + // The message tries to name a sender of its own in every field a + // peer could reach. None of them may win. + m := Message{ + MessageID: "m1", Role: RoleUser, Parts: []Part{{Text: "trust me"}}, + Metadata: map[string]any{ + "sender": "planner", + "from": "planner", + FIPAExtensionURI: map[string]any{ + "performative": "inform", + "sender": "planner", + }, + }, + } + params, err := EnvelopeParamsFromMessage(m, + a2a.Address{Agent: "their-agent", Node: "peer.example"}, + a2a.Address{Agent: "worker"}) + if err != nil { + t.Fatalf("EnvelopeParamsFromMessage: %v", err) + } + if params.Sender.Agent != "their-agent" || params.Sender.Node != "peer.example" { + t.Fatalf("sender = %+v, want the one the caller passed", params.Sender) + } +} + +func TestMessageWithSeveralTextPartsIsJoined(t *testing.T) { + m := Message{MessageID: "m1", Role: RoleUser, Parts: []Part{{Text: "first"}, {Text: "second"}}} + params, err := EnvelopeParamsFromMessage(m, a2a.Address{Agent: "s", Node: "n"}, a2a.Address{Agent: "w"}) + if err != nil { + t.Fatalf("EnvelopeParamsFromMessage: %v", err) + } + if params.Content != "first\n\nsecond" { + t.Fatalf("content = %q, want the parts joined", params.Content) + } +} + +func TestUnsupportedPartTypeIsRefused(t *testing.T) { + for name, m := range map[string]Message{ + "file": {MessageID: "m1", Role: RoleUser, Parts: []Part{{File: &FilePart{URL: "https://x/y.png"}}}}, + "data": {MessageID: "m1", Role: RoleUser, Parts: []Part{{Data: &DataPart{Data: map[string]any{"k": "v"}}}}}, + } { + t.Run(name, func(t *testing.T) { + _, err := EnvelopeParamsFromMessage(m, a2a.Address{Agent: "s", Node: "n"}, a2a.Address{Agent: "w"}) + var aerr *Error + if !errors.As(err, &aerr) || aerr.Code != CodeContentTypeNotSupported { + t.Fatalf("err = %v, want ContentTypeNotSupportedError", err) + } + }) + } +} + +func TestEmptyMessageIsRefused(t *testing.T) { + _, err := EnvelopeParamsFromMessage(Message{MessageID: "m1", Role: RoleUser}, + a2a.Address{Agent: "s", Node: "n"}, a2a.Address{Agent: "w"}) + var aerr *Error + if !errors.As(err, &aerr) || aerr.Code != CodeInvalidParams { + t.Fatalf("err = %v, want InvalidParams", err) + } +} + +func TestTaskFromRun(t *testing.T) { + r := &run.Run{ID: id.NewAgentRunID(), State: run.StateCompleted, Output: "done and dusted"} + task := TaskFromRun(r, "conv_1") + + if task.ID != r.ID.String() { + t.Errorf("task id = %q, want the run id verbatim", task.ID) + } + if task.ContextID != "conv_1" { + t.Errorf("contextId = %q", task.ContextID) + } + if task.Status.State != TaskStateCompleted { + t.Errorf("state = %s", task.Status.State) + } + if len(task.Artifacts) != 1 || task.Artifacts[0].Parts[0].Text != "done and dusted" { + t.Errorf("the run output must arrive as an artifact: %+v", task.Artifacts) + } +} + +func TestFailedRunCarriesItsErrorIntoTheStatus(t *testing.T) { + r := &run.Run{ID: id.NewAgentRunID(), State: run.StateFailed, Error: "the model refused"} + task := TaskFromRun(r, "") + + if task.Status.State != TaskStateFailed { + t.Fatalf("state = %s, want failed", task.Status.State) + } + if task.Status.Message == nil || !strings.Contains(task.Status.Message.Parts[0].Text, "the model refused") { + t.Fatalf("a failed task must say why: %+v", task.Status) + } + if len(task.Artifacts) != 0 { + t.Fatalf("a failed run produced no output, so it has no artifacts: %+v", task.Artifacts) + } +} + +func TestRunningRunHasNoArtifactsYet(t *testing.T) { + r := &run.Run{ID: id.NewAgentRunID(), State: run.StateRunning} + task := TaskFromRun(r, "") + if task.Status.State != TaskStateWorking || len(task.Artifacts) != 0 { + t.Fatalf("task = %+v", task) + } +} From 5d1487eb5d91be4fcc4bf63f2857f8b134e30140 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:30:36 -0500 Subject: [PATCH 32/50] feat(a2aremote): build and fetch agent cards --- a2aremote/card.go | 225 +++++++++++++++++++++++++++++++++++++++++ a2aremote/card_test.go | 165 ++++++++++++++++++++++++++++++ 2 files changed, 390 insertions(+) create mode 100644 a2aremote/card.go create mode 100644 a2aremote/card_test.go diff --git a/a2aremote/card.go b/a2aremote/card.go new file mode 100644 index 0000000..b183660 --- /dev/null +++ b/a2aremote/card.go @@ -0,0 +1,225 @@ +package a2aremote + +import ( + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/skill" +) + +// WellKnownCardPath is where a 1.0 client looks for an agent card. The +// 0.x path was /.well-known/agent.json, and serving that instead is how +// an agent ends up invisible to every current client. +const WellKnownCardPath = "/.well-known/agent-card.json" + +// The protocol binding names, which are an open set with three official +// members. +const ( + BindingJSONRPC = "JSONRPC" + BindingGRPC = "GRPC" + BindingREST = "HTTP+JSON" +) + +// maxCardBytes caps a fetched card. A peer that answers the card path +// with something enormous should not be able to spend our memory. +const maxCardBytes = 1 << 20 + +// AgentProvider is who runs an agent. +type AgentProvider struct { + URL string `json:"url"` + Organization string `json:"organization"` +} + +// AgentInterface is one way to reach an agent. +// +// Tenant is the protocol's routing identifier for serving many agents +// behind one endpoint, and it is what makes cortex's many-agents model +// expressible without inventing anything: the tenant IS the agent name. +type AgentInterface struct { + URL string `json:"url"` + ProtocolBinding string `json:"protocolBinding"` + Tenant string `json:"tenant,omitempty"` +} + +// AgentExtension declares an extension an agent understands. +type AgentExtension struct { + URI string `json:"uri"` + Description string `json:"description,omitempty"` + Required bool `json:"required,omitempty"` + Params map[string]any `json:"params,omitempty"` +} + +// AgentCapabilities says what an agent can do beyond the base protocol. +type AgentCapabilities struct { + Streaming bool `json:"streaming,omitempty"` + PushNotifications bool `json:"pushNotifications,omitempty"` + Extensions []AgentExtension `json:"extensions,omitempty"` + ExtendedAgentCard bool `json:"extendedAgentCard,omitempty"` +} + +// AgentSkill is one thing an agent can do. +type AgentSkill struct { + ID string `json:"id"` + Name string `json:"name"` + Description string `json:"description"` + Tags []string `json:"tags"` + Examples []string `json:"examples,omitempty"` +} + +// AgentCard is what an agent publishes about itself. +// +// Everything here is public to anyone who can reach the endpoint, which +// is why exposing an agent is opt-in rather than automatic: an agent's +// description and skill list are disclosure. +type AgentCard struct { + Name string `json:"name"` + Description string `json:"description"` + SupportedInterfaces []AgentInterface `json:"supportedInterfaces"` + Provider *AgentProvider `json:"provider,omitempty"` + Version string `json:"version"` + DocumentationURL string `json:"documentationUrl,omitempty"` + Capabilities AgentCapabilities `json:"capabilities"` + DefaultInputModes []string `json:"defaultInputModes"` + DefaultOutputModes []string `json:"defaultOutputModes"` + Skills []AgentSkill `json:"skills"` +} + +// CardOptions is the host-supplied half of a card: the things cortex +// cannot know about itself, like the URL it is reachable at. +type CardOptions struct { + BaseURL string + Version string + Provider AgentProvider + DocumentationURL string + // Bindings limits which bindings the card advertises. Empty means + // JSON-RPC only, which is what this module serves today. + Bindings []string +} + +// BuildCard renders one cortex agent as an A2A agent card. +func BuildCard(a *agent.Config, skills []*skill.Skill, opts CardOptions) AgentCard { + version := opts.Version + if version == "" { + version = "1.0.0" + } + bindings := opts.Bindings + if len(bindings) == 0 { + bindings = []string{BindingJSONRPC} + } + + interfaces := make([]AgentInterface, 0, len(bindings)) + for _, b := range bindings { + interfaces = append(interfaces, AgentInterface{ + URL: opts.BaseURL, + ProtocolBinding: b, + Tenant: a.Name, + }) + } + + card := AgentCard{ + Name: a.Name, + Description: describe(a), + SupportedInterfaces: interfaces, + Version: version, + DocumentationURL: opts.DocumentationURL, + Capabilities: AgentCapabilities{ + // Everything cortex does not implement is declared false + // rather than left out, so a peer reads a refusal here + // instead of discovering it mid-conversation. + Streaming: false, + PushNotifications: false, + ExtendedAgentCard: false, + Extensions: []AgentExtension{{ + URI: FIPAExtensionURI, + Description: "FIPA-ACL speech acts carried in message metadata. " + + "Optional: a client that ignores it still receives valid A2A messages.", + Required: false, + }}, + }, + // Only text is understood. Saying so here beats letting a peer + // find out by having its attachment refused. + DefaultInputModes: []string{"text/plain"}, + DefaultOutputModes: []string{"text/plain"}, + Skills: cardSkills(a, skills), + } + if opts.Provider.Organization != "" { + p := opts.Provider + card.Provider = &p + } + return card +} + +func describe(a *agent.Config) string { + if a.Description != "" { + return a.Description + } + return "A cortex agent named " + a.Name + "." +} + +// cardSkills maps cortex skills onto card skills, synthesising one when +// the agent has none. skills is REQUIRED in the schema, and an empty +// list reads as an agent that can do nothing. +func cardSkills(a *agent.Config, skills []*skill.Skill) []AgentSkill { + if len(skills) == 0 { + return []AgentSkill{{ + ID: a.Name, + Name: a.Name, + Description: describe(a), + Tags: []string{"cortex"}, + }} + } + out := make([]AgentSkill, 0, len(skills)) + for _, s := range skills { + desc := s.Description + if desc == "" { + desc = s.Name + } + out = append(out, AgentSkill{ + ID: s.Name, + Name: s.Name, + Description: desc, + Tags: []string{"cortex"}, + }) + } + return out +} + +// FetchCard reads a peer's agent card from its well-known path. +func FetchCard(ctx context.Context, c *http.Client, baseURL string) (AgentCard, error) { + if c == nil { + c = http.DefaultClient + } + url := strings.TrimSuffix(baseURL, "/") + WellKnownCardPath + + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + if err != nil { + return AgentCard{}, fmt.Errorf("build card request: %w", err) + } + req.Header.Set("Accept", "application/json") + + resp, err := c.Do(req) + if err != nil { + return AgentCard{}, fmt.Errorf("fetch agent card: %w", err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return AgentCard{}, fmt.Errorf("fetch agent card: %s returned %s", url, resp.Status) + } + + var card AgentCard + if err := json.NewDecoder(io.LimitReader(resp.Body, maxCardBytes)).Decode(&card); err != nil { + return AgentCard{}, fmt.Errorf("decode agent card: %w", err) + } + // A card with no name and nowhere to reach it is not a card, and + // treating it as one means failing later with a stranger error. + if card.Name == "" || len(card.SupportedInterfaces) == 0 { + return AgentCard{}, fmt.Errorf("%s served a document that is not an agent card", url) + } + return card, nil +} diff --git a/a2aremote/card_test.go b/a2aremote/card_test.go new file mode 100644 index 0000000..4e952b1 --- /dev/null +++ b/a2aremote/card_test.go @@ -0,0 +1,165 @@ +package a2aremote + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/skill" +) + +func testCardOptions() CardOptions { + return CardOptions{ + BaseURL: "https://cortex.example/a2a", + Version: "1.0.0", + Provider: AgentProvider{Organization: "acme", URL: "https://acme.example"}, + } +} + +func TestBuildCardDeclaresTheAgentAsATenant(t *testing.T) { + card := BuildCard(&agent.Config{Name: "db-expert", Description: "knows the database"}, nil, testCardOptions()) + + if card.Name != "db-expert" { + t.Errorf("name = %q", card.Name) + } + if card.Description == "" { + t.Error("description is required by the schema and is what a human reads first") + } + if len(card.SupportedInterfaces) == 0 { + t.Fatal("a card with no interface tells a client nothing about how to reach it") + } + iface := card.SupportedInterfaces[0] + if iface.ProtocolBinding != BindingJSONRPC { + t.Errorf("protocolBinding = %q, want JSONRPC", iface.ProtocolBinding) + } + // tenant is how one endpoint serves many agents, and it is the + // protocol's own mechanism rather than a cortex convention. + if iface.Tenant != "db-expert" { + t.Errorf("tenant = %q, want the agent name", iface.Tenant) + } + if iface.URL != "https://cortex.example/a2a" { + t.Errorf("url = %q", iface.URL) + } +} + +func TestCardDeclaresTheFIPAExtensionAsOptional(t *testing.T) { + card := BuildCard(&agent.Config{Name: "a"}, nil, testCardOptions()) + + var found *AgentExtension + for i := range card.Capabilities.Extensions { + if card.Capabilities.Extensions[i].URI == FIPAExtensionURI { + found = &card.Capabilities.Extensions[i] + } + } + if found == nil { + t.Fatal("the card must declare the extension its messages use") + } + // Required would refuse conversations we can hold perfectly well: a + // peer that ignores the extension still gets valid A2A and reads the + // text. + if found.Required { + t.Fatal("the FIPA extension must be optional") + } + if found.Description == "" { + t.Error("an extension nobody can look up needs a description in the card") + } +} + +func TestCardDeclaresWhatIsNotSupported(t *testing.T) { + card := BuildCard(&agent.Config{Name: "a"}, nil, testCardOptions()) + + if card.Capabilities.PushNotifications { + t.Error("push notifications are not implemented and must not be advertised") + } + if card.Capabilities.ExtendedAgentCard { + t.Error("the extended card is not implemented and must not be advertised") + } + if card.Capabilities.Streaming { + t.Error("streaming is not implemented yet and must not be advertised") + } +} + +func TestCardSkillsComeFromTheAgentsSkills(t *testing.T) { + card := BuildCard(&agent.Config{Name: "a"}, []*skill.Skill{ + {Name: "sql-review", Description: "reviews SQL migrations"}, + }, testCardOptions()) + if len(card.Skills) != 1 || card.Skills[0].Name != "sql-review" { + t.Fatalf("skills = %+v", card.Skills) + } + if card.Skills[0].ID == "" || card.Skills[0].Description == "" { + t.Errorf("id and description are required by the schema: %+v", card.Skills[0]) + } + + // An agent with no skills still needs one entry: skills is REQUIRED + // in the schema, and an empty list makes the agent look useless. + bare := BuildCard(&agent.Config{Name: "a", Description: "does things"}, nil, testCardOptions()) + if len(bare.Skills) != 1 { + t.Fatalf("a skill-less agent needs one synthesised skill, got %+v", bare.Skills) + } +} + +func TestCardModesAreText(t *testing.T) { + card := BuildCard(&agent.Config{Name: "a"}, nil, testCardOptions()) + // Only text is understood, and the card says so rather than letting a + // peer discover it by having its attachment refused. + if len(card.DefaultInputModes) != 1 || card.DefaultInputModes[0] != "text/plain" { + t.Errorf("defaultInputModes = %+v", card.DefaultInputModes) + } + if len(card.DefaultOutputModes) != 1 || card.DefaultOutputModes[0] != "text/plain" { + t.Errorf("defaultOutputModes = %+v", card.DefaultOutputModes) + } +} + +func TestCardWireNames(t *testing.T) { + b, err := json.Marshal(BuildCard(&agent.Config{Name: "a"}, nil, testCardOptions())) + if err != nil { + t.Fatalf("marshal: %v", err) + } + var raw map[string]any + if err := json.Unmarshal(b, &raw); err != nil { + t.Fatalf("unmarshal: %v", err) + } + for _, key := range []string{"name", "description", "supportedInterfaces", "version", "capabilities", "defaultInputModes", "defaultOutputModes", "skills"} { + if _, ok := raw[key]; !ok { + t.Errorf("missing wire field %q in %s", key, b) + } + } +} + +func TestFetchCardRoundTrip(t *testing.T) { + want := BuildCard(&agent.Config{Name: "db-expert", Description: "knows the database"}, nil, testCardOptions()) + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // The path a 1.0 client asks for. 0.x used /.well-known/agent.json + // and nothing looks there now. + if r.URL.Path != WellKnownCardPath { + w.WriteHeader(http.StatusNotFound) + return + } + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(want) + })) + defer srv.Close() + + got, err := FetchCard(context.Background(), srv.Client(), srv.URL) + if err != nil { + t.Fatalf("FetchCard: %v", err) + } + if got.Name != want.Name || len(got.SupportedInterfaces) != len(want.SupportedInterfaces) { + t.Fatalf("fetched card does not match: %+v", got) + } +} + +func TestFetchCardRejectsANonCard(t *testing.T) { + srv := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"nothing":"useful"}`)) + })) + defer srv.Close() + + if _, err := FetchCard(context.Background(), srv.Client(), srv.URL); err == nil { + t.Fatal("a document with no name and no interfaces is not a card") + } +} From af050054cec07a0f18d308d271112e98b5af7dbf Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:30:51 -0500 Subject: [PATCH 33/50] style(a2aremote): use http.NoBody for the card request --- a2aremote/card.go | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/a2aremote/card.go b/a2aremote/card.go index b183660..05c9b02 100644 --- a/a2aremote/card.go +++ b/a2aremote/card.go @@ -196,7 +196,7 @@ func FetchCard(ctx context.Context, c *http.Client, baseURL string) (AgentCard, } url := strings.TrimSuffix(baseURL, "/") + WellKnownCardPath - req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil) + req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, http.NoBody) if err != nil { return AgentCard{}, fmt.Errorf("build card request: %w", err) } From e80c2c9311f87a14c2b8e8a751fa5b8457280091 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:35:28 -0500 Subject: [PATCH 34/50] feat(a2aremote): add the service every binding shares A task id is the delivery id, not a run id. Nothing has run when SendMessage returns, so naming a run would mean inventing an id for something that does not exist; the delivery row is the durable handle that exists right now, and GetTask follows it to the run once there is one. DeliveryOutcome and the a2a store grew what that needs. --- a2a/bus.go | 9 +- a2a/memstore_test.go | 11 ++ a2a/store.go | 4 + a2aremote/fakes_test.go | 163 +++++++++++++++++ a2aremote/seams.go | 97 ++++++++++ a2aremote/service.go | 299 ++++++++++++++++++++++++++++++ a2aremote/service_test.go | 370 ++++++++++++++++++++++++++++++++++++++ store/mongo/a2a.go | 9 + store/postgres/a2a.go | 9 + store/sqlite/a2a.go | 9 + 10 files changed, 979 insertions(+), 1 deletion(-) create mode 100644 a2aremote/fakes_test.go create mode 100644 a2aremote/seams.go create mode 100644 a2aremote/service.go create mode 100644 a2aremote/service_test.go diff --git a/a2a/bus.go b/a2a/bus.go index b32d318..733143d 100644 --- a/a2a/bus.go +++ b/a2a/bus.go @@ -147,6 +147,13 @@ type DeliveryOutcome struct { Receiver Address `json:"receiver"` Status string `json:"status"` Error string `json:"error,omitempty"` + // DeliveryID is the row this delivery became. + // + // It exists because a caller often needs a handle to the work before + // the work exists: at send time nothing has been delivered, so there + // is no run to name yet, and the delivery is the only durable thing + // to point at. The remote transport hands it to a peer as a task id. + DeliveryID id.DeliveryID `json:"delivery_id,omitempty"` } // SendResult is what a send produced. @@ -286,7 +293,7 @@ func (b *Bus) submit(ctx context.Context, e *Envelope, conv *Conversation) (*Sen res.Deliveries = append(res.Deliveries, DeliveryOutcome{Receiver: r, Status: DeliveryFailed, Error: err.Error()}) continue } - res.Deliveries = append(res.Deliveries, DeliveryOutcome{Receiver: r, Status: DeliveryQueued}) + res.Deliveries = append(res.Deliveries, DeliveryOutcome{Receiver: r, Status: DeliveryQueued, DeliveryID: d.ID}) b.dispatch.enqueue(d.ID) } diff --git a/a2a/memstore_test.go b/a2a/memstore_test.go index a62ed81..817139a 100644 --- a/a2a/memstore_test.go +++ b/a2a/memstore_test.go @@ -145,6 +145,17 @@ func (s *memStore) CreateDelivery(_ context.Context, d *Delivery) error { return nil } +func (s *memStore) GetDelivery(_ context.Context, deliveryID id.DeliveryID) (*Delivery, error) { + s.mu.Lock() + defer s.mu.Unlock() + d, ok := s.deliveries[deliveryID.String()] + if !ok { + return nil, ErrDeliveryNotFound + } + cp := *d + return &cp, nil +} + func (s *memStore) UpdateDelivery(_ context.Context, d *Delivery) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/a2a/store.go b/a2a/store.go index 0007e0a..774e97d 100644 --- a/a2a/store.go +++ b/a2a/store.go @@ -57,6 +57,10 @@ type Store interface { ListConversations(ctx context.Context, filter *ConversationListFilter) ([]*Conversation, error) CreateDelivery(ctx context.Context, d *Delivery) error + // GetDelivery reads one delivery row. A caller that was handed a + // delivery id at send time uses it to find out what became of the + // message, including the run it eventually started. + GetDelivery(ctx context.Context, deliveryID id.DeliveryID) (*Delivery, error) UpdateDelivery(ctx context.Context, d *Delivery) error // ClaimDelivery takes ownership of a queued delivery and marks it // delivering. It returns ErrDeliveryAlreadyClaimed when the row is in diff --git a/a2aremote/fakes_test.go b/a2aremote/fakes_test.go new file mode 100644 index 0000000..2b781b2 --- /dev/null +++ b/a2aremote/fakes_test.go @@ -0,0 +1,163 @@ +package a2aremote + +import ( + "context" + "errors" + "sync" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" + "github.com/xraph/cortex/skill" +) + +func testScope() cortex.Scope { + return cortex.Scope{Levels: []cortex.Level{{Key: "tenant", Value: "resolved"}}} +} + +// fakeGateway records the context and params it was called with, which +// is how the scope tests see what actually reached cortex. +type fakeGateway struct { + mu sync.Mutex + lastCtx context.Context //nolint:containedctx // the test asserts on the scope it carried + lastParams a2a.SendParams + sendCalls int + + agents map[string]*agent.Config + runs map[string]*run.Run + skills map[string]*skill.Skill + deliveries map[string]*a2a.Delivery + + sendResult *a2a.SendResult + sendErr error + cancelErr error +} + +func newFakeGateway() *fakeGateway { + return &fakeGateway{ + agents: map[string]*agent.Config{"worker": {ID: id.NewAgentID(), Name: "worker"}}, + runs: map[string]*run.Run{}, + skills: map[string]*skill.Skill{}, + deliveries: map[string]*a2a.Delivery{}, + } +} + +func (g *fakeGateway) SendMessage(ctx context.Context, p a2a.SendParams) (*a2a.SendResult, error) { + g.mu.Lock() + defer g.mu.Unlock() + g.lastCtx, g.lastParams = ctx, p + g.sendCalls++ + if g.sendErr != nil { + return nil, g.sendErr + } + if g.sendResult != nil { + return g.sendResult, nil + } + return &a2a.SendResult{MessageID: id.NewMessageID(), ConversationID: id.NewConversationID()}, nil +} + +func (g *fakeGateway) GetRun(_ context.Context, runID id.AgentRunID) (*run.Run, error) { + g.mu.Lock() + defer g.mu.Unlock() + r, ok := g.runs[runID.String()] + if !ok { + return nil, errors.New("run not found") + } + return r, nil +} + +func (g *fakeGateway) GetDelivery(_ context.Context, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + g.mu.Lock() + defer g.mu.Unlock() + d, ok := g.deliveries[deliveryID.String()] + if !ok { + return nil, errors.New("delivery not found") + } + return d, nil +} + +func (g *fakeGateway) addDelivery(d *a2a.Delivery) { + g.mu.Lock() + defer g.mu.Unlock() + g.deliveries[d.ID.String()] = d +} + +func (g *fakeGateway) ListRuns(_ context.Context, _ *run.ListFilter) ([]*run.Run, error) { + g.mu.Lock() + defer g.mu.Unlock() + out := make([]*run.Run, 0, len(g.runs)) + for _, r := range g.runs { + out = append(out, r) + } + return out, nil +} + +func (g *fakeGateway) CancelRun(_ context.Context, _ id.AgentRunID) error { + g.mu.Lock() + defer g.mu.Unlock() + return g.cancelErr +} + +func (g *fakeGateway) GetAgentByName(_ context.Context, name string) (*agent.Config, error) { + g.mu.Lock() + defer g.mu.Unlock() + a, ok := g.agents[name] + if !ok { + return nil, errors.New("agent not found") + } + return a, nil +} + +func (g *fakeGateway) GetSkillByName(_ context.Context, name string) (*skill.Skill, error) { + g.mu.Lock() + defer g.mu.Unlock() + s, ok := g.skills[name] + if !ok { + return nil, errors.New("skill not found") + } + return s, nil +} + +func (g *fakeGateway) calls() int { + g.mu.Lock() + defer g.mu.Unlock() + return g.sendCalls +} + +func (g *fakeGateway) params() a2a.SendParams { + g.mu.Lock() + defer g.mu.Unlock() + return g.lastParams +} + +func (g *fakeGateway) scopeSeen() cortex.Scope { + g.mu.Lock() + defer g.mu.Unlock() + if g.lastCtx == nil { + return cortex.Scope{} + } + return cortex.ScopeFromContext(g.lastCtx) +} + +func (g *fakeGateway) addRun(r *run.Run) { + g.mu.Lock() + defer g.mu.Unlock() + g.runs[r.ID.String()] = r +} + +// staticResolver answers with one peer, or refuses. +type staticResolver struct { + peer Peer + err error +} + +func (s staticResolver) ResolvePeer(context.Context, Credentials) (Peer, error) { + return s.peer, s.err +} + +func testService(t interface{ Helper() }, gw Gateway, res PeerResolver) *Service { + t.Helper() + return NewService(gw, res, Options{}) +} diff --git a/a2aremote/seams.go b/a2aremote/seams.go new file mode 100644 index 0000000..3d96f75 --- /dev/null +++ b/a2aremote/seams.go @@ -0,0 +1,97 @@ +package a2aremote + +import ( + "context" + "crypto/tls" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" + "github.com/xraph/cortex/skill" +) + +// Gateway is the cortex surface the remote service needs. The engine +// satisfies it through a thin adapter, which is what keeps the core +// module from knowing this package exists. +type Gateway interface { + SendMessage(ctx context.Context, p a2a.SendParams) (*a2a.SendResult, error) + GetRun(ctx context.Context, runID id.AgentRunID) (*run.Run, error) + // GetDelivery resolves the handle a peer was given at send time. A + // task id is a delivery id until the delivery has started a run. + GetDelivery(ctx context.Context, deliveryID id.DeliveryID) (*a2a.Delivery, error) + ListRuns(ctx context.Context, filter *run.ListFilter) ([]*run.Run, error) + CancelRun(ctx context.Context, runID id.AgentRunID) error + GetAgentByName(ctx context.Context, name string) (*agent.Config, error) + GetSkillByName(ctx context.Context, name string) (*skill.Skill, error) +} + +// Credentials is everything a binding could learn about a caller. +// +// It is transport-neutral on purpose: HTTP headers and gRPC metadata are +// both string-keyed multimaps, and mutual TLS peers arrive through the +// same struct. A resolver written once serves all three bindings. +type Credentials struct { + Headers map[string][]string + RemoteAddr string + TLS *tls.ConnectionState +} + +// Header returns the first value for a header, case-insensitively. +func (c Credentials) Header(name string) string { + for k, v := range c.Headers { + if len(v) > 0 && equalFold(k, name) { + return v[0] + } + } + return "" +} + +// Peer is who a caller turned out to be. +type Peer struct { + // Node is how this caller appears as an a2a.Address.Node. Every + // sender name a peer claims is namespaced by it, which is what stops + // a peer presenting itself as one of your own agents. + Node string + // Scope is the cortex scope this peer's messages act in. It is the + // only place a scope enters an inbound request. + Scope cortex.Scope +} + +// PeerResolver authenticates an inbound caller. +// +// Cortex ships the seam and no implementation: hosts already have an +// identity system, and a second weaker one living inside cortex would be +// a liability rather than a convenience. Returning an error refuses the +// request, and the caller is told only that it was refused. +type PeerResolver interface { + ResolvePeer(ctx context.Context, cred Credentials) (Peer, error) +} + +// ResolverFunc adapts a function to PeerResolver. +type ResolverFunc func(ctx context.Context, cred Credentials) (Peer, error) + +// ResolvePeer implements PeerResolver. +func (f ResolverFunc) ResolvePeer(ctx context.Context, cred Credentials) (Peer, error) { + return f(ctx, cred) +} + +func equalFold(a, b string) bool { + if len(a) != len(b) { + return false + } + for i := range len(a) { + if lower(a[i]) != lower(b[i]) { + return false + } + } + return true +} + +func lower(c byte) byte { + if c >= 'A' && c <= 'Z' { + return c + ('a' - 'A') + } + return c +} diff --git a/a2aremote/service.go b/a2aremote/service.go new file mode 100644 index 0000000..5e6d0c4 --- /dev/null +++ b/a2aremote/service.go @@ -0,0 +1,299 @@ +package a2aremote + +import ( + "context" + "errors" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" +) + +// defaultRemoteSenderName is who a peer is, as far as cortex is +// concerned, when it does not say which of its agents is speaking. +const defaultRemoteSenderName = "remote" + +// defaultTaskPageSize caps a ListTasks page when the caller names none. +const defaultTaskPageSize = 50 + +// Options tunes the service. +type Options struct { + // Card supplies the half of an agent card cortex cannot know about + // itself, chiefly the URL peers reach it at. + Card CardOptions + // Exposed lists the agents whose cards are served. A card is public, + // so exposure is opt-in: an empty list serves no cards at all. + Exposed []string + // DefaultAgent also gets its card served at the root well-known + // path, so plain discovery finds something. + DefaultAgent string +} + +// Service holds every semantic decision the remote transport makes. +// +// The bindings translate formats and call in here. That is what lets +// three of them share one implementation and, more to the point, one set +// of security rules: a rule enforced here cannot be forgotten by the +// gRPC handler. +type Service struct { + gw Gateway + resolver PeerResolver + opts Options +} + +// NewService builds a Service, or returns nil when it cannot. +// +// A nil resolver returns nil rather than defaulting to something +// permissive. A service that authenticates nobody is an open door onto +// every agent in the process, and defaulting to it would be the kind of +// convenience that reads as a feature until it is a breach. +func NewService(gw Gateway, resolver PeerResolver, opts Options) *Service { + if gw == nil || resolver == nil { + return nil + } + return &Service{gw: gw, resolver: resolver, opts: opts} +} + +// SendMessage carries an inbound message to the agent named by tenant. +func (s *Service) SendMessage(ctx context.Context, cred Credentials, req SendMessageRequest) (*SendMessageResult, error) { + ctx, peer, err := s.authenticate(ctx, cred) + if err != nil { + return nil, err + } + if tenantErr := s.checkTenant(ctx, req.Tenant); tenantErr != nil { + return nil, tenantErr + } + + // The sender is built here, from the peer the credentials earned and + // the name the peer claims, in that order of authority. The claimed + // name only ever fills the agent half; the node half is ours. + sender := a2a.Address{Agent: req.SenderName, Node: peer.Node} + if sender.Agent == "" { + sender.Agent = defaultRemoteSenderName + } + + params, err := EnvelopeParamsFromMessage(req.Message, sender, a2a.Address{Agent: req.Tenant}) + if err != nil { + return nil, err + } + + sent, err := s.gw.SendMessage(ctx, params) + if err != nil { + return nil, mapBusError(err) + } + + // An informative was filed, so there is nothing to follow. A + // directive is work, and the peer gets a task to poll and cancel. + class, _ := params.Performative.Class() + if class != a2a.ClassDirective { + return &SendMessageResult{Message: &Message{ + MessageID: sent.MessageID.String(), + ContextID: sent.ConversationID.String(), + Role: RoleAgent, + Parts: []Part{{Text: "received"}}, + }}, nil + } + + // The task id is the DELIVERY id, not a run id. + // + // Nothing has run yet when this returns: the message is queued, and + // a run only exists once a worker carries it. The delivery row is + // the durable handle that exists right now, and GetTask follows it + // to the run as soon as there is one. Naming a run here would mean + // inventing an id for something that does not exist. + task := Task{ + ID: deliveryIDOf(sent), + ContextID: sent.ConversationID.String(), + Status: TaskStatus{State: TaskStateSubmitted}, + } + return &SendMessageResult{Task: &task}, nil +} + +// GetTask projects a run as a task. +func (s *Service) GetTask(ctx context.Context, cred Credentials, req GetTaskRequest) (*Task, error) { + ctx, _, err := s.authenticate(ctx, cred) + if err != nil { + return nil, err + } + r, err := s.loadRun(ctx, req.ID) + if err != nil { + return nil, err + } + task := TaskFromRun(r, "") + return &task, nil +} + +// ListTasks projects this scope's runs as tasks. +func (s *Service) ListTasks(ctx context.Context, cred Credentials, req ListTasksRequest) (*ListTasksResult, error) { + ctx, _, err := s.authenticate(ctx, cred) + if err != nil { + return nil, err + } + size := req.PageSize + if size <= 0 { + size = defaultTaskPageSize + } + runs, err := s.gw.ListRuns(ctx, &run.ListFilter{Limit: size}) + if err != nil { + return nil, ErrInternal("could not list tasks") + } + out := make([]Task, 0, len(runs)) + for _, r := range runs { + out = append(out, TaskFromRun(r, "")) + } + return &ListTasksResult{Tasks: out}, nil +} + +// CancelTask stops a running task. +func (s *Service) CancelTask(ctx context.Context, cred Credentials, req CancelTaskRequest) (*Task, error) { + ctx, _, err := s.authenticate(ctx, cred) + if err != nil { + return nil, err + } + r, err := s.loadRun(ctx, req.ID) + if err != nil { + return nil, err + } + if taskState(r.State).Terminal() { + return nil, ErrTaskNotCancelable(req.ID) + } + if err := s.gw.CancelRun(ctx, r.ID); err != nil { + return nil, ErrInternal("could not cancel the task") + } + // Read the projection off the state we just moved it to rather than + // re-reading: the store write has happened, and a second read would + // only add a way for this to disagree with itself. + task := TaskFromRun(&run.Run{ID: r.ID, State: run.StateCancelled}, "") + return &task, nil +} + +// authenticate resolves the caller and puts ITS scope on the context. +// +// Everything downstream reads the scope from the context, so this is the +// only place a scope enters an inbound request. No header, no message +// field and no query parameter reaches it. +func (s *Service) authenticate(ctx context.Context, cred Credentials) (context.Context, Peer, error) { + peer, err := s.resolver.ResolvePeer(ctx, cred) + if err != nil { + // Deliberately opaque: a caller that has not proved who it is + // learns that it was refused and nothing else about what is here. + return ctx, Peer{}, ErrUnauthenticated() + } + if peer.Scope.IsZero() { + // A resolver that answered with no scope did not really answer. + // Letting it through would fail several layers down, at a store + // call, with an error that says nothing about the cause. + return ctx, Peer{}, ErrUnauthenticated() + } + if peer.Node == "" { + // Without a node there is nothing to namespace the peer's sender + // name under, and it would land looking like a local agent. + return ctx, Peer{}, ErrUnauthenticated() + } + return cortex.WithScope(ctx, peer.Scope), peer, nil +} + +// checkTenant refuses a tenant that names no agent here. +// +// The error is TaskNotFound rather than something about agents, so a +// caller cannot map out which agents exist by probing names. +func (s *Service) checkTenant(ctx context.Context, tenant string) error { + if tenant == "" { + return ErrInvalidParams("tenant is required: it names the agent this message is for") + } + if _, err := s.gw.GetAgentByName(ctx, tenant); err != nil { + return ErrTaskNotFound(tenant) + } + return nil +} + +// loadRun reads the run behind a task id. +// +// A task id is one of two things, because of when a peer got it. A +// delivery id is what SendMessage hands back, before any run exists. A +// run id is what a peer may have learned later. Both resolve here, and +// anything else is not found: a malformed id and a missing run get the +// same answer, for the same reason an unknown tenant does. +func (s *Service) loadRun(ctx context.Context, taskID string) (*run.Run, error) { + parsed, err := id.Parse(taskID) + if err != nil { + return nil, ErrTaskNotFound(taskID) + } + + switch parsed.Prefix() { + case id.PrefixAgentRun: + r, runErr := s.gw.GetRun(ctx, parsed) + if runErr != nil { + return nil, ErrTaskNotFound(taskID) + } + return r, nil + + case id.PrefixDelivery: + d, delErr := s.gw.GetDelivery(ctx, parsed) + if delErr != nil { + return nil, ErrTaskNotFound(taskID) + } + if d.RunID.IsNil() { + // Queued or carried, but nothing has started yet. That is a + // real state rather than a missing task, so it gets a + // synthetic run to project from. + return &run.Run{ID: parsed, State: deliveryRunState(d)}, nil + } + r, runErr := s.gw.GetRun(ctx, d.RunID) + if runErr != nil { + return nil, ErrTaskNotFound(taskID) + } + return r, nil + + default: + return nil, ErrTaskNotFound(taskID) + } +} + +// deliveryRunState reads a delivery that has not started a run as a run +// state, so one projection covers both. +func deliveryRunState(d *a2a.Delivery) run.State { + switch d.State { + case a2a.DeliveryFailed: + return run.StateFailed + case a2a.DeliveryQueued: + return run.StateCreated + default: + // Delivering, or delivered to an inbox with no run behind it. + return run.StateRunning + } +} + +// deliveryIDOf picks the handle a send produced. A directive addressed +// to one agent has exactly one delivery; the message id is the fallback +// for the case where the send produced none, which a caller can still +// quote back even though it will not resolve to work. +func deliveryIDOf(res *a2a.SendResult) string { + for _, d := range res.Deliveries { + if !d.DeliveryID.IsNil() { + return d.DeliveryID.String() + } + } + return res.MessageID.String() +} + +// mapBusError turns a bus refusal into the protocol's own vocabulary. +// A hop ceiling and a closed conversation are the caller's problem to +// understand, so they keep their meaning; anything else is internal. +func mapBusError(err error) *Error { + switch { + case errors.Is(err, a2a.ErrHopCeiling): + return ErrInvalidRequest("this conversation has used up its message budget") + case errors.Is(err, a2a.ErrConversationClosed): + return ErrInvalidRequest("this conversation is closed") + case errors.Is(err, a2a.ErrSelfAddressed): + return ErrInvalidParams("that message is addressed to its own sender") + case errors.Is(err, a2a.ErrInvalidPerformative): + return ErrInvalidParams("unknown performative") + case errors.Is(err, a2a.ErrUnknownReceiver), errors.Is(err, a2a.ErrUnroutable): + return ErrTaskNotFound("recipient") + default: + return ErrInternal("the message could not be delivered") + } +} diff --git a/a2aremote/service_test.go b/a2aremote/service_test.go new file mode 100644 index 0000000..563c1b2 --- /dev/null +++ b/a2aremote/service_test.go @@ -0,0 +1,370 @@ +package a2aremote + +import ( + "context" + "errors" + "testing" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" +) + +func okResolver() staticResolver { + return staticResolver{peer: Peer{Node: "peer.example", Scope: testScope()}} +} + +func plainRequest(text string) SendMessageRequest { + return SendMessageRequest{ + Tenant: "worker", + Message: Message{MessageID: "m1", Role: RoleUser, Parts: []Part{{Text: text}}}, + } +} + +// The load-bearing rule of the whole inbound path: the scope comes from +// the resolver and from nothing a caller can set. +func TestSendMessageUsesTheResolversScopeAndNotTheMessages(t *testing.T) { + gw := newFakeGateway() + svc := testService(t, gw, okResolver()) + + req := plainRequest("hi") + req.Message.Metadata = map[string]any{"scope": "attacker", "tenant": "attacker"} + req.Metadata = map[string]any{"scope": "attacker"} + + if _, err := svc.SendMessage(context.Background(), Credentials{ + Headers: map[string][]string{"X-Scope": {"attacker"}, "X-Tenant": {"attacker"}}, + }, req); err != nil { + t.Fatalf("SendMessage: %v", err) + } + + got := gw.scopeSeen() + if len(got.Levels) != 1 || got.Levels[0].Value != "resolved" { + t.Fatalf("scope = %+v, want the resolver's and nothing else", got.Levels) + } +} + +// A peer must not be able to present itself as one of your own agents. +func TestSenderIsNamespacedByThePeersNode(t *testing.T) { + gw := newFakeGateway() + svc := testService(t, gw, okResolver()) + + req := plainRequest("trust me") + req.SenderName = "planner" // the name of a local agent, as it happens + + if _, err := svc.SendMessage(context.Background(), Credentials{}, req); err != nil { + t.Fatalf("SendMessage: %v", err) + } + + sender := gw.params().Sender + if sender.Node != "peer.example" { + t.Fatalf("sender = %+v, want it namespaced by the peer's node", sender) + } + if sender.IsLocal() { + t.Fatal("a remote peer must never present as a local agent") + } +} + +func TestSendMessageRefusesAnUnauthenticatedCaller(t *testing.T) { + gw := newFakeGateway() + svc := testService(t, gw, staticResolver{err: errors.New("no credentials")}) + + _, err := svc.SendMessage(context.Background(), Credentials{}, plainRequest("hi")) + var aerr *Error + if !errors.As(err, &aerr) || aerr.Code != CodeInvalidRequest { + t.Fatalf("err = %v, want a refusal", err) + } + if gw.calls() != 0 { + t.Fatal("a refused caller reached the gateway") + } +} + +// A resolver that answers with no scope is a resolver that did not +// really answer. Letting that through would run the message with an +// empty scope, which every store call refuses anyway, several layers +// further down and with a stranger error. +func TestAResolverWithNoScopeIsRefused(t *testing.T) { + gw := newFakeGateway() + svc := testService(t, gw, staticResolver{peer: Peer{Node: "peer.example"}}) + + if _, err := svc.SendMessage(context.Background(), Credentials{}, plainRequest("hi")); err == nil { + t.Fatal("want a refusal") + } + if gw.calls() != 0 { + t.Fatal("a peer with no scope reached the gateway") + } +} + +// A caller must not be able to enumerate which agents exist by probing +// names, so an unknown tenant is the same answer as an unknown task. +func TestUnknownTenantIsTaskNotFound(t *testing.T) { + gw := newFakeGateway() + svc := testService(t, gw, okResolver()) + + req := plainRequest("hi") + req.Tenant = "does-not-exist" + + _, err := svc.SendMessage(context.Background(), Credentials{}, req) + var aerr *Error + if !errors.As(err, &aerr) || aerr.Code != CodeTaskNotFound { + t.Fatalf("err = %v, want TaskNotFoundError", err) + } + if containsFold(aerr.Message, "agent") { + t.Fatalf("the refusal tells the caller what kind of thing was missing: %q", aerr.Message) + } +} + +func TestMissingTenantIsInvalidParams(t *testing.T) { + svc := testService(t, newFakeGateway(), okResolver()) + req := plainRequest("hi") + req.Tenant = "" + + _, err := svc.SendMessage(context.Background(), Credentials{}, req) + var aerr *Error + if !errors.As(err, &aerr) || aerr.Code != CodeInvalidParams { + t.Fatalf("err = %v, want InvalidParams", err) + } +} + +// An informative is filed, not worked on, so there is no task to return. +func TestInformativeReturnsAMessageAcknowledgement(t *testing.T) { + gw := newFakeGateway() + svc := testService(t, gw, okResolver()) + + req := plainRequest("the build is green") + req.Message.Metadata = map[string]any{FIPAExtensionURI: map[string]any{"performative": "inform"}} + + res, err := svc.SendMessage(context.Background(), Credentials{}, req) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if res.Task != nil { + t.Fatalf("an inform started a task: %+v", res.Task) + } + if res.Message == nil || res.Message.ContextID == "" { + t.Fatalf("an acknowledgement must name the conversation it joined: %+v", res.Message) + } +} + +// A directive is work, so the peer gets a task it can poll and cancel. +func TestDirectiveReturnsATaskBackedByTheRun(t *testing.T) { + gw := newFakeGateway() + runID := id.NewAgentRunID() + convID := id.NewConversationID() + gw.sendResult = &a2a.SendResult{ + MessageID: id.NewMessageID(), + ConversationID: convID, + Deliveries: []a2a.DeliveryOutcome{{Receiver: a2a.Address{Agent: "worker"}, Status: a2a.DeliveryQueued}}, + } + gw.addRun(&run.Run{ID: runID, State: run.StateRunning}) + svc := testService(t, gw, okResolver()) + + res, err := svc.SendMessage(context.Background(), Credentials{}, plainRequest("do the thing")) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if res.Task == nil { + t.Fatal("a request must come back as a task the peer can follow") + } + if res.Task.ContextID != convID.String() { + t.Errorf("contextId = %q, want the conversation id", res.Task.ContextID) + } + // The run has not been started by the time the send returns, so the + // task is submitted rather than working. Claiming WORKING for a run + // that has not begun would be a small lie with no upside. + if res.Task.Status.State != TaskStateSubmitted { + t.Errorf("state = %s, want submitted", res.Task.Status.State) + } +} + +func TestGetTaskProjectsTheRun(t *testing.T) { + gw := newFakeGateway() + runID := id.NewAgentRunID() + gw.addRun(&run.Run{ID: runID, State: run.StateCompleted, Output: "all done"}) + svc := testService(t, gw, okResolver()) + + task, err := svc.GetTask(context.Background(), Credentials{}, GetTaskRequest{Tenant: "worker", ID: runID.String()}) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if task.Status.State != TaskStateCompleted || len(task.Artifacts) != 1 { + t.Fatalf("task = %+v", task) + } +} + +func TestGetTaskOfSomethingElseIsNotFound(t *testing.T) { + svc := testService(t, newFakeGateway(), okResolver()) + + _, err := svc.GetTask(context.Background(), Credentials{}, GetTaskRequest{Tenant: "worker", ID: id.NewAgentRunID().String()}) + var aerr *Error + if !errors.As(err, &aerr) || aerr.Code != CodeTaskNotFound { + t.Fatalf("err = %v, want TaskNotFoundError", err) + } +} + +func TestGetTaskRejectsAnIDThatIsNotARunID(t *testing.T) { + svc := testService(t, newFakeGateway(), okResolver()) + + _, err := svc.GetTask(context.Background(), Credentials{}, GetTaskRequest{Tenant: "worker", ID: "not-an-id"}) + var aerr *Error + if !errors.As(err, &aerr) || aerr.Code != CodeTaskNotFound { + t.Fatalf("err = %v, want TaskNotFoundError rather than a parse error", err) + } +} + +func TestCancelTaskOfATerminalRunIsNotCancelable(t *testing.T) { + gw := newFakeGateway() + runID := id.NewAgentRunID() + gw.addRun(&run.Run{ID: runID, State: run.StateCompleted}) + svc := testService(t, gw, okResolver()) + + _, err := svc.CancelTask(context.Background(), Credentials{}, CancelTaskRequest{Tenant: "worker", ID: runID.String()}) + var aerr *Error + if !errors.As(err, &aerr) || aerr.Code != CodeTaskNotCancelable { + t.Fatalf("err = %v, want TaskNotCancelableError", err) + } +} + +func TestCancelTaskCancelsARunningRun(t *testing.T) { + gw := newFakeGateway() + runID := id.NewAgentRunID() + gw.addRun(&run.Run{ID: runID, State: run.StateRunning}) + svc := testService(t, gw, okResolver()) + + task, err := svc.CancelTask(context.Background(), Credentials{}, CancelTaskRequest{Tenant: "worker", ID: runID.String()}) + if err != nil { + t.Fatalf("CancelTask: %v", err) + } + if task.Status.State != TaskStateCanceled { + t.Fatalf("state = %s, want canceled", task.Status.State) + } +} + +func TestListTasksProjectsEveryRun(t *testing.T) { + gw := newFakeGateway() + gw.addRun(&run.Run{ID: id.NewAgentRunID(), State: run.StateRunning}) + gw.addRun(&run.Run{ID: id.NewAgentRunID(), State: run.StateCompleted}) + svc := testService(t, gw, okResolver()) + + res, err := svc.ListTasks(context.Background(), Credentials{}, ListTasksRequest{Tenant: "worker"}) + if err != nil { + t.Fatalf("ListTasks: %v", err) + } + if len(res.Tasks) != 2 { + t.Fatalf("got %d tasks, want 2", len(res.Tasks)) + } +} + +// Every method authenticates. A method that forgot would be an +// unauthenticated read of somebody else's tasks. +func TestEveryMethodRefusesAnUnauthenticatedCaller(t *testing.T) { + svc := testService(t, newFakeGateway(), staticResolver{err: errors.New("nope")}) + ctx, cred := context.Background(), Credentials{} + + if _, err := svc.SendMessage(ctx, cred, plainRequest("x")); err == nil { + t.Error("SendMessage let an unauthenticated caller through") + } + if _, err := svc.GetTask(ctx, cred, GetTaskRequest{ID: "x"}); err == nil { + t.Error("GetTask let an unauthenticated caller through") + } + if _, err := svc.ListTasks(ctx, cred, ListTasksRequest{}); err == nil { + t.Error("ListTasks let an unauthenticated caller through") + } + if _, err := svc.CancelTask(ctx, cred, CancelTaskRequest{ID: "x"}); err == nil { + t.Error("CancelTask let an unauthenticated caller through") + } +} + +func TestNewServiceNeedsAGatewayAndAResolver(t *testing.T) { + if NewService(nil, okResolver(), Options{}) != nil { + t.Error("a service with no gateway must not build") + } + if NewService(newFakeGateway(), nil, Options{}) != nil { + t.Error("a service with no resolver would be an open door") + } + _ = cortex.Scope{} +} + +// A peer polls with the handle it was given at send time, which is a +// delivery id, and it must resolve to whatever became of the message. +func TestGetTaskFollowsADeliveryToItsRun(t *testing.T) { + gw := newFakeGateway() + runID := id.NewAgentRunID() + dlvID := id.NewDeliveryID() + gw.addRun(&run.Run{ID: runID, State: run.StateCompleted, Output: "answered"}) + gw.addDelivery(&a2a.Delivery{ID: dlvID, State: a2a.DeliveryDelivered, RunID: runID}) + svc := testService(t, gw, okResolver()) + + task, err := svc.GetTask(context.Background(), Credentials{}, GetTaskRequest{Tenant: "worker", ID: dlvID.String()}) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if task.Status.State != TaskStateCompleted { + t.Fatalf("state = %s, want completed", task.Status.State) + } + if len(task.Artifacts) != 1 || task.Artifacts[0].Parts[0].Text != "answered" { + t.Fatalf("artifacts = %+v", task.Artifacts) + } +} + +// A delivery that has not started a run yet is a real state, not a +// missing task. A peer polling right after sending must not be told its +// task does not exist. +func TestGetTaskOfAQueuedDeliveryIsSubmitted(t *testing.T) { + gw := newFakeGateway() + dlvID := id.NewDeliveryID() + gw.addDelivery(&a2a.Delivery{ID: dlvID, State: a2a.DeliveryQueued}) + svc := testService(t, gw, okResolver()) + + task, err := svc.GetTask(context.Background(), Credentials{}, GetTaskRequest{Tenant: "worker", ID: dlvID.String()}) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if task.Status.State != TaskStateSubmitted { + t.Fatalf("state = %s, want submitted", task.Status.State) + } +} + +func TestGetTaskOfAFailedDeliveryIsFailed(t *testing.T) { + gw := newFakeGateway() + dlvID := id.NewDeliveryID() + gw.addDelivery(&a2a.Delivery{ID: dlvID, State: a2a.DeliveryFailed, Error: "peer unreachable"}) + svc := testService(t, gw, okResolver()) + + task, err := svc.GetTask(context.Background(), Credentials{}, GetTaskRequest{Tenant: "worker", ID: dlvID.String()}) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if task.Status.State != TaskStateFailed { + t.Fatalf("state = %s, want failed", task.Status.State) + } +} + +// The handle a peer is handed must be one it can poll with. A task id +// that resolves to nothing is worse than no task at all. +func TestTheTaskIDFromSendResolves(t *testing.T) { + gw := newFakeGateway() + dlvID := id.NewDeliveryID() + gw.sendResult = &a2a.SendResult{ + MessageID: id.NewMessageID(), + ConversationID: id.NewConversationID(), + Deliveries: []a2a.DeliveryOutcome{{ + Receiver: a2a.Address{Agent: "worker"}, Status: a2a.DeliveryQueued, DeliveryID: dlvID, + }}, + } + gw.addDelivery(&a2a.Delivery{ID: dlvID, State: a2a.DeliveryQueued}) + svc := testService(t, gw, okResolver()) + + res, err := svc.SendMessage(context.Background(), Credentials{}, plainRequest("do the thing")) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if res.Task == nil { + t.Fatal("a request must come back as a task") + } + if _, err := svc.GetTask(context.Background(), Credentials{}, GetTaskRequest{ + Tenant: "worker", ID: res.Task.ID, + }); err != nil { + t.Fatalf("the id handed back by SendMessage does not resolve: %v", err) + } +} diff --git a/store/mongo/a2a.go b/store/mongo/a2a.go index b80436b..dc82c1b 100644 --- a/store/mongo/a2a.go +++ b/store/mongo/a2a.go @@ -242,6 +242,15 @@ func (s *Store) CreateDelivery(ctx context.Context, d *a2a.Delivery) error { return nil } +// GetDelivery reads one delivery document within the caller's scope. +func (s *Store) GetDelivery(ctx context.Context, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + return s.getDelivery(ctx, scope, deliveryID) +} + func (s *Store) UpdateDelivery(ctx context.Context, d *a2a.Delivery) error { scope := cortex.ScopeFromContext(ctx) if scope.IsZero() { diff --git a/store/postgres/a2a.go b/store/postgres/a2a.go index 4b4efa4..d3d61bb 100644 --- a/store/postgres/a2a.go +++ b/store/postgres/a2a.go @@ -246,6 +246,15 @@ func (s *Store) CreateDelivery(ctx context.Context, d *a2a.Delivery) error { return nil } +// GetDelivery reads one delivery row within the caller's scope. +func (s *Store) GetDelivery(ctx context.Context, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + return s.getDelivery(ctx, scope, deliveryID) +} + func (s *Store) UpdateDelivery(ctx context.Context, d *a2a.Delivery) error { scope := cortex.ScopeFromContext(ctx) if scope.IsZero() { diff --git a/store/sqlite/a2a.go b/store/sqlite/a2a.go index e7c1832..7c025a0 100644 --- a/store/sqlite/a2a.go +++ b/store/sqlite/a2a.go @@ -244,6 +244,15 @@ func (s *Store) CreateDelivery(ctx context.Context, d *a2a.Delivery) error { return nil } +// GetDelivery reads one delivery row within the caller's scope. +func (s *Store) GetDelivery(ctx context.Context, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + return s.getDelivery(ctx, scope, deliveryID) +} + func (s *Store) UpdateDelivery(ctx context.Context, d *a2a.Delivery) error { scope := cortex.ScopeFromContext(ctx) if scope.IsZero() { From 0850c0c94bf959ce3511cccb318327fafdd90473 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:37:05 -0500 Subject: [PATCH 35/50] feat(a2aremote): add the JSON-RPC binding and card serving --- a2aremote/fakes_test.go | 2 + a2aremote/jsonrpc.go | 261 ++++++++++++++++++++++++++++++++++++++ a2aremote/jsonrpc_test.go | 242 +++++++++++++++++++++++++++++++++++ 3 files changed, 505 insertions(+) create mode 100644 a2aremote/jsonrpc.go create mode 100644 a2aremote/jsonrpc_test.go diff --git a/a2aremote/fakes_test.go b/a2aremote/fakes_test.go index 2b781b2..532917e 100644 --- a/a2aremote/fakes_test.go +++ b/a2aremote/fakes_test.go @@ -13,6 +13,8 @@ import ( "github.com/xraph/cortex/skill" ) +var errTest = errors.New("refused for the test") + func testScope() cortex.Scope { return cortex.Scope{Levels: []cortex.Level{{Key: "tenant", Value: "resolved"}}} } diff --git a/a2aremote/jsonrpc.go b/a2aremote/jsonrpc.go new file mode 100644 index 0000000..a22fa10 --- /dev/null +++ b/a2aremote/jsonrpc.go @@ -0,0 +1,261 @@ +package a2aremote + +import ( + "context" + "encoding/json" + "errors" + "io" + "net/http" + "strings" + + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/skill" +) + +// ProtocolVersion is the A2A version this package implements. A request +// that pins a different one is refused rather than answered on a guess. +const ProtocolVersion = "1.0.0" + +// versionHeader carries the protocol version a caller expects. +const versionHeader = "A2A-Version" + +// maxRequestBytes caps an inbound body. +const maxRequestBytes = 4 << 20 + +// The JSON-RPC method names. These are the 1.0 spellings; the 0.x ones +// (message/send, tasks/get) are deliberately not served, because +// answering them would tell a client this server speaks a version it +// does not. +const ( + MethodSendMessage = "SendMessage" + MethodSendStreamingMessage = "SendStreamingMessage" + MethodGetTask = "GetTask" + MethodListTasks = "ListTasks" + MethodCancelTask = "CancelTask" + MethodSubscribeToTask = "SubscribeToTask" + MethodGetExtendedCard = "GetExtendedAgentCard" +) + +type rpcRequest struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Method string `json:"method"` + Params json.RawMessage `json:"params,omitempty"` +} + +type rpcResponse struct { + JSONRPC string `json:"jsonrpc"` + ID json.RawMessage `json:"id,omitempty"` + Result any `json:"result,omitempty"` + Error *Error `json:"error,omitempty"` +} + +// JSONRPCHandler serves the JSON-RPC binding. +// +// It decodes, dispatches and encodes. Every decision about what an +// operation MEANS lives in Service, which is what lets the other two +// bindings behave identically without repeating a single rule. +func (s *Service) JSONRPCHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + w.Header().Set("Allow", http.MethodPost) + http.Error(w, "JSON-RPC requests are POSTed", http.StatusMethodNotAllowed) + return + } + + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBytes)) + if err != nil { + writeRPC(w, rpcResponse{JSONRPC: "2.0", Error: ErrParse()}) + return + } + + var req rpcRequest + if err := json.Unmarshal(body, &req); err != nil { + writeRPC(w, rpcResponse{JSONRPC: "2.0", Error: ErrParse()}) + return + } + // A request with no id is a notification: it is acted on and it + // gets no response, which JSON-RPC is explicit about. + notification := len(req.ID) == 0 + + resp := s.dispatch(r, req) + if notification { + w.WriteHeader(http.StatusNoContent) + return + } + writeRPC(w, resp) + }) +} + +func (s *Service) dispatch(r *http.Request, req rpcRequest) rpcResponse { + out := rpcResponse{JSONRPC: "2.0", ID: req.ID} + + if req.JSONRPC != "2.0" { + out.Error = ErrInvalidRequest(`the "jsonrpc" member must be "2.0"`) + return out + } + if v := r.Header.Get(versionHeader); v != "" && v != ProtocolVersion { + out.Error = ErrVersionNotSupported(v) + return out + } + + cred := credentialsOf(r) + result, err := s.call(r.Context(), cred, req.Method, req.Params) + if err != nil { + out.Error = asProtocolError(err) + return out + } + out.Result = result + return out +} + +// call routes one method. The parameter decoding lives here rather than +// in each service method, so the service takes typed requests and never +// sees a wire format. +func (s *Service) call(ctx context.Context, cred Credentials, method string, params json.RawMessage) (any, error) { + switch method { + case MethodSendMessage: + var req SendMessageRequest + if err := decodeParams(params, &req); err != nil { + return nil, err + } + return s.SendMessage(ctx, cred, req) + + case MethodGetTask: + var req GetTaskRequest + if err := decodeParams(params, &req); err != nil { + return nil, err + } + return s.GetTask(ctx, cred, req) + + case MethodListTasks: + var req ListTasksRequest + if err := decodeParams(params, &req); err != nil { + return nil, err + } + return s.ListTasks(ctx, cred, req) + + case MethodCancelTask: + var req CancelTaskRequest + if err := decodeParams(params, &req); err != nil { + return nil, err + } + return s.CancelTask(ctx, cred, req) + + case MethodSendStreamingMessage, MethodSubscribeToTask: + // Declared unsupported in the card too, so a client that read the + // card never gets here. + return nil, ErrUnsupportedOperation(method) + + case MethodGetExtendedCard: + return nil, ErrExtendedCardNotConfigured() + + default: + return nil, ErrMethodNotFound(method) + } +} + +func decodeParams(params json.RawMessage, dest any) error { + if len(params) == 0 { + return nil + } + if err := json.Unmarshal(params, dest); err != nil { + return ErrInvalidParams("params could not be decoded: " + err.Error()) + } + return nil +} + +// asProtocolError keeps a service error's code and turns anything else +// into an internal error without leaking its text. A peer must be able +// to tell "no such task" from "the server broke", and must not learn the +// shape of our internals from an error string. +func asProtocolError(err error) *Error { + var perr *Error + if errors.As(err, &perr) { + return perr + } + return ErrInternal("the request could not be completed") +} + +func writeRPC(w http.ResponseWriter, resp rpcResponse) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set(versionHeader, ProtocolVersion) + // The status stays 200 even for an error: a JSON-RPC error is a + // well-formed response, and a client reading HTTP status instead of + // the error member would see a transport failure that did not happen. + _ = json.NewEncoder(w).Encode(resp) +} + +// credentialsOf lifts what a resolver can authenticate on out of the +// request. +func credentialsOf(r *http.Request) Credentials { + return Credentials{ + Headers: r.Header, + RemoteAddr: r.RemoteAddr, + TLS: r.TLS, + } +} + +// CardHandler serves agent cards. +// +// Only exposed agents are served, and an unexposed one is a 404 rather +// than a 403: a card is public, so the fact that an agent exists here at +// all is disclosure. +func (s *Service) CardHandler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("GET /agents/{name}"+WellKnownCardPath, func(w http.ResponseWriter, r *http.Request) { + s.writeCard(w, r, r.PathValue("name")) + }) + if s.opts.DefaultAgent != "" { + mux.HandleFunc("GET "+WellKnownCardPath, func(w http.ResponseWriter, r *http.Request) { + s.writeCard(w, r, s.opts.DefaultAgent) + }) + } + return mux +} + +func (s *Service) writeCard(w http.ResponseWriter, r *http.Request, name string) { + if !s.exposed(name) { + http.NotFound(w, r) + return + } + // A card is read without credentials, so it is built under no scope. + // That is why the agent lookup below cannot be scoped, and why + // exposure is the only gate on it. + a, err := s.gw.GetAgentByName(r.Context(), name) + if err != nil { + http.NotFound(w, r) + return + } + + opts := s.opts.Card + card := BuildCard(a, s.skillsOf(r.Context(), a), opts) + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(card) +} + +func (s *Service) exposed(name string) bool { + for _, n := range s.opts.Exposed { + if strings.EqualFold(n, name) { + return true + } + } + return false +} + +// skillsOf resolves an agent's inline skills for its card, skipping any +// the store cannot produce: a card missing one skill is better than no +// card at all. +func (s *Service) skillsOf(ctx context.Context, a *agent.Config) []*skill.Skill { + out := make([]*skill.Skill, 0, len(a.InlineSkills)) + for _, name := range a.InlineSkills { + sk, err := s.gw.GetSkillByName(ctx, name) + if err != nil { + continue + } + out = append(out, sk) + } + return out +} diff --git a/a2aremote/jsonrpc_test.go b/a2aremote/jsonrpc_test.go new file mode 100644 index 0000000..09d6601 --- /dev/null +++ b/a2aremote/jsonrpc_test.go @@ -0,0 +1,242 @@ +package a2aremote + +import ( + "bytes" + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" +) + +func post(t *testing.T, h http.Handler, body string) map[string]any { + t.Helper() + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString(body)) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Body.Len() == 0 { + return nil + } + var out map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("response is not JSON: %q", rec.Body.String()) + } + return out +} + +func errorCode(t *testing.T, resp map[string]any) int { + t.Helper() + e, ok := resp["error"].(map[string]any) + if !ok { + t.Fatalf("response carries no error: %+v", resp) + } + code, ok := e["code"].(float64) + if !ok { + t.Fatalf("error carries no code: %+v", e) + } + return int(code) +} + +func TestJSONRPCSendMessage(t *testing.T) { + gw := newFakeGateway() + h := testService(t, gw, okResolver()).JSONRPCHandler() + + resp := post(t, h, `{ + "jsonrpc":"2.0","id":1,"method":"SendMessage", + "params":{"tenant":"worker","message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hello"}]}} + }`) + + if resp["jsonrpc"] != "2.0" { + t.Errorf("jsonrpc = %v, want 2.0", resp["jsonrpc"]) + } + if resp["id"] != float64(1) { + t.Errorf("id = %v, want it echoed", resp["id"]) + } + if _, ok := resp["result"]; !ok { + t.Fatalf("no result: %+v", resp) + } + if gw.calls() != 1 { + t.Errorf("gateway called %d times, want 1", gw.calls()) + } +} + +func TestJSONRPCGetTask(t *testing.T) { + gw := newFakeGateway() + runID := id.NewAgentRunID() + gw.addRun(&run.Run{ID: runID, State: run.StateCompleted, Output: "done"}) + h := testService(t, gw, okResolver()).JSONRPCHandler() + + resp := post(t, h, `{"jsonrpc":"2.0","id":"abc","method":"GetTask","params":{"tenant":"worker","id":"`+runID.String()+`"}}`) + result, ok := resp["result"].(map[string]any) + if !ok { + t.Fatalf("no result: %+v", resp) + } + if result["id"] != runID.String() { + t.Errorf("task id = %v", result["id"]) + } +} + +func TestJSONRPCUnknownMethod(t *testing.T) { + h := testService(t, newFakeGateway(), okResolver()).JSONRPCHandler() + resp := post(t, h, `{"jsonrpc":"2.0","id":1,"method":"DoTheThing","params":{}}`) + if got := errorCode(t, resp); got != CodeMethodNotFound { + t.Fatalf("code = %d, want %d", got, CodeMethodNotFound) + } +} + +// The 0.x spellings must not work: answering them would leave a client +// talking a protocol version this server does not actually implement. +func TestJSONRPCRefusesThe0xMethodNames(t *testing.T) { + h := testService(t, newFakeGateway(), okResolver()).JSONRPCHandler() + for _, method := range []string{"message/send", "tasks/get", "tasks/cancel"} { + resp := post(t, h, `{"jsonrpc":"2.0","id":1,"method":"`+method+`","params":{}}`) + if got := errorCode(t, resp); got != CodeMethodNotFound { + t.Errorf("%s: code = %d, want method not found", method, got) + } + } +} + +func TestJSONRPCMalformedBody(t *testing.T) { + h := testService(t, newFakeGateway(), okResolver()).JSONRPCHandler() + resp := post(t, h, `{not json`) + if got := errorCode(t, resp); got != CodeParse { + t.Fatalf("code = %d, want %d", got, CodeParse) + } +} + +func TestJSONRPCWrongVersionField(t *testing.T) { + h := testService(t, newFakeGateway(), okResolver()).JSONRPCHandler() + resp := post(t, h, `{"jsonrpc":"1.0","id":1,"method":"SendMessage","params":{}}`) + if got := errorCode(t, resp); got != CodeInvalidRequest { + t.Fatalf("code = %d, want %d", got, CodeInvalidRequest) + } +} + +// A service error keeps its code through the binding. A binding that +// flattened everything to -32603 would leave a peer unable to tell "no +// such task" from "the server broke". +func TestJSONRPCServiceErrorKeepsItsCode(t *testing.T) { + h := testService(t, newFakeGateway(), okResolver()).JSONRPCHandler() + resp := post(t, h, `{"jsonrpc":"2.0","id":1,"method":"GetTask","params":{"tenant":"worker","id":"arun_notreal"}}`) + if got := errorCode(t, resp); got != CodeTaskNotFound { + t.Fatalf("code = %d, want %d", got, CodeTaskNotFound) + } +} + +func TestJSONRPCUnauthenticated(t *testing.T) { + h := testService(t, newFakeGateway(), staticResolver{err: errTest}).JSONRPCHandler() + resp := post(t, h, `{"jsonrpc":"2.0","id":1,"method":"ListTasks","params":{"tenant":"worker"}}`) + if got := errorCode(t, resp); got != CodeInvalidRequest { + t.Fatalf("code = %d, want a refusal", got) + } +} + +// A notification carries no id and gets no response, per JSON-RPC. +func TestJSONRPCNotificationGetsNoBody(t *testing.T) { + gw := newFakeGateway() + h := testService(t, gw, okResolver()).JSONRPCHandler() + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString( + `{"jsonrpc":"2.0","method":"SendMessage","params":{"tenant":"worker","message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hi"}]}}}`)) + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if rec.Code != http.StatusNoContent { + t.Errorf("status = %d, want 204 for a notification", rec.Code) + } + if rec.Body.Len() != 0 { + t.Errorf("a notification got a body: %q", rec.Body.String()) + } + if gw.calls() != 1 { + t.Errorf("a notification must still be acted on, calls = %d", gw.calls()) + } +} + +func TestJSONRPCRejectsGET(t *testing.T) { + h := testService(t, newFakeGateway(), okResolver()).JSONRPCHandler() + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/", http.NoBody)) + if rec.Code != http.StatusMethodNotAllowed { + t.Fatalf("status = %d, want 405", rec.Code) + } +} + +func TestJSONRPCRejectsAnUnknownProtocolVersion(t *testing.T) { + h := testService(t, newFakeGateway(), okResolver()).JSONRPCHandler() + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString( + `{"jsonrpc":"2.0","id":1,"method":"ListTasks","params":{"tenant":"worker"}}`)) + req.Header.Set("A2A-Version", "0.1.0") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("response is not JSON: %q", rec.Body.String()) + } + if got := errorCode(t, resp); got != CodeVersionNotSupported { + t.Fatalf("code = %d, want %d", got, CodeVersionNotSupported) + } +} + +// The credentials a resolver sees must be the request's own. +func TestJSONRPCPassesCredentialsToTheResolver(t *testing.T) { + var seen Credentials + res := ResolverFunc(func(_ context.Context, cred Credentials) (Peer, error) { + seen = cred + return Peer{Node: "peer.example", Scope: testScope()}, nil + }) + h := testService(t, newFakeGateway(), res).JSONRPCHandler() + + req := httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString( + `{"jsonrpc":"2.0","id":1,"method":"ListTasks","params":{"tenant":"worker"}}`)) + req.Header.Set("Authorization", "Bearer sekrit") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + if seen.Header("authorization") != "Bearer sekrit" { + t.Fatalf("the resolver did not see the request's credentials: %+v", seen.Headers) + } + if seen.RemoteAddr == "" { + t.Error("the resolver should see where the request came from") + } +} + +func TestCardHandlerServesExposedAgentsOnly(t *testing.T) { + gw := newFakeGateway() + gw.agents["secret"] = &agent.Config{ID: id.NewAgentID(), Name: "secret"} + svc := NewService(gw, okResolver(), Options{ + Card: CardOptions{BaseURL: "https://cortex.example/a2a"}, + Exposed: []string{"worker"}, + DefaultAgent: "worker", + }) + h := svc.CardHandler() + + rec := httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/agents/worker"+WellKnownCardPath, http.NoBody)) + if rec.Code != http.StatusOK { + t.Fatalf("exposed agent: status = %d, body = %s", rec.Code, rec.Body) + } + + // A card is public, so exposure is opt-in. An agent nobody exposed is + // not merely undocumented, it is not there. + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/agents/secret"+WellKnownCardPath, http.NoBody)) + if rec.Code != http.StatusNotFound { + t.Fatalf("unexposed agent: status = %d, want 404", rec.Code) + } + + // The default agent is also reachable at the root path, so plain + // discovery finds something. + rec = httptest.NewRecorder() + h.ServeHTTP(rec, httptest.NewRequest(http.MethodGet, WellKnownCardPath, http.NoBody)) + if rec.Code != http.StatusOK { + t.Fatalf("root card: status = %d", rec.Code) + } +} From 60fe656fa08425b952de8398574811f87a445b8a Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:40:05 -0500 Subject: [PATCH 36/50] feat(a2aremote): add the outbound client Credentials go on a wrapping transport rather than per call, because the card fetch needs them too: a peer that gates its card would otherwise be undiscoverable, and the failure would read as a missing card rather than a missing token. --- a2aremote/client.go | 381 +++++++++++++++++++++++++++++++++++++++ a2aremote/client_test.go | 323 +++++++++++++++++++++++++++++++++ a2aremote/jsonrpc.go | 11 +- 3 files changed, 713 insertions(+), 2 deletions(-) create mode 100644 a2aremote/client.go create mode 100644 a2aremote/client_test.go diff --git a/a2aremote/client.go b/a2aremote/client.go new file mode 100644 index 0000000..4786a22 --- /dev/null +++ b/a2aremote/client.go @@ -0,0 +1,381 @@ +package a2aremote + +import ( + "bytes" + "context" + "encoding/json" + "fmt" + "io" + "net/http" + "strings" + "sync" + "time" + + "github.com/xraph/cortex/a2a" +) + +// Defaults for a client. +const ( + DefaultPollInterval = 2 * time.Second + DefaultPollTimeout = 2 * time.Minute +) + +// PeerConfig is one remote agent host this engine will talk to. +// +// Peers are configuration rather than data on purpose. An agent's own +// output cannot introduce one, so a prompt-injected agent can at worst +// misuse a peer that was already trusted. +type PeerConfig struct { + // Node is the address suffix agents write: worker@. + Node string + // BaseURL is where the peer's agent card and endpoint live. + BaseURL string + // Header carries whatever the peer authenticates on. + Header http.Header +} + +// ReplySink is how an answer re-enters cortex. The engine wires it to +// Bus.Send, so a remote reply travels the same path a local one does and +// resolves a waiting ask the same way. +type ReplySink func(ctx context.Context, p a2a.SendParams) error + +// ClientOptions tunes the outbound client. +type ClientOptions struct { + HTTPClient *http.Client + PollInterval time.Duration + PollTimeout time.Duration +} + +// Client carries cortex envelopes to remote A2A agents. It satisfies +// a2a.Transport, which is the seam the bus already routes through. +type Client struct { + peers map[string]PeerConfig + sink ReplySink + http *http.Client + poll time.Duration + maxAge time.Duration + + mu sync.Mutex + cards map[string]AgentCard + clients map[string]*http.Client +} + +// NewClient builds a client over the given peers. +func NewClient(peers []PeerConfig, sink ReplySink, opts ClientOptions) *Client { + c := &Client{ + peers: make(map[string]PeerConfig, len(peers)), + sink: sink, + http: opts.HTTPClient, + poll: opts.PollInterval, + maxAge: opts.PollTimeout, + cards: map[string]AgentCard{}, + clients: map[string]*http.Client{}, + } + for _, p := range peers { + c.peers[p.Node] = p + } + if c.http == nil { + c.http = http.DefaultClient + } + if c.poll <= 0 { + c.poll = DefaultPollInterval + } + if c.maxAge <= 0 { + c.maxAge = DefaultPollTimeout + } + return c +} + +// Handles claims addresses at registered peers, and nothing else. +// +// This is the outbound half of the trust boundary: an agent that names a +// hostname nobody configured gets an unroutable error rather than a +// connection. +func (c *Client) Handles(addr a2a.Address) bool { + if addr.IsLocal() { + return false + } + _, ok := c.peers[addr.Node] + return ok +} + +// Deliver carries one envelope to a remote agent and feeds the answer +// back through the sink. +// +// A message with no ReplyWith is fire-and-forget: it is delivered and +// nothing comes back, because nobody is waiting for anything. +func (c *Client) Deliver(ctx context.Context, e *a2a.Envelope, receiver a2a.Address) error { + peer, ok := c.peers[receiver.Node] + if !ok { + return fmt.Errorf("no peer configured for %q", receiver.Node) + } + + endpoint, tenant, err := c.endpointFor(ctx, peer, receiver) + if err != nil { + return err + } + + res, err := c.sendMessage(ctx, peer, endpoint, SendMessageRequest{ + Tenant: tenant, + Message: MessageFromEnvelope(e), + SenderName: e.Sender.Agent, + }) + if err != nil { + return err + } + + // Nobody is waiting, so there is nothing to carry home. + if e.ReplyWith == "" { + return nil + } + + answer, err := c.answerOf(ctx, peer, endpoint, tenant, res, e) + if err != nil { + return err + } + if c.sink == nil { + return nil + } + return c.sink(ctx, a2a.SendParams{ + Sender: receiver, + Receivers: []a2a.Address{e.Sender}, + Performative: a2a.Inform, + Content: answer, + ConversationID: e.ConversationID, + InReplyTo: e.ReplyWith, + Protocol: e.Protocol, + Ontology: e.Ontology, + }) +} + +// answerOf turns a peer's response into the text the waiting agent gets. +// A task that has not finished is polled, bounded by the ask's own +// deadline, which is already the thing that turns silence into a +// readable failure. +func (c *Client) answerOf(ctx context.Context, peer PeerConfig, endpoint, tenant string, res *SendMessageResult, e *a2a.Envelope) (string, error) { + switch { + case res.Message != nil: + return textOf(res.Message.Parts), nil + case res.Task == nil: + return "", ErrInvalidAgentResponse("the peer returned neither a message nor a task") + } + + deadline := time.Now().Add(c.maxAge) + if e.ReplyBy != nil && e.ReplyBy.Before(deadline) { + deadline = *e.ReplyBy + } + + task := res.Task + for { + if task.Status.State.Terminal() { + return answerFromTask(task) + } + if time.Now().After(deadline) { + return "", fmt.Errorf("%s did not finish before the deadline", task.ID) + } + select { + case <-ctx.Done(): + return "", ctx.Err() + case <-time.After(c.poll): + } + + polled, err := c.getTask(ctx, peer, endpoint, GetTaskRequest{Tenant: tenant, ID: task.ID}) + if err != nil { + return "", err + } + task = polled + } +} + +func answerFromTask(task *Task) (string, error) { + switch task.Status.State { + case TaskStateCompleted: + parts := make([]Part, 0, len(task.Artifacts)) + for _, a := range task.Artifacts { + parts = append(parts, a.Parts...) + } + if text := textOf(parts); text != "" { + return text, nil + } + if task.Status.Message != nil { + return textOf(task.Status.Message.Parts), nil + } + return "", ErrInvalidAgentResponse("the peer completed the task with no output") + default: + // Failed, canceled or rejected. The peer's own words are more + // useful to the waiting agent than a status name. + if task.Status.Message != nil { + if text := textOf(task.Status.Message.Parts); text != "" { + return "", fmt.Errorf("the peer could not answer: %s", text) + } + } + return "", fmt.Errorf("the peer ended the task as %s", task.Status.State) + } +} + +// endpointFor reads the peer's card and picks a binding we speak. +func (c *Client) endpointFor(ctx context.Context, peer PeerConfig, receiver a2a.Address) (endpoint, tenant string, err error) { + card, err := c.card(ctx, peer) + if err != nil { + return "", "", err + } + for _, iface := range card.SupportedInterfaces { + if iface.ProtocolBinding != BindingJSONRPC { + continue + } + // The tenant the card declares wins over the agent name we were + // addressed with: the peer decides how it routes internally. + t := iface.Tenant + if t == "" { + t = receiver.Agent + } + return iface.URL, t, nil + } + return "", "", fmt.Errorf("%s speaks no binding this client supports", peer.Node) +} + +// card reads a peer's agent card, once per peer. +func (c *Client) card(ctx context.Context, peer PeerConfig) (AgentCard, error) { + c.mu.Lock() + cached, ok := c.cards[peer.Node] + c.mu.Unlock() + if ok { + return cached, nil + } + + card, err := FetchCard(ctx, c.clientFor(peer), peer.BaseURL) + if err != nil { + return AgentCard{}, err + } + c.mu.Lock() + c.cards[peer.Node] = card + c.mu.Unlock() + return card, nil +} + +// clientFor returns an HTTP client that presents this peer's +// credentials on every request. +// +// It wraps rather than setting headers per call, because the card fetch +// needs them too: a peer that gates its agent card behind credentials +// would otherwise be undiscoverable, and the failure would look like a +// missing card rather than a missing token. +func (c *Client) clientFor(peer PeerConfig) *http.Client { + if len(peer.Header) == 0 { + return c.http + } + c.mu.Lock() + defer c.mu.Unlock() + if existing, ok := c.clients[peer.Node]; ok { + return existing + } + + base := c.http.Transport + if base == nil { + base = http.DefaultTransport + } + wrapped := &http.Client{ + Timeout: c.http.Timeout, + Transport: headerTransport{base: base, header: peer.Header.Clone()}, + } + c.clients[peer.Node] = wrapped + return wrapped +} + +// headerTransport adds a fixed set of headers to every request. +type headerTransport struct { + base http.RoundTripper + header http.Header +} + +func (t headerTransport) RoundTrip(r *http.Request) (*http.Response, error) { + // The request is cloned rather than mutated: a RoundTripper must not + // modify the request it was handed. + clone := r.Clone(r.Context()) + for k, vs := range t.header { + for _, v := range vs { + clone.Header.Add(k, v) + } + } + return t.base.RoundTrip(clone) +} + +func (c *Client) sendMessage(ctx context.Context, peer PeerConfig, endpoint string, req SendMessageRequest) (*SendMessageResult, error) { + var out SendMessageResult + if err := c.rpc(ctx, peer, endpoint, MethodSendMessage, req, &out); err != nil { + return nil, err + } + return &out, nil +} + +func (c *Client) getTask(ctx context.Context, peer PeerConfig, endpoint string, req GetTaskRequest) (*Task, error) { + var out Task + if err := c.rpc(ctx, peer, endpoint, MethodGetTask, req, &out); err != nil { + return nil, err + } + return &out, nil +} + +// rpc performs one JSON-RPC call against a peer. +func (c *Client) rpc(ctx context.Context, peer PeerConfig, endpoint, method string, params, dest any) error { + body, err := json.Marshal(rpcRequest{JSONRPC: "2.0", ID: json.RawMessage(`1`), Method: method, Params: mustRaw(params)}) + if err != nil { + return fmt.Errorf("encode %s: %w", method, err) + } + + httpReq, err := http.NewRequestWithContext(ctx, http.MethodPost, endpoint, bytes.NewReader(body)) + if err != nil { + return fmt.Errorf("build %s request: %w", method, err) + } + httpReq.Header.Set("Content-Type", "application/json") + httpReq.Header.Set(versionHeader, ProtocolVersion) + + resp, err := c.clientFor(peer).Do(httpReq) + if err != nil { + return fmt.Errorf("call %s at %s: %w", method, peer.Node, err) + } + defer func() { _ = resp.Body.Close() }() + + if resp.StatusCode != http.StatusOK { + return fmt.Errorf("%s at %s returned %s", method, peer.Node, resp.Status) + } + + var envelope struct { + Result json.RawMessage `json:"result"` + Error *Error `json:"error"` + } + if err := json.NewDecoder(io.LimitReader(resp.Body, maxRequestBytes)).Decode(&envelope); err != nil { + return ErrInvalidAgentResponse("the peer's response was not JSON-RPC") + } + if envelope.Error != nil { + return envelope.Error + } + if len(envelope.Result) == 0 { + return ErrInvalidAgentResponse("the peer answered with no result") + } + if err := json.Unmarshal(envelope.Result, dest); err != nil { + return ErrInvalidAgentResponse("the peer's result did not decode: " + err.Error()) + } + return nil +} + +func mustRaw(v any) json.RawMessage { + b, err := json.Marshal(v) + if err != nil { + return json.RawMessage(`{}`) + } + return b +} + +func textOf(parts []Part) string { + var texts []string + for _, p := range parts { + if p.Text != "" { + texts = append(texts, p.Text) + } + } + return strings.Join(texts, "\n\n") +} + +// Compile-time proof that a client is a transport the bus can use. +var _ a2a.Transport = (*Client)(nil) diff --git a/a2aremote/client_test.go b/a2aremote/client_test.go new file mode 100644 index 0000000..d4da5c2 --- /dev/null +++ b/a2aremote/client_test.go @@ -0,0 +1,323 @@ +package a2aremote + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "sync" + "testing" + "time" + + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/id" +) + +// peerServer is a stand-in for a remote A2A agent: it serves a card and +// answers SendMessage and GetTask however the test tells it to. +type peerServer struct { + *httptest.Server + mu sync.Mutex + cardHits int + sends []SendMessageRequest + reply func(SendMessageRequest) (any, *Error) + getTask func(GetTaskRequest) (any, *Error) +} + +func newPeerServer(t *testing.T) *peerServer { + t.Helper() + p := &peerServer{} + mux := http.NewServeMux() + + mux.HandleFunc("GET "+WellKnownCardPath, func(w http.ResponseWriter, _ *http.Request) { + p.mu.Lock() + p.cardHits++ + p.mu.Unlock() + card := BuildCard(&agent.Config{Name: "specialist", Description: "answers things"}, nil, CardOptions{ + BaseURL: p.URL + "/rpc", + }) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(card) + }) + + mux.HandleFunc("POST /rpc", func(w http.ResponseWriter, r *http.Request) { + var req rpcRequest + _ = json.NewDecoder(r.Body).Decode(&req) + + var result any + var rpcErr *Error + switch req.Method { + case MethodSendMessage: + var sm SendMessageRequest + _ = json.Unmarshal(req.Params, &sm) + p.mu.Lock() + p.sends = append(p.sends, sm) + p.mu.Unlock() + if p.reply != nil { + result, rpcErr = p.reply(sm) + } else { + result = SendMessageResult{Message: &Message{ + MessageID: "m-reply", Role: RoleAgent, Parts: []Part{{Text: "an answer"}}, + }} + } + case MethodGetTask: + var gt GetTaskRequest + _ = json.Unmarshal(req.Params, >) + if p.getTask != nil { + result, rpcErr = p.getTask(gt) + } else { + rpcErr = ErrTaskNotFound(gt.ID) + } + default: + rpcErr = ErrMethodNotFound(req.Method) + } + + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: result, Error: rpcErr}) + }) + + p.Server = httptest.NewServer(mux) + t.Cleanup(p.Close) + return p +} + +func (p *peerServer) lastSend(t *testing.T) SendMessageRequest { + t.Helper() + p.mu.Lock() + defer p.mu.Unlock() + if len(p.sends) == 0 { + t.Fatal("the peer was never called") + } + return p.sends[len(p.sends)-1] +} + +type recordingSink struct { + mu sync.Mutex + params []a2a.SendParams +} + +func (s *recordingSink) fn() ReplySink { + return func(_ context.Context, p a2a.SendParams) error { + s.mu.Lock() + defer s.mu.Unlock() + s.params = append(s.params, p) + return nil + } +} + +func (s *recordingSink) last(t *testing.T) a2a.SendParams { + t.Helper() + s.mu.Lock() + defer s.mu.Unlock() + if len(s.params) == 0 { + t.Fatal("nothing was fed back into the bus") + } + return s.params[len(s.params)-1] +} + +func (s *recordingSink) count() int { + s.mu.Lock() + defer s.mu.Unlock() + return len(s.params) +} + +func testEnvelope(replyWith string) *a2a.Envelope { + return &a2a.Envelope{ + ID: id.NewMessageID(), ConversationID: id.NewConversationID(), + Performative: a2a.Request, Sender: a2a.Address{Agent: "planner"}, + Receivers: []a2a.Address{{Agent: "specialist", Node: "peer-b"}}, + Content: "what is the status?", ReplyWith: replyWith, + } +} + +// An agent must not be able to make cortex call a host nobody +// configured. This is the outbound half of the trust boundary. +func TestClientHandlesOnlyRegisteredPeers(t *testing.T) { + c := NewClient([]PeerConfig{{Node: "peer-b", BaseURL: "https://b.example"}}, nil, ClientOptions{}) + + if !c.Handles(a2a.Address{Agent: "x", Node: "peer-b"}) { + t.Error("a configured peer must be handled") + } + if c.Handles(a2a.Address{Agent: "x", Node: "evil.example"}) { + t.Error("an unconfigured hostname must not be handled") + } + if c.Handles(a2a.Address{Agent: "x"}) { + t.Error("a local address is not this transport's business") + } +} + +func TestDeliverSendsAndFeedsTheReplyBack(t *testing.T) { + peer := newPeerServer(t) + sink := &recordingSink{} + c := NewClient([]PeerConfig{{Node: "peer-b", BaseURL: peer.URL}}, sink.fn(), ClientOptions{}) + + e := testEnvelope("rw-1") + if err := c.Deliver(context.Background(), e, a2a.Address{Agent: "specialist", Node: "peer-b"}); err != nil { + t.Fatalf("Deliver: %v", err) + } + + sent := peer.lastSend(t) + if sent.Tenant != "specialist" { + t.Errorf("tenant = %q, want the agent name the card declared", sent.Tenant) + } + if sent.SenderName != "planner" { + t.Errorf("senderName = %q, want the local agent that spoke", sent.SenderName) + } + if len(sent.Message.Extensions) != 1 || sent.Message.Extensions[0] != FIPAExtensionURI { + t.Errorf("the outbound message did not declare the extension: %+v", sent.Message.Extensions) + } + + back := sink.last(t) + if back.Content != "an answer" { + t.Errorf("content = %q", back.Content) + } + if back.InReplyTo != "rw-1" { + t.Errorf("inReplyTo = %q, want the token the ask is waiting on", back.InReplyTo) + } + if back.ConversationID != e.ConversationID { + t.Errorf("the reply joined a different conversation") + } + if back.Sender.Node != "peer-b" { + t.Errorf("sender = %+v, want the remote peer", back.Sender) + } +} + +// A send that nobody is waiting on must not manufacture a reply. +func TestFireAndForgetDeliversNothingBack(t *testing.T) { + peer := newPeerServer(t) + sink := &recordingSink{} + c := NewClient([]PeerConfig{{Node: "peer-b", BaseURL: peer.URL}}, sink.fn(), ClientOptions{}) + + if err := c.Deliver(context.Background(), testEnvelope(""), a2a.Address{Agent: "specialist", Node: "peer-b"}); err != nil { + t.Fatalf("Deliver: %v", err) + } + if sink.count() != 0 { + t.Fatalf("%d replies fed back for a fire-and-forget send", sink.count()) + } +} + +func TestDeliverPollsANonTerminalTask(t *testing.T) { + peer := newPeerServer(t) + var polls int + peer.reply = func(SendMessageRequest) (any, *Error) { + return SendMessageResult{Task: &Task{ID: "dlv_1", Status: TaskStatus{State: TaskStateWorking}}}, nil + } + peer.getTask = func(GetTaskRequest) (any, *Error) { + polls++ + if polls < 2 { + return Task{ID: "dlv_1", Status: TaskStatus{State: TaskStateWorking}}, nil + } + return Task{ + ID: "dlv_1", + Status: TaskStatus{State: TaskStateCompleted}, + Artifacts: []Artifact{{ArtifactID: "a", Parts: []Part{{Text: "took a while, but done"}}}}, + }, nil + } + + sink := &recordingSink{} + c := NewClient([]PeerConfig{{Node: "peer-b", BaseURL: peer.URL}}, sink.fn(), + ClientOptions{PollInterval: time.Millisecond}) + + if err := c.Deliver(context.Background(), testEnvelope("rw-1"), a2a.Address{Agent: "specialist", Node: "peer-b"}); err != nil { + t.Fatalf("Deliver: %v", err) + } + if got := sink.last(t).Content; got != "took a while, but done" { + t.Fatalf("content = %q", got) + } +} + +func TestDeliverSurfacesAPeerError(t *testing.T) { + peer := newPeerServer(t) + peer.reply = func(SendMessageRequest) (any, *Error) { return nil, ErrTaskNotFound("worker") } + + sink := &recordingSink{} + c := NewClient([]PeerConfig{{Node: "peer-b", BaseURL: peer.URL}}, sink.fn(), ClientOptions{}) + + err := c.Deliver(context.Background(), testEnvelope("rw-1"), a2a.Address{Agent: "specialist", Node: "peer-b"}) + if err == nil { + t.Fatal("a peer error must surface, so the bus can fail the ask with it") + } + if sink.count() != 0 { + t.Fatal("a failed delivery must not feed a reply back") + } +} + +func TestDeliverToAnUnconfiguredPeerFails(t *testing.T) { + c := NewClient(nil, nil, ClientOptions{}) + if err := c.Deliver(context.Background(), testEnvelope("rw-1"), a2a.Address{Agent: "x", Node: "nowhere"}); err == nil { + t.Fatal("want an error for a peer that was never configured") + } +} + +func TestClientCachesTheAgentCard(t *testing.T) { + peer := newPeerServer(t) + c := NewClient([]PeerConfig{{Node: "peer-b", BaseURL: peer.URL}}, (&recordingSink{}).fn(), ClientOptions{}) + + for range 3 { + if err := c.Deliver(context.Background(), testEnvelope("rw-1"), a2a.Address{Agent: "specialist", Node: "peer-b"}); err != nil { + t.Fatalf("Deliver: %v", err) + } + } + peer.mu.Lock() + hits := peer.cardHits + peer.mu.Unlock() + if hits != 1 { + t.Fatalf("the card was fetched %d times, want 1", hits) + } +} + +// Whatever a peer authenticates on has to reach it, or every call is +// refused and nothing says why. +func TestClientPresentsItsCredentials(t *testing.T) { + var ( + mu sync.Mutex + seenRPC string + seenCard string + ) + var srv *httptest.Server + mux := http.NewServeMux() + + mux.HandleFunc("GET "+WellKnownCardPath, func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seenCard = r.Header.Get("Authorization") + mu.Unlock() + card := BuildCard(&agent.Config{Name: "specialist"}, nil, CardOptions{BaseURL: srv.URL + "/rpc"}) + w.Header().Set("Content-Type", "application/json") + _ = json.NewEncoder(w).Encode(card) + }) + mux.HandleFunc("POST /rpc", func(w http.ResponseWriter, r *http.Request) { + mu.Lock() + seenRPC = r.Header.Get("Authorization") + mu.Unlock() + var req rpcRequest + _ = json.NewDecoder(r.Body).Decode(&req) + _ = json.NewEncoder(w).Encode(rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: SendMessageResult{ + Message: &Message{MessageID: "m", Role: RoleAgent, Parts: []Part{{Text: "ok"}}}, + }}) + }) + + srv = httptest.NewServer(mux) + defer srv.Close() + + c := NewClient([]PeerConfig{{ + Node: "peer-b", BaseURL: srv.URL, + Header: http.Header{"Authorization": {"Bearer sekrit"}}, + }}, (&recordingSink{}).fn(), ClientOptions{}) + + if err := c.Deliver(context.Background(), testEnvelope("rw-1"), a2a.Address{Agent: "specialist", Node: "peer-b"}); err != nil { + t.Fatalf("Deliver: %v", err) + } + + mu.Lock() + defer mu.Unlock() + if seenRPC != "Bearer sekrit" { + t.Errorf("the RPC carried %q, want the peer's credentials", seenRPC) + } + // The card fetch carries them too. A peer that gates its card behind + // credentials would otherwise be undiscoverable, and the failure + // would read as a missing card rather than a missing token. + if seenCard != "Bearer sekrit" { + t.Errorf("the card request carried %q, want the peer's credentials", seenCard) + } +} diff --git a/a2aremote/jsonrpc.go b/a2aremote/jsonrpc.go index a22fa10..26089e9 100644 --- a/a2aremote/jsonrpc.go +++ b/a2aremote/jsonrpc.go @@ -183,7 +183,12 @@ func writeRPC(w http.ResponseWriter, resp rpcResponse) { // The status stays 200 even for an error: a JSON-RPC error is a // well-formed response, and a client reading HTTP status instead of // the error member would see a transport failure that did not happen. - _ = json.NewEncoder(w).Encode(resp) + // + // An encode failure means the connection went away mid-write, which + // there is nothing useful to do about and nobody left to tell. + if err := json.NewEncoder(w).Encode(resp); err != nil { + return + } } // credentialsOf lifts what a resolver can authenticate on out of the @@ -233,7 +238,9 @@ func (s *Service) writeCard(w http.ResponseWriter, r *http.Request, name string) card := BuildCard(a, s.skillsOf(r.Context(), a), opts) w.Header().Set("Content-Type", "application/json") - _ = json.NewEncoder(w).Encode(card) + if err := json.NewEncoder(w).Encode(card); err != nil { + return + } } func (s *Service) exposed(name string) bool { From af21e7a657e3b10de88c5c5ccb968b55097b4b99 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:41:03 -0500 Subject: [PATCH 37/50] feat(a2aremote): attach the remote transport to an engine --- a2aremote/gateway.go | 122 ++++++++++++++++++++++++++++++++++++++ a2aremote/gateway_test.go | 38 ++++++++++++ a2aremote/go.mod | 3 + a2aremote/go.sum | 36 +++++++++++ 4 files changed, 199 insertions(+) create mode 100644 a2aremote/gateway.go create mode 100644 a2aremote/gateway_test.go diff --git a/a2aremote/gateway.go b/a2aremote/gateway.go new file mode 100644 index 0000000..19e5dbc --- /dev/null +++ b/a2aremote/gateway.go @@ -0,0 +1,122 @@ +package a2aremote + +import ( + "context" + "errors" + "net/http" + + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/engine" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" + "github.com/xraph/cortex/skill" +) + +// ErrNoMessaging is returned when attaching to an engine that never +// turned messaging on. Without a bus there is nothing to deliver into +// and nothing to carry out. +var ErrNoMessaging = errors.New("cortex/a2aremote: the engine has no message bus: build it with engine.WithA2A") + +// engineGateway adapts an engine to Gateway. +// +// The adapter lives on this side of the seam on purpose: the core module +// keeps knowing nothing about A2A, and the direction of the dependency +// stays one way. +type engineGateway struct{ eng *engine.Engine } + +// EngineGateway wraps an engine as the Gateway this package needs. +func EngineGateway(eng *engine.Engine) Gateway { return engineGateway{eng: eng} } + +func (g engineGateway) SendMessage(ctx context.Context, p a2a.SendParams) (*a2a.SendResult, error) { + return g.eng.SendMessage(ctx, p) +} + +func (g engineGateway) GetRun(ctx context.Context, runID id.AgentRunID) (*run.Run, error) { + return g.eng.GetRun(ctx, runID) +} + +func (g engineGateway) ListRuns(ctx context.Context, filter *run.ListFilter) ([]*run.Run, error) { + return g.eng.ListRuns(ctx, filter) +} + +func (g engineGateway) CancelRun(ctx context.Context, runID id.AgentRunID) error { + return g.eng.CancelRun(ctx, runID) +} + +func (g engineGateway) GetAgentByName(ctx context.Context, name string) (*agent.Config, error) { + return g.eng.GetAgentByName(ctx, name) +} + +func (g engineGateway) GetSkillByName(ctx context.Context, name string) (*skill.Skill, error) { + return g.eng.GetSkillByName(ctx, name) +} + +func (g engineGateway) GetDelivery(ctx context.Context, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + return g.eng.Store().GetDelivery(ctx, deliveryID) +} + +// AttachOptions is everything a host supplies to put an engine on the +// network. +type AttachOptions struct { + // Resolver authenticates inbound callers. There is no default: a + // service that authenticates nobody is an open door onto every agent + // in the process. + Resolver PeerResolver + // Peers this engine may call out to. Trust is configuration, so an + // agent's own output cannot add one. + Peers []PeerConfig + // Service tunes card serving and exposure. + Service Options + // Client tunes outbound calls. + Client ClientOptions +} + +// Attach wires an engine for remote A2A in both directions. +// +// It builds the inbound service, builds the outbound client over the +// configured peers, and registers that client as a transport on the +// engine's bus. Registration happens here rather than at bus +// construction because the two need each other: a peer's reply re-enters +// through the bus, and the bus routes outbound messages through the +// client. +func Attach(eng *engine.Engine, opts AttachOptions) (*Service, error) { + if eng == nil { + return nil, errors.New("cortex/a2aremote: no engine") + } + bus := eng.A2A() + if bus == nil { + return nil, ErrNoMessaging + } + if opts.Resolver == nil { + return nil, errors.New("cortex/a2aremote: no peer resolver: inbound requests would be unauthenticated") + } + + svc := NewService(EngineGateway(eng), opts.Resolver, opts.Service) + if svc == nil { + return nil, errors.New("cortex/a2aremote: the service could not be built") + } + + if len(opts.Peers) > 0 { + // The sink is Bus.Send, so a peer's answer travels the same path + // a local agent's answer does and resolves a waiting ask the same + // way. That convergence is what makes a resumed agent unable to + // tell where its peer was running. + sink := func(ctx context.Context, p a2a.SendParams) error { + _, err := bus.Send(ctx, p) + return err + } + bus.AddTransport(NewClient(opts.Peers, sink, opts.Client)) + } + return svc, nil +} + +// Handler serves everything an A2A peer needs from this engine: the RPC +// endpoint and the agent cards. +func (s *Service) Handler() http.Handler { + mux := http.NewServeMux() + mux.Handle(WellKnownCardPath, s.CardHandler()) + mux.Handle("/agents/", s.CardHandler()) + mux.Handle("/", s.JSONRPCHandler()) + return mux +} diff --git a/a2aremote/gateway_test.go b/a2aremote/gateway_test.go new file mode 100644 index 0000000..92ae060 --- /dev/null +++ b/a2aremote/gateway_test.go @@ -0,0 +1,38 @@ +package a2aremote + +import ( + "context" + "errors" + "testing" + + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/engine" +) + +func TestAttachRefusesAnEngineWithoutMessaging(t *testing.T) { + eng, err := engine.New() + if err != nil { + t.Fatalf("New: %v", err) + } + _, err = Attach(eng, AttachOptions{Resolver: okResolver()}) + if !errors.Is(err, ErrNoMessaging) { + t.Fatalf("err = %v, want ErrNoMessaging", err) + } +} + +// A service with no resolver would answer anyone, so building one is +// refused rather than defaulted. +func TestAttachRefusesAMissingResolver(t *testing.T) { + if _, err := Attach(nil, AttachOptions{}); err == nil { + t.Fatal("attaching to no engine must fail") + } +} + +func TestEngineGatewaySatisfiesTheSeam(t *testing.T) { + var gw Gateway = EngineGateway(nil) + if gw == nil { + t.Fatal("the adapter must satisfy Gateway") + } + _ = context.Background() + _ = a2a.Address{} +} diff --git a/a2aremote/go.mod b/a2aremote/go.mod index 3331c76..9d625e2 100644 --- a/a2aremote/go.mod +++ b/a2aremote/go.mod @@ -8,5 +8,8 @@ require github.com/xraph/cortex v0.0.0-00010101000000-000000000000 require ( github.com/gofrs/uuid/v5 v5.3.2 // indirect + github.com/xraph/go-utils v1.1.8 // indirect go.jetify.com/typeid/v2 v2.0.0-alpha.3 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect ) diff --git a/a2aremote/go.sum b/a2aremote/go.sum index fdbbfcb..bbae445 100644 --- a/a2aremote/go.sum +++ b/a2aremote/go.sum @@ -1,14 +1,50 @@ github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xraph/go-utils v1.1.8 h1:O8+Vie/u/ntn2cEbvh47jJLzQ6S7qwxhYwgRm2SL1sw= +github.com/xraph/go-utils v1.1.8/go.mod h1:Mckdi+nR0bI4bUESKSYajJq4tNSPsvZiuLRYJ0+qDQw= +github.com/xraph/grove v1.6.2 h1:O/3UyHTKQQ57CyZiLkDQi5T7xyzMSBz320VlK3C04Vo= +github.com/xraph/grove v1.6.2/go.mod h1:bgjHNhnmyfEyzbdpcppRt+Zf24nNcbGKlo450Mi4giI= +github.com/xraph/grove/drivers/sqlitedriver v1.6.2 h1:+s2mbOPufStYaK1bHHnWRBeX0ENeisvJ7ZU3Ip46wVA= +github.com/xraph/grove/drivers/sqlitedriver v1.6.2/go.mod h1:xzHewWROOPVn0Luu8/sWEAhSgmDnw3xmNrRw0xcefnM= go.jetify.com/typeid/v2 v2.0.0-alpha.3 h1:T6RPx6bNl10lp0JN2Xz/XcgLZWSlVmL58Xqy9cgTCcc= go.jetify.com/typeid/v2 v2.0.0-alpha.3/go.mod h1:zfD1ZDHDJNgXZANsO9jDOD81XRRQ0zAOnDBEHmIV/Gw= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= +modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= From 04d1a5ac949e8f16d99e2286920944e3566649c5 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:52:43 -0500 Subject: [PATCH 38/50] feat(a2aremote): prove two engines hold a conversation over the wire The loopback test earned its keep. It found four things no unit test could see: the tools never parsed the agent@node form, so a remote address was looked up as a local agent with an @ in its name; a drain that lost a claim race stranded the delivery until the next sweep, which on sqlite is an ordinary collision rather than an exceptional one; card serving had no scope to read its agents under, because a card is fetched without credentials; and a peer's contextId names a conversation in the peer's database, not ours. --- a2a/dispatcher.go | 31 +++- a2a/dispatcher_test.go | 48 ++++++ a2a/envelope.go | 20 +++ a2a/envelope_test.go | 34 ++++ a2a/memstore_test.go | 14 ++ a2aremote/gateway_test.go | 5 +- a2aremote/go.mod | 17 +- a2aremote/go.sum | 30 ++++ a2aremote/jsonrpc.go | 16 +- a2aremote/loopback_test.go | 320 +++++++++++++++++++++++++++++++++++++ a2aremote/service.go | 36 +++++ engine/a2a_tools.go | 7 +- 12 files changed, 567 insertions(+), 11 deletions(-) create mode 100644 a2aremote/loopback_test.go diff --git a/a2a/dispatcher.go b/a2a/dispatcher.go index 3869312..4257335 100644 --- a/a2a/dispatcher.go +++ b/a2a/dispatcher.go @@ -92,22 +92,51 @@ func (b *Bus) Stop() { <-done } +// retryDelay is how long a worker waits after a failed drain before +// trying again. It doubles up to the sweep interval. +// +// It exists because a failed drain has already consumed the wake nudge +// that queued the work. Without a short retry, a delivery whose claim +// lost a race with another writer would sit until the next sweep, which +// on the default interval is thirty seconds of delay for something that +// was busy for a millisecond. On sqlite, where a concurrent write is +// answered with SQLITE_BUSY, that race is ordinary rather than +// exceptional. +const retryDelay = 25 * time.Millisecond + func (d *dispatcher) work(ctx context.Context) { ticker := time.NewTicker(d.bus.opts.SweepInterval) defer ticker.Stop() + + backoff := retryDelay for { // A drain error is per batch and the loop keeps going: one bad row // must not stop delivery for everyone else. A cancelled context is // the exception, because that is the worker being shut down. - if _, err := d.bus.Drain(ctx); errors.Is(err, context.Canceled) { + _, err := d.bus.Drain(ctx) + if errors.Is(err, context.Canceled) { return } + // retry stays nil on success, so a healthy worker waits for real + // work rather than spinning. + var retry <-chan time.Time + if err != nil { + retry = time.After(backoff) + backoff *= 2 + if backoff > d.bus.opts.SweepInterval { + backoff = d.bus.opts.SweepInterval + } + } else { + backoff = retryDelay + } + select { case <-ctx.Done(): return case <-d.wake: case <-ticker.C: + case <-retry: } } } diff --git a/a2a/dispatcher_test.go b/a2a/dispatcher_test.go index 801a5b6..685371e 100644 --- a/a2a/dispatcher_test.go +++ b/a2a/dispatcher_test.go @@ -3,6 +3,7 @@ package a2a import ( "sync" "testing" + "time" ) func TestDrainDeliversEverythingQueued(t *testing.T) { @@ -149,3 +150,50 @@ func TestWorkersDeliverWithoutADrainCall(t *testing.T) { } <-done } + +// A store that is momentarily busy must not strand a delivery until the +// next sweep. On sqlite a concurrent write is answered with SQLITE_BUSY, +// which is ordinary rather than exceptional, and a worker that waited a +// full interval on it would make every colliding message arrive half a +// minute late. +func TestATransientStoreErrorIsRetriedPromptly(t *testing.T) { + st, runner := newMemStore(), newFakeRunner() + delivered := make(chan struct{}) + var once sync.Once + runner.respond = func(string, string) string { + once.Do(func() { close(delivered) }) + return "answered" + } + + b, err := NewBus(BusConfig{ + Store: st, Runner: runner, + // A sweep interval far longer than the test's patience, so the + // only way this passes is the retry. + Options: Options{Workers: 1, SweepInterval: time.Hour}, + }) + if err != nil { + t.Fatalf("NewBus: %v", err) + } + ctx := testCtx() + if err := b.Start(ctx); err != nil { + t.Fatalf("Start: %v", err) + } + defer b.Stop() + + // The first claim fails, which consumes the wake nudge that the send + // below produces. + st.failNextClaims(1) + + if _, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Request, Content: "retry me", + }); err != nil { + t.Fatalf("Send: %v", err) + } + + select { + case <-delivered: + case <-time.After(5 * time.Second): + t.Fatal("a delivery stranded by one busy claim was never retried") + } +} diff --git a/a2a/envelope.go b/a2a/envelope.go index ed6017c..bf3eb5a 100644 --- a/a2a/envelope.go +++ b/a2a/envelope.go @@ -2,6 +2,7 @@ package a2a import ( "errors" + "strings" "time" "github.com/xraph/cortex" @@ -50,6 +51,25 @@ func (a Address) String() string { return a.Agent + "@" + a.Node } +// ParseAddress reads the textual form agents use: "worker" for a local +// agent, "worker@peer.example" for one at a remote node. +// +// It splits on the LAST @, because an agent name cannot contain one but +// a node conceivably can. Without this, an agent naming a remote peer +// would be looked up as a local agent whose name happens to contain an +// @, which fails as "agent not found" and says nothing about why. +func ParseAddress(s string) Address { + s = strings.TrimSpace(s) + if s == "" { + return Address{} + } + at := strings.LastIndex(s, "@") + if at < 0 { + return Address{Agent: s} + } + return Address{Agent: s[:at], Node: s[at+1:]} +} + // Envelope is one FIPA-ACL message. The first block is the ACL parameter // set, verbatim; the second is cortex's own additions, kept apart from it. type Envelope struct { diff --git a/a2a/envelope_test.go b/a2a/envelope_test.go index c13092f..5efc12e 100644 --- a/a2a/envelope_test.go +++ b/a2a/envelope_test.go @@ -99,3 +99,37 @@ func TestAddressString(t *testing.T) { t.Fatalf("String() = %q, want %q", got, "x@n") } } + +// Agents write addresses as text, and "worker@peer.example" has to mean +// the remote worker rather than a local agent whose name contains an @. +func TestParseAddress(t *testing.T) { + cases := map[string]Address{ + "worker": {Agent: "worker"}, + "worker@peer.example": {Agent: "worker", Node: "peer.example"}, + " worker ": {Agent: "worker"}, + "worker@": {Agent: "worker"}, + "": {}, + } + for in, want := range cases { + if got := ParseAddress(in); got != want { + t.Errorf("ParseAddress(%q) = %+v, want %+v", in, got, want) + } + } +} + +// A node containing an @ would be ambiguous, so the split is on the +// LAST one: an agent name cannot contain an @, but a node might. +func TestParseAddressSplitsOnTheLastAt(t *testing.T) { + got := ParseAddress("worker@a@b.example") + if got.Agent != "worker@a" || got.Node != "b.example" { + t.Fatalf("got %+v", got) + } +} + +func TestAddressStringRoundTrips(t *testing.T) { + for _, in := range []string{"worker", "worker@peer.example"} { + if got := ParseAddress(in).String(); got != in { + t.Errorf("round trip of %q gave %q", in, got) + } + } +} diff --git a/a2a/memstore_test.go b/a2a/memstore_test.go index 817139a..bfb8460 100644 --- a/a2a/memstore_test.go +++ b/a2a/memstore_test.go @@ -44,6 +44,16 @@ type memStore struct { deliveryIDs []string asks map[string]*PendingAsk askKeys []string + + // claimErrs are returned by the next N ClaimDelivery calls, standing + // in for a store that is momentarily busy. + claimErrs int +} + +func (s *memStore) failNextClaims(n int) { + s.mu.Lock() + defer s.mu.Unlock() + s.claimErrs = n } func newMemStore() *memStore { @@ -170,6 +180,10 @@ func (s *memStore) UpdateDelivery(_ context.Context, d *Delivery) error { func (s *memStore) ClaimDelivery(_ context.Context, deliveryID id.DeliveryID) (*Delivery, error) { s.mu.Lock() defer s.mu.Unlock() + if s.claimErrs > 0 { + s.claimErrs-- + return nil, errors.New("database is locked") + } d, ok := s.deliveries[deliveryID.String()] if !ok { return nil, ErrDeliveryNotFound diff --git a/a2aremote/gateway_test.go b/a2aremote/gateway_test.go index 92ae060..ba3cb59 100644 --- a/a2aremote/gateway_test.go +++ b/a2aremote/gateway_test.go @@ -28,9 +28,10 @@ func TestAttachRefusesAMissingResolver(t *testing.T) { } } +// The adapter is what keeps the core module from ever importing this +// one, so the compile-time proof that it fits is worth keeping. func TestEngineGatewaySatisfiesTheSeam(t *testing.T) { - var gw Gateway = EngineGateway(nil) - if gw == nil { + if EngineGateway(nil) == nil { t.Fatal("the adapter must satisfy Gateway") } _ = context.Background() diff --git a/a2aremote/go.mod b/a2aremote/go.mod index 9d625e2..28dae0c 100644 --- a/a2aremote/go.mod +++ b/a2aremote/go.mod @@ -4,12 +4,27 @@ go 1.26.0 replace github.com/xraph/cortex => ../ -require github.com/xraph/cortex v0.0.0-00010101000000-000000000000 +require ( + github.com/xraph/cortex v0.0.0-00010101000000-000000000000 + github.com/xraph/grove v1.6.2 + github.com/xraph/grove/drivers/sqlitedriver v1.6.2 +) require ( + github.com/dustin/go-humanize v1.0.1 // indirect github.com/gofrs/uuid/v5 v5.3.2 // indirect + github.com/google/uuid v1.6.0 // indirect + github.com/mattn/go-isatty v0.0.24 // indirect + github.com/ncruces/go-strftime v1.0.0 // indirect + github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect github.com/xraph/go-utils v1.1.8 // indirect go.jetify.com/typeid/v2 v2.0.0-alpha.3 // indirect go.uber.org/multierr v1.11.0 // indirect go.uber.org/zap v1.28.0 // indirect + golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 // indirect + golang.org/x/sys v0.47.0 // indirect + modernc.org/libc v1.68.0 // indirect + modernc.org/mathutil v1.7.1 // indirect + modernc.org/memory v1.11.0 // indirect + modernc.org/sqlite v1.46.1 // indirect ) diff --git a/a2aremote/go.sum b/a2aremote/go.sum index bbae445..138ec68 100644 --- a/a2aremote/go.sum +++ b/a2aremote/go.sum @@ -4,8 +4,12 @@ github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkp github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs= +github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA= github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/hashicorp/golang-lru/v2 v2.0.7 h1:a+bsQ5rvGLjzHuww6tVxozPZFVghXaHOwFs4luLUK2k= +github.com/hashicorp/golang-lru/v2 v2.0.7/go.mod h1:QeFd9opnmA6QUJc5vARoKUSoFhyfM2/ZepoAG6RGpeM= github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= @@ -34,17 +38,43 @@ go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/mod v0.38.0 h1:MECBjubtXD7yj4HrhIUcywNaGeNVUdfVnxmPajOk4yk= +golang.org/x/mod v0.38.0/go.mod h1:V6Xz0pq8TQ3dGqVQ1FVHuelZpAL0uNhSkk9ogYP3c40= +golang.org/x/sync v0.22.0 h1:SZjpbeLmrCk4xhRSZFNZW5gFUeCeFgjekvI/+gfScek= +golang.org/x/sync v0.22.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/tools v0.48.0 h1:3+hClM1aLL5mjMKm5ovokw9epgRXPuu2tILgismM6RE= +golang.org/x/tools v0.48.0/go.mod h1:08xX0orndb/F7jJxGDicx061tyd5pcMto75YMAXr6lk= gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/cc/v4 v4.27.1 h1:9W30zRlYrefrDV2JE2O8VDtJ1yPGownxciz5rrbQZis= +modernc.org/cc/v4 v4.27.1/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0= +modernc.org/ccgo/v4 v4.30.2 h1:4yPaaq9dXYXZ2V8s1UgrC3KIj580l2N4ClrLwnbv2so= +modernc.org/ccgo/v4 v4.30.2/go.mod h1:yZMnhWEdW0qw3EtCndG1+ldRrVGS+bIwyWmAWzS0XEw= +modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA= +modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc= +modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI= +modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito= +modernc.org/gc/v3 v3.1.2 h1:ZtDCnhonXSZexk/AYsegNRV1lJGgaNZJuKjJSWKyEqo= +modernc.org/gc/v3 v3.1.2/go.mod h1:HFK/6AGESC7Ex+EZJhJ2Gni6cTaYpSMmU/cT9RmlfYY= +modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks= +modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI= modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8= +modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns= +modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w= +modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE= modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= +modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0= +modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A= +modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y= +modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM= diff --git a/a2aremote/jsonrpc.go b/a2aremote/jsonrpc.go index 26089e9..9756841 100644 --- a/a2aremote/jsonrpc.go +++ b/a2aremote/jsonrpc.go @@ -8,6 +8,7 @@ import ( "net/http" "strings" + "github.com/xraph/cortex" "github.com/xraph/cortex/agent" "github.com/xraph/cortex/skill" ) @@ -225,17 +226,22 @@ func (s *Service) writeCard(w http.ResponseWriter, r *http.Request, name string) http.NotFound(w, r) return } - // A card is read without credentials, so it is built under no scope. - // That is why the agent lookup below cannot be scoped, and why - // exposure is the only gate on it. - a, err := s.gw.GetAgentByName(r.Context(), name) + // A card is read without credentials, so there is no caller scope to + // borrow. The host names the scope its exposed agents live in, and + // only agents it exposed are ever read under it. + ctx := r.Context() + if !s.opts.Scope.IsZero() { + ctx = cortex.WithScope(ctx, s.opts.Scope) + } + + a, err := s.gw.GetAgentByName(ctx, name) if err != nil { http.NotFound(w, r) return } opts := s.opts.Card - card := BuildCard(a, s.skillsOf(r.Context(), a), opts) + card := BuildCard(a, s.skillsOf(ctx, a), opts) w.Header().Set("Content-Type", "application/json") if err := json.NewEncoder(w).Encode(card); err != nil { diff --git a/a2aremote/loopback_test.go b/a2aremote/loopback_test.go new file mode 100644 index 0000000..b9c7754 --- /dev/null +++ b/a2aremote/loopback_test.go @@ -0,0 +1,320 @@ +package a2aremote_test + +import ( + "context" + "errors" + "net/http" + "net/http/httptest" + "path/filepath" + "strings" + "sync" + "testing" + "time" + + "github.com/xraph/grove" + "github.com/xraph/grove/drivers/sqlitedriver" + _ "github.com/xraph/grove/drivers/sqlitedriver/sqlitemigrate" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/a2aremote" + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/engine" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/llm" + "github.com/xraph/cortex/run" + sqlitestore "github.com/xraph/cortex/store/sqlite" +) + +// scriptedLLM answers as whichever agent is calling, keyed on the system +// prompt, which is the only thing in a request that says who is asking. +type scriptedLLM struct { + mu sync.Mutex + asked bool + answer string + resumed chan struct{} + once sync.Once +} + +func (l *scriptedLLM) Complete(_ context.Context, req *llm.Request) (*llm.Response, error) { + l.mu.Lock() + defer l.mu.Unlock() + + system := req.System + for _, m := range req.Messages { + if m.Role == "system" && system == "" { + system = m.Content + } + } + + if strings.Contains(system, "specialist") { + return &llm.Response{Content: l.answer}, nil + } + if !l.asked { + l.asked = true + return &llm.Response{ToolCalls: []llm.ToolCall{{ + ID: "call-1", + Name: "agent_ask", + Arguments: `{"to":"specialist@peer-b","content":"is the migration safe?"}`, + }}}, nil + } + if l.resumed != nil { + l.once.Do(func() { close(l.resumed) }) + } + return &llm.Response{Content: "the specialist has answered"}, nil +} + +func (l *scriptedLLM) CompleteStream(context.Context, *llm.Request) (llm.Stream, error) { + return nil, errors.New("not supported") +} + +func newEngine(ctx context.Context, t *testing.T, name string, model llm.Client, agents ...string) (*engine.Engine, *sqlitestore.Store) { + t.Helper() + // The busy timeout matters here rather than being test hygiene. The + // messaging dispatcher writes on its own goroutines while a run is + // writing on yours, and sqlite answers a concurrent writer with + // SQLITE_BUSY unless it is told to wait. Any real sqlite deployment + // of messaging needs this too, and the docs say so. + dsn := filepath.Join(t.TempDir(), name+".db") + "?_pragma=busy_timeout(5000)" + drv := sqlitedriver.New() + if err := drv.Open(ctx, dsn); err != nil { + t.Fatalf("open sqlite: %v", err) + } + db, err := grove.Open(drv) + if err != nil { + t.Fatalf("grove open: %v", err) + } + st := sqlitestore.New(db) + if migrateErr := st.Migrate(ctx); migrateErr != nil { + t.Fatalf("migrate: %v", migrateErr) + } + t.Cleanup(func() { _ = st.Close() }) + + eng, err := engine.New( + engine.WithStore(st), + engine.WithLLM(model), + engine.WithA2A(a2a.Options{HopCeiling: 6, Workers: 1}), + ) + if err != nil { + t.Fatalf("engine.New: %v", err) + } + for _, a := range agents { + if createErr := eng.CreateAgent(ctx, &agent.Config{ + ID: id.NewAgentID(), Name: a, SystemPrompt: "you are the " + a, + Model: "test-model", MaxSteps: 4, + }); createErr != nil { + t.Fatalf("CreateAgent %s: %v", a, createErr) + } + } + return eng, st +} + +// TestTwoEnginesTalkOverTheWire is the whole feature, end to end and +// across a network boundary. +// +// Engine B serves an agent over JSON-RPC. An agent on engine A asks it a +// question, A's run suspends on a row in A's own database, the question +// travels as an A2A message, B runs its agent, the answer comes back, +// and A resumes with it. +// +// Every piece under this is unit tested. What is not, and cannot be, is +// that the pieces line up: the ids, the extension metadata, the sender +// namespacing, the reply correlation and the resume. +func TestTwoEnginesTalkOverTheWire(t *testing.T) { + ctx := cortex.WithScope(context.Background(), cortex.Scope{ + Levels: []cortex.Level{{Key: "tenant", Value: "acme"}}, + }) + + // Engine B, the peer being called. + engB, _ := newEngine(ctx, t, "b", &scriptedLLM{answer: "yes, the migration is safe"}, "specialist") + if err := engB.Start(ctx); err != nil { + t.Fatalf("engine B start: %v", err) + } + defer func() { _ = engB.Stop(ctx) }() + + // B authenticates its callers. The resolver is the only thing that + // decides what scope an inbound message acts in. + svcB, err := a2aremote.Attach(engB, a2aremote.AttachOptions{ + Resolver: a2aremote.ResolverFunc(func(_ context.Context, cred a2aremote.Credentials) (a2aremote.Peer, error) { + if cred.Header("authorization") != "Bearer trust-me" { + return a2aremote.Peer{}, errors.New("who are you") + } + return a2aremote.Peer{ + Node: "peer-a", + Scope: cortex.Scope{Levels: []cortex.Level{{Key: "tenant", Value: "acme"}}}, + }, nil + }), + Service: a2aremote.Options{ + Exposed: []string{"specialist"}, + DefaultAgent: "specialist", + Scope: cortex.Scope{Levels: []cortex.Level{{Key: "tenant", Value: "acme"}}}, + }, + }) + if err != nil { + t.Fatalf("attach B: %v", err) + } + + srv := httptest.NewServer(svcB.Handler()) + defer srv.Close() + + // The card has to point back at the server it is served from, which + // the host knows and cortex does not. + svcB, err = a2aremote.Attach(engB, a2aremote.AttachOptions{ + Resolver: a2aremote.ResolverFunc(func(_ context.Context, cred a2aremote.Credentials) (a2aremote.Peer, error) { + if cred.Header("authorization") != "Bearer trust-me" { + return a2aremote.Peer{}, errors.New("who are you") + } + return a2aremote.Peer{ + Node: "peer-a", + Scope: cortex.Scope{Levels: []cortex.Level{{Key: "tenant", Value: "acme"}}}, + }, nil + }), + Service: a2aremote.Options{ + Card: a2aremote.CardOptions{BaseURL: srv.URL, Version: "1.0.0"}, + Exposed: []string{"specialist"}, + DefaultAgent: "specialist", + Scope: cortex.Scope{Levels: []cortex.Level{{Key: "tenant", Value: "acme"}}}, + }, + }) + if err != nil { + t.Fatalf("re-attach B: %v", err) + } + srv.Config.Handler = svcB.Handler() + + // Engine A, the caller. + modelA := &scriptedLLM{resumed: make(chan struct{})} + engA, stA := newEngine(ctx, t, "a", modelA, "planner") + if _, attachErr := a2aremote.Attach(engA, a2aremote.AttachOptions{ + Resolver: a2aremote.ResolverFunc(func(context.Context, a2aremote.Credentials) (a2aremote.Peer, error) { + return a2aremote.Peer{}, errors.New("A takes no inbound calls in this test") + }), + Peers: []a2aremote.PeerConfig{{ + Node: "peer-b", + BaseURL: srv.URL, + Header: headerWith("Authorization", "Bearer trust-me"), + }}, + Client: a2aremote.ClientOptions{PollInterval: 10 * time.Millisecond}, + }); attachErr != nil { + t.Fatalf("attach A: %v", attachErr) + } + if startErr := engA.Start(ctx); startErr != nil { + t.Fatalf("engine A start: %v", startErr) + } + defer func() { _ = engA.Stop(ctx) }() + + // The planner asks the specialist, over the wire. + paused, runErr := engA.RunAgent(ctx, "planner", "find out whether tonight's migration is safe", nil) + if runErr != nil { + t.Fatalf("RunAgent: %v", runErr) + } + if paused.State != run.StatePaused { + t.Fatalf("state = %s, want paused while the peer answers", paused.State) + } + + select { + case <-modelA.resumed: + case <-time.After(15 * time.Second): + final, _ := stA.GetRun(ctx, paused.ID) + t.Fatalf("the planner never resumed; the run is %s", final.State) + } + + // The model's signal fires while the resumed run is still finishing, + // so the run's own terminal state is what to wait on. + final := waitForTerminal(ctx, t, stA, paused.ID) + if final.State != run.StateCompleted { + t.Fatalf("state = %s, want completed (error: %q)", final.State, final.Error) + } + + // The answer reached the model as the ask's tool result. + steps, err := stA.ListSteps(ctx, paused.ID) + if err != nil { + t.Fatalf("ListSteps: %v", err) + } + var carried bool + for _, s := range steps { + calls, callErr := stA.ListToolCalls(ctx, s.ID) + if callErr != nil { + t.Fatalf("ListToolCalls: %v", callErr) + } + for _, c := range calls { + if strings.Contains(c.Result, "yes, the migration is safe") { + carried = true + } + } + } + if !carried { + t.Fatal("the peer's answer never reached the asking model") + } + + // The peer is recorded as remote, not as a local agent that happens + // to share a name. + msgs, err := stA.ListMessages(ctx, &a2a.MessageListFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + var sawRemoteSender bool + for _, m := range msgs { + if m.Sender.Node == "peer-b" { + sawRemoteSender = true + } + } + if !sawRemoteSender { + t.Fatal("the reply was not recorded as coming from the remote peer") + } +} + +// waitForTerminal polls a run until it stops moving. Polling is the +// honest thing here: the run finishes on a dispatcher goroutine, and the +// test has no channel into it. +func waitForTerminal(ctx context.Context, t *testing.T, st *sqlitestore.Store, runID id.AgentRunID) *run.Run { + t.Helper() + deadline := time.Now().Add(10 * time.Second) + for { + r, err := st.GetRun(ctx, runID) + if err != nil { + t.Fatalf("GetRun: %v", err) + } + switch r.State { + case run.StateCompleted, run.StateFailed, run.StateCancelled: + return r + } + if time.Now().After(deadline) { + t.Fatalf("the run never reached a terminal state; it is %s", r.State) + } + time.Sleep(20 * time.Millisecond) + } +} + +func headerWith(k, v string) http.Header { + h := http.Header{} + h.Set(k, v) + return h +} + +func TestAPeerWithoutCredentialsIsRefused(t *testing.T) { + ctx := cortex.WithScope(context.Background(), cortex.Scope{ + Levels: []cortex.Level{{Key: "tenant", Value: "acme"}}, + }) + engB, _ := newEngine(ctx, t, "b2", &scriptedLLM{answer: "no"}, "specialist") + + svc, err := a2aremote.Attach(engB, a2aremote.AttachOptions{ + Resolver: a2aremote.ResolverFunc(func(context.Context, a2aremote.Credentials) (a2aremote.Peer, error) { + return a2aremote.Peer{}, errors.New("no") + }), + Service: a2aremote.Options{Exposed: []string{"specialist"}}, + }) + if err != nil { + t.Fatalf("attach: %v", err) + } + srv := httptest.NewServer(svc.Handler()) + defer srv.Close() + + client := a2aremote.NewClient([]a2aremote.PeerConfig{{Node: "peer-b", BaseURL: srv.URL}}, nil, a2aremote.ClientOptions{}) + err = client.Deliver(ctx, &a2a.Envelope{ + ID: id.NewMessageID(), Performative: a2a.Request, + Sender: a2a.Address{Agent: "planner"}, Content: "let me in", + }, a2a.Address{Agent: "specialist", Node: "peer-b"}) + if err == nil { + t.Fatal("an unauthenticated peer must be refused") + } +} diff --git a/a2aremote/service.go b/a2aremote/service.go index 5e6d0c4..949b608 100644 --- a/a2aremote/service.go +++ b/a2aremote/service.go @@ -25,6 +25,14 @@ type Options struct { // Exposed lists the agents whose cards are served. A card is public, // so exposure is opt-in: an empty list serves no cards at all. Exposed []string + // Scope is where the exposed agents are read from. + // + // It has to be said explicitly because a card is fetched without + // credentials: there is no caller to borrow a scope from, and every + // store read in cortex needs one. Only agents named in Exposed are + // ever read under it, so it grants no more reach than exposure + // already did. + Scope cortex.Scope // DefaultAgent also gets its card served at the root well-known // path, so plain discovery finds something. DefaultAgent string @@ -79,6 +87,19 @@ func (s *Service) SendMessage(ctx context.Context, cred Credentials, req SendMes } sent, err := s.gw.SendMessage(ctx, params) + if errors.Is(err, a2a.ErrConversationNotFound) { + // The peer quoted a context id from its own world. + // + // A contextId names a conversation in whichever engine issued + // it, and a peer continuing a thread on its side has no idea + // whether that id means anything here. Rather than refuse a + // perfectly good message, the exchange starts a conversation on + // this side and the peer's id is kept as metadata, so a reader + // can still line the two up later. + params.ConversationID = id.ConversationID{} + params.Metadata = withPeerContext(params.Metadata, req.Message.ContextID) + sent, err = s.gw.SendMessage(ctx, params) + } if err != nil { return nil, mapBusError(err) } @@ -265,6 +286,21 @@ func deliveryRunState(d *a2a.Delivery) run.State { } } +// peerContextKey is where a peer's own conversation id is recorded when +// it does not name a conversation of ours. +const peerContextKey = "a2a.peer_context_id" + +func withPeerContext(meta map[string]any, contextID string) map[string]any { + if contextID == "" { + return meta + } + if meta == nil { + meta = map[string]any{} + } + meta[peerContextKey] = contextID + return meta +} + // deliveryIDOf picks the handle a send produced. A directive addressed // to one agent has exactly one delivery; the message id is the fallback // for the case where the send produced none, which a caller can still diff --git a/engine/a2a_tools.go b/engine/a2a_tools.go index 8933d47..7446c7a 100644 --- a/engine/a2a_tools.go +++ b/engine/a2a_tools.go @@ -218,7 +218,7 @@ func (e *Engine) executeAgentAsk(ctx context.Context, inv cortex.Invocation) (st params := a2a.AskParams{ SendParams: a2a.SendParams{ Sender: sender, - Receivers: []a2a.Address{{Agent: args.To}}, + Receivers: []a2a.Address{a2a.ParseAddress(args.To)}, Performative: performativeOr(args.Performative, a2a.Request), Content: args.Content, Ontology: args.Ontology, @@ -286,10 +286,13 @@ func (e *Engine) a2aSelf(ctx context.Context, inv cortex.Invocation) (a2a.Addres return a2a.Address{Agent: ag.Name}, nil } +// addressesOf parses what the model wrote. Agents address peers as text, +// and "worker@peer.example" has to reach the remote worker rather than a +// local agent whose name contains an @. func addressesOf(names []string) []a2a.Address { out := make([]a2a.Address, 0, len(names)) for _, n := range names { - out = append(out, a2a.Address{Agent: n}) + out = append(out, a2a.ParseAddress(n)) } return out } From 3485a8fd87051119de5c0aef4f97764317e9b62d Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 20:53:49 -0500 Subject: [PATCH 39/50] docs: document remote agents and the sqlite busy timeout --- CHANGELOG.md | 51 ++++++ docs/content/docs/execution/meta.json | 1 + .../docs/execution/remote-messaging.mdx | 146 ++++++++++++++++++ 3 files changed, 198 insertions(+) create mode 100644 docs/content/docs/execution/remote-messaging.mdx diff --git a/CHANGELOG.md b/CHANGELOG.md index 234191b..54f5181 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -104,8 +104,59 @@ when cross-process messaging lands. completed one, which is what lets `agent_ask` suspend a step. This is internal to the engine; a host-registered tool's contract is unchanged. +### Added later in this release: remote agents + +Messaging crossed the process boundary. A new module, +`github.com/xraph/cortex/a2aremote`, serves your agents to remote A2A +clients and lets your agents call agents that were never built with +cortex. It is a separate module because gRPC's dependency graph should +not land on a host that only ever wanted in-process messaging. + +The protocol is A2A 1.0.0, the Linux Foundation release, and checking +that rather than assuming it changed the work: method names are +PascalCase now, and agent cards moved to `/.well-known/agent-card.json`. +A server still answering `message/send` at `/.well-known/agent.json` is +invisible to every current client. Cortex serves the JSON-RPC binding +and says so in its card, so a peer that needs gRPC learns that by +reading rather than by failing. + +Your FIPA-ACL semantics survive the hop as a declared, optional A2A +extension. A peer that has never heard of FIPA gets a valid A2A message +and reads the text; one that has gets `cfp`, `refuse` and `agree` +intact. + +Inbound requests are authenticated by a `PeerResolver` you implement, +and its answer is the only thing that decides which scope a request acts +in. Nothing in a message body or a header can influence that. Outbound +peers are configuration rather than data, so an agent's own output +cannot introduce a host to call. + +### Fixed + +- **Remote receivers were never carried by their transport.** The first + round shipped a `Transport` seam that the delivery path did not + consult, so an envelope addressed to `worker@peer.example` would have + been answered by a local agent that happened to be called `worker`. + Delivery now asks whether a receiver is local before it asks what the + performative wants. +- **`agent@node` was not parsed.** The messaging tools took the whole + string as an agent name, so addressing a remote peer failed as "agent + not found" and said nothing about why. +- **A delivery whose claim lost a race waited a full sweep.** A failed + drain consumes the wake that queued the work, so a momentarily busy + store meant a thirty second delay. Workers now retry on a short + backoff. On sqlite, where a concurrent write is answered with + SQLITE_BUSY, that race is ordinary rather than exceptional. + ### Known gaps +- **Sqlite needs a busy timeout** once messaging is on. The dispatcher + writes while your runs write, and sqlite refuses a concurrent writer + rather than waiting unless told to. Open with + `cortex.db?_pragma=busy_timeout(5000)`. +- Conversations are not stitched across engines. A peer quoting a + `contextId` from its own database gets a fresh conversation on this + side, with its id kept as metadata. - A delivery claimed by a process that then dies stays marked `delivering` and is not redriven. Nothing wedges, because an ask resolves on its deadline either way, but an informative message caught diff --git a/docs/content/docs/execution/meta.json b/docs/content/docs/execution/meta.json index 345fb7e..8f40532 100644 --- a/docs/content/docs/execution/meta.json +++ b/docs/content/docs/execution/meta.json @@ -4,6 +4,7 @@ "runs", "orchestration", "messaging", + "remote-messaging", "memory", "checkpoints" ] diff --git a/docs/content/docs/execution/remote-messaging.mdx b/docs/content/docs/execution/remote-messaging.mdx new file mode 100644 index 0000000..feeec6b --- /dev/null +++ b/docs/content/docs/execution/remote-messaging.mdx @@ -0,0 +1,146 @@ +--- +title: Remote Agents (A2A) +description: Talking to agents that are not cortex agents, over the Agent2Agent protocol, in both directions. +--- + +Messaging gets your agents talking to each other inside one engine. This gets +them talking to agents built by somebody else, over +[Agent2Agent](https://a2a-protocol.org), and lets those agents talk to yours. + +It lives in its own module, `github.com/xraph/cortex/a2aremote`, so a host that +only ever wanted in-process messaging does not inherit its dependencies. + +```bash +go get github.com/xraph/cortex/a2aremote +``` + +## What version of A2A + +1.0.0, the Linux Foundation release. That matters more than it sounds, because +the protocol moved and the differences are not cosmetic: + +- Method names are PascalCase (`SendMessage`, `GetTask`), not the 0.x + `message/send` style. Cortex does not answer the old spellings, because + answering them would tell a client it speaks a version it does not. +- Agent cards live at `/.well-known/agent-card.json`. The 0.x path was + `/.well-known/agent.json`, and a 1.0 client never looks there. +- A2A defines three bindings. Cortex serves JSON-RPC 2.0 today, and the card + says so, so a client that needs gRPC finds that out by reading rather than by + failing. + +## Serving your agents + +```go +svc, err := a2aremote.Attach(eng, a2aremote.AttachOptions{ + Resolver: a2aremote.ResolverFunc(func(ctx context.Context, cred a2aremote.Credentials) (a2aremote.Peer, error) { + tenant, ok := myAuth.TenantFor(cred.Header("Authorization")) + if !ok { + return a2aremote.Peer{}, errors.New("unknown caller") + } + return a2aremote.Peer{ + Node: tenant.PeerName, + Scope: cortex.Scope{Levels: []cortex.Level{{Key: "tenant", Value: tenant.ID}}}, + }, nil + }), + Service: a2aremote.Options{ + Card: a2aremote.CardOptions{BaseURL: "https://agents.example.com/a2a"}, + Exposed: []string{"support-triage", "db-expert"}, + DefaultAgent: "support-triage", + Scope: cortex.Scope{Levels: []cortex.Level{{Key: "tenant", Value: "acme"}}}, + }, +}) +if err != nil { + log.Fatal(err) +} + +http.Handle("/a2a/", http.StripPrefix("/a2a", svc.Handler())) +``` + +Three things in there decide your security posture, so they are worth reading +slowly. + +**The resolver is the only thing that assigns a scope.** No header, no message +field and no query parameter can influence which cortex scope an inbound +request acts in. Cortex ships the seam and no implementation of it: you already +have an identity system, and a second weaker one living inside cortex would be +a liability. + +**`Exposed` is opt-in.** An agent card is public to anyone who can reach the +endpoint, and it carries the agent's name, description and skills. An agent you +do not list is a 404, not a 403, because the fact that it exists here is itself +disclosure. + +**`Scope` says where exposed agents are read from.** A card is fetched without +credentials, so there is no caller to borrow a scope from. Only the agents you +exposed are ever read under it. + +## Calling other people's agents + +Register the peers you trust, and your agents address them as +`agent@node`: + +```go +_, err := a2aremote.Attach(eng, a2aremote.AttachOptions{ + Resolver: myResolver, + Peers: []a2aremote.PeerConfig{{ + Node: "partner", + BaseURL: "https://partner.example.com/a2a", + Header: http.Header{"Authorization": {"Bearer " + partnerToken}}, + }}, +}) +``` + +Now an agent can call `agent_ask` with `to: "research@partner"` and the answer +comes back the same way a local peer's would: the run suspends, the question +travels as an A2A message, and the reply resumes the run through the same +correlation ledger. A resumed agent cannot tell where its peer was running. + +Peers are configuration rather than data on purpose. An agent's own output +cannot introduce one, so a prompt-injected agent can at worst misuse a peer you +already trusted. An address whose node is not registered is unroutable, and the +model gets told so. + +## What travels + +Your FIPA-ACL semantics survive the hop as a declared A2A extension. The card +advertises it under `capabilities.extensions`, each message names it in +`extensions`, and the ACL parameters ride in `metadata` under the extension's +URI. It is declared optional, which is the point: a peer that has never heard of +FIPA still receives a valid A2A message and reads the text, while one that has +gets `cfp`, `refuse`, `agree` and the rest intact. + +Ids line up with the protocol's own fields. A conversation is a `contextId`, and +a task is the delivery cortex queued. Task state is projected from the run, so +the two can never disagree: `WORKING` is a running run, `INPUT_REQUIRED` is a +suspended one, and a peer learns that your agent is waiting without learning +what it is waiting on. + +## Running on sqlite + +Set a busy timeout: + +```go +drv.Open(ctx, "cortex.db?_pragma=busy_timeout(5000)") +``` + +The dispatcher writes on its own goroutines while your runs write on theirs, and +sqlite answers a concurrent writer with `SQLITE_BUSY` unless told to wait. +Without the timeout you will see run writes fail under load for no reason you +can see. Postgres needs nothing here. + +## What is not implemented + +Declared in the card rather than discovered by failing: + +- **Streaming.** `SendStreamingMessage` and `SubscribeToTask` return + `UnsupportedOperationError`, and `capabilities.streaming` is false. +- **Push notifications.** The config methods return + `PushNotificationNotSupportedError`. +- **The extended agent card.** `GetExtendedAgentCard` returns + `ExtendedAgentCardNotConfiguredError`. +- **gRPC and HTTP+JSON bindings.** Only `JSONRPC` appears in the card's + `supportedInterfaces`. + +One behaviour worth knowing about rather than discovering: a peer that quotes a +`contextId` from its own database gets a fresh conversation on this side, with +its id kept as metadata. Conversations are not stitched across engines. From cf164dd8f640a57fdb726a1b5ba3bd0c865168ab Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 21:12:18 -0500 Subject: [PATCH 40/50] docs(a2a): design contract net Almost nothing is missing. The performatives are carried and routed already, so the whole protocol is one change: an ask that waits for several answers rather than exactly one. --- ...26-08-26-cortex-a2a-contract-net-design.md | 183 ++++++++++++++++++ 1 file changed, 183 insertions(+) create mode 100644 docs/superpowers/specs/2026-08-26-cortex-a2a-contract-net-design.md diff --git a/docs/superpowers/specs/2026-08-26-cortex-a2a-contract-net-design.md b/docs/superpowers/specs/2026-08-26-cortex-a2a-contract-net-design.md new file mode 100644 index 0000000..db901cb --- /dev/null +++ b/docs/superpowers/specs/2026-08-26-cortex-a2a-contract-net-design.md @@ -0,0 +1,183 @@ +# Cortex A2A: Contract Net (spec 3) + +- **Status:** Approved, ready for implementation +- **Date:** 2026-08-26 +- **Author:** Rex Raphael +- **Repos touched:** `github.com/xraph/cortex` +- **Related:** Third of the three specs decomposed in + [2026-08-26-cortex-a2a-messaging-design.md](2026-08-26-cortex-a2a-messaging-design.md) §3. +- **Method:** Test-driven. + +--- + +## 1. Goal + +Let an agent put work out to tender: announce a task to several agents, collect +what they each say they can do, pick one, and hold the winner to it. + +That is the FIPA Contract Net Interaction Protocol, and it is the oldest +task-allocation protocol in multi-agent systems for a reason. It handles the +case a manager cannot: the initiator does not know who is best placed, so it +asks rather than assigns. + +``` +initiator participants + |-------- cfp ------------------>| (to several at once) + |<------- propose / refuse -------| (each answers, or does not) + |-------- accept-proposal ------->| (to the one it picked) + |-------- reject-proposal ------->| (to the others) + |<------- inform / failure -------| (the winner reports back) +``` + +## 2. What is already here, and what is missing + +Almost all of it exists. Spec 1 carries all four Contract Net performatives and +routes them correctly: `cfp`, `propose` and `accept-proposal` are directives +that start a run, `refuse` and `reject-proposal` are informatives that do not. +The conversation, the hop budget and the deadline are all in place, and +`Protocol` already travels on the envelope. + +**One thing is missing: an ask that waits for several answers.** + +`Bus.Ask` refuses more than one receiver, because a durable ask correlates one +reply to one waiting run. A call for proposals is exactly the case that +constraint rules out. So the whole of this spec is one change to that rule, plus +the vocabulary and the documentation to use it. + +## 3. The change: asks that wait for a quorum + +`Ask` accepts several receivers. The pending ask resolves when **every** +recipient has answered, or when the deadline passes, whichever comes first. + +Nothing new is stored. The number of answers to expect is `len(Receivers)` on +the ask's own message, which is already persisted, and the answers themselves +are already persisted as messages carrying `in_reply_to`. Counting them is a +read of the conversation rather than a new table. + +The resume payload becomes a list: + +```go +// AskReply is what a resumed agent_ask returns to the model. +// +// Replies is plural because an ask may have gone to several agents, which +// is what a call for proposals is. A single-recipient ask carries exactly +// one entry, so nothing about the common case changes shape twice. +type AskReply struct { + Replies []AskReplyItem `json:"replies"` + // Complete says every recipient answered. False means the deadline + // arrived first and Replies holds whoever did. + Complete bool `json:"complete"` +} + +type AskReplyItem struct { + Performative string `json:"performative"` + Sender string `json:"sender"` + Content string `json:"content"` + ConversationID string `json:"conversation_id"` +} +``` + +That is a breaking change to the tool result an agent sees, and it is worth +taking now rather than shipping two shapes: an agent reading a single reply out +of a one-element list costs a prompt sentence, while a payload that changes +shape depending on recipient count costs every prompt forever. + +### 3.1 Partial answers are the normal case + +A participant that never answers is ordinary in Contract Net, not exceptional. +The deadline resolves the ask with whoever replied, `Complete` false, and the +initiator picks from what it got. An agent that asked three specialists and +heard from two should be able to proceed, and it can. + +### 3.2 Refuse no longer resolves early + +Today a `refuse` resolves a waiting ask. With several recipients that is wrong: +one participant declining a tender must not un-pause the initiator while the +others are still thinking. A refusal now counts as an answer from that +participant and nothing more. + +## 4. What Contract Net looks like from an agent + +No new tools. The protocol is the primitives used in the order the protocol +describes, which is the point: an interaction protocol is a convention, not a +mechanism. + +``` +1. agent_ask(to: ["a","b","c"], performative: "cfp", + content: "who can review this migration by 5pm?", + protocol: "fipa-contract-net") + -> the run suspends, and resumes with every proposal and refusal + +2. agent_send(to: ["b"], performative: "reject-proposal", ...) + agent_send(to: ["c"], performative: "reject-proposal", ...) + +3. agent_ask(to: "a", performative: "accept-proposal", + content: "yours please, by 5pm") + -> the run suspends again, and resumes with the winner's result +``` + +Step 3 is an ask rather than a send because the initiator wants the work, not +just an acknowledgement, and `accept-proposal` is already a directive. + +## 5. The Go surface + +A small package-level helper for hosts driving the protocol themselves rather +than through an agent's own reasoning: + +```go +// ContractNet announces a task, collects what comes back, and reports it. +// Awarding is deliberately left to the caller: choosing a contractor is a +// judgement, and this package has no business making it. +func ContractNet(ctx context.Context, b *Bus, p ContractNetParams) (*Tender, error) + +type ContractNetParams struct { + Initiator Address + Recipients []Address + Content string + Ontology string + ReplyBy *time.Time + AskerRunID id.AgentRunID + ToolCallID string +} + +// Tender is what a call for proposals came back with. +type Tender struct { + ConversationID id.ConversationID + ReplyWith string + Proposals []Proposal + Refusals []Proposal + Complete bool +} + +type Proposal struct { + From Address + Content string +} +``` + +`ProtocolContractNet = "fipa-contract-net"` is stamped on the envelope, so a +reader of a stored conversation can tell a tender from an ordinary exchange, and +a remote peer sees the protocol name the FIPA specification uses. + +## 6. Testing + +- Multi-recipient ask resolving on the last answer, and on the deadline with + whoever answered. +- A `refuse` from one participant not resolving an ask with three. +- A single-recipient ask still resolving on its one answer, with a one-element + list. +- Exactly-once resume under a race between the last answer and the deadline + sweep, which is the same claim discipline as before and needs to stay true + with several writers arriving at once. +- An end-to-end tender through the engine with a fake LLM: three participants, + two propose, one refuses, the initiator awards and gets the work back. + +## 7. Out of scope + +- **Iterated Contract Net** (FIPA00030), where the initiator counter-proposes + and rounds repeat. It is expressible with these primitives already; what it + would need from cortex is nothing. +- **Automatic award.** Cortex will not pick a winner. Choosing is a judgement, + and an agent or a host is what makes it. +- **Bid formats.** A proposal's content is text, like every other message. A + host that wants structured bids puts JSON in it and says so in the ontology. From c590bfefdb1469a2e5ef70d98598e18c7df46a87 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 21:23:14 -0500 Subject: [PATCH 41/50] feat(a2a): contract net, as an ask that waits for the whole field Nearly all of the protocol was already here: the four performatives are carried and routed correctly, and a tender is a conversation like any other. What was missing was an ask addressed to several agents, so this is that, plus the vocabulary to name a tender on the wire. An ask to one agent still resumes on that agent's answer. An ask to several waits for everyone, or for the deadline, because an initiator that resumed on the first proposal would be choosing before the rest had spoken. The count comes from the recipients on the ask's own message and the answers are the messages replying to it, so a tender keeps no state of its own. A refusal now counts as an answer rather than ending the round. In a tender, an agent that will not bid has told you what you needed to know about that agent. --- a2a/bus.go | 105 +++++++++-- a2a/bus_ask_test.go | 20 ++- a2a/bus_cancel_test.go | 4 +- a2a/bus_reply_test.go | 2 +- a2a/contractnet.go | 115 ++++++++++++ a2a/contractnet_test.go | 317 +++++++++++++++++++++++++++++++++ a2a/deliver.go | 34 +++- a2a/fakes_test.go | 3 + a2a/performative.go | 16 +- a2a/performative_test.go | 22 ++- a2a/sweep.go | 20 ++- a2a/sweep_test.go | 5 +- engine/a2a_contractnet_test.go | 200 +++++++++++++++++++++ engine/a2a_tools.go | 81 ++++++--- engine/a2a_tools_test.go | 48 +++++ engine/approval_store_test.go | 8 +- 16 files changed, 935 insertions(+), 65 deletions(-) create mode 100644 a2a/contractnet.go create mode 100644 a2a/contractnet_test.go create mode 100644 engine/a2a_contractnet_test.go diff --git a/a2a/bus.go b/a2a/bus.go index 733143d..d25bda6 100644 --- a/a2a/bus.go +++ b/a2a/bus.go @@ -326,9 +326,8 @@ func addressList(addrs []Address) string { // Ask errors. var ( - // ErrAskNeedsOneReceiver means Ask was given zero or several receivers. - // A durable ask correlates one reply to one waiting run. - ErrAskNeedsOneReceiver = errors.New("cortex: a2a: ask needs exactly one receiver") + // ErrAskNeedsReceivers means Ask was given nobody to ask. + ErrAskNeedsReceivers = errors.New("cortex: a2a: ask needs at least one receiver") // ErrAskNeedsDirective means the performative does not demand an answer, // so nothing would ever resume the asker. ErrAskNeedsDirective = errors.New("cortex: a2a: ask needs a directive performative") @@ -358,8 +357,8 @@ func (b *Bus) Ask(ctx context.Context, p AskParams) (*AskResult, error) { if p.Performative == "" { p.Performative = Request } - if len(p.Receivers) != 1 { - return nil, ErrAskNeedsOneReceiver + if len(p.Receivers) == 0 { + return nil, ErrAskNeedsReceivers } if c, ok := p.Performative.Class(); !ok || c != ClassDirective { return nil, ErrAskNeedsDirective @@ -400,23 +399,54 @@ func (b *Bus) Ask(ctx context.Context, p AskParams) (*AskResult, error) { } // AskReply is what a resumed agent_ask tool call returns to the model. +// +// Replies is plural because an ask may have gone to several agents at +// once, which is what a call for proposals is. A single-recipient ask +// carries exactly one entry, so the payload never changes shape with the +// number of recipients: an agent reads the same structure either way. type AskReply struct { + Replies []AskReplyItem `json:"replies"` + // Complete says every recipient answered. False means the deadline + // arrived first and Replies holds whoever did, which is an ordinary + // outcome for a tender rather than a failure. + Complete bool `json:"complete"` +} + +// AskReplyItem is one agent's answer. +type AskReplyItem struct { Performative string `json:"performative"` Sender string `json:"sender"` Content string `json:"content"` ConversationID string `json:"conversation_id"` } -// resolveAsk matches an inbound reply to a waiting ask and resumes it, -// reporting whether a run was resumed. +// resolveAsk matches an inbound reply to a waiting ask and resumes it +// once the ask has everything it was waiting for, reporting whether a run +// was resumed. +// +// An ask addressed to one agent resumes on that agent's answer. An ask +// addressed to several waits for the whole field, because an initiator +// that resumed on the first proposal would be choosing before the others +// had spoken, which is the opposite of what a tender is for. // // The claim happens before the resume, and that ordering is the design // rather than a precaution: a late reply, the deadline sweep and a cancel // are three writers racing for one row, and only the winner may resume. func (b *Bus) resolveAsk(ctx context.Context, e *Envelope) (bool, error) { - if e.InReplyTo == "" || !e.Performative.ResolvesAsk() { + if e.InReplyTo == "" || !e.Performative.AnswersAsk() { + return false, nil + } + + ready, replies, err := b.tallyAnswers(ctx, e) + if err != nil { + return false, err + } + if !ready { + // Somebody is still to answer. The message is stored either way, + // so nothing is lost by waiting for them. return false, nil } + ask, err := b.store.ClaimPendingAsk(ctx, e.InReplyTo) switch { case errors.Is(err, ErrAskNotFound), errors.Is(err, ErrAskAlreadyClaimed): @@ -428,12 +458,7 @@ func (b *Bus) resolveAsk(ctx context.Context, e *Envelope) (bool, error) { return false, nil } - payload, err := json.Marshal(AskReply{ - Performative: string(e.Performative), - Sender: e.Sender.String(), - Content: e.Content, - ConversationID: e.ConversationID.String(), - }) + payload, err := json.Marshal(AskReply{Replies: replies, Complete: true}) if err != nil { return false, err } @@ -443,6 +468,58 @@ func (b *Bus) resolveAsk(ctx context.Context, e *Envelope) (bool, error) { return true, nil } +// tallyAnswers reads what an ask has collected so far and says whether +// that is everyone. +// +// The expected count is the recipient count on the ask's own message, and +// the answers are the messages carrying its reply-with token. Both are +// stored already, so a tender keeps no state of its own: the count is a +// read of the conversation rather than a table to hold in step with it. +func (b *Bus) tallyAnswers(ctx context.Context, latest *Envelope) (bool, []AskReplyItem, error) { + msgs, err := b.store.ListMessages(ctx, &MessageListFilter{ConversationID: latest.ConversationID}) + if err != nil { + return false, nil, err + } + + var expected int + replies := make([]AskReplyItem, 0, len(msgs)) + answered := make(map[string]bool, len(msgs)) + + for _, m := range msgs { + if m.ReplyWith == latest.InReplyTo { + expected = len(m.Receivers) + continue + } + if m.InReplyTo != latest.InReplyTo || !m.Performative.AnswersAsk() { + continue + } + // One answer per agent. A participant that says two things has + // still answered once, and counting both would resume an + // initiator while somebody else was still thinking. + if answered[m.Sender.String()] { + continue + } + answered[m.Sender.String()] = true + replies = append(replies, askReplyItem(m)) + } + + if expected == 0 { + // The ask's own message is not in this conversation, so this + // reply answers something else. Ordinary mail. + return false, nil, nil + } + return len(replies) >= expected, replies, nil +} + +func askReplyItem(e *Envelope) AskReplyItem { + return AskReplyItem{ + Performative: string(e.Performative), + Sender: e.Sender.String(), + Content: e.Content, + ConversationID: e.ConversationID.String(), + } +} + // InboxItem is one delivered message as an agent sees it. type InboxItem struct { DeliveryID string `json:"delivery_id"` diff --git a/a2a/bus_ask_test.go b/a2a/bus_ask_test.go index c95eb51..a6dddd9 100644 --- a/a2a/bus_ask_test.go +++ b/a2a/bus_ask_test.go @@ -68,20 +68,26 @@ func TestAskDefaultsToRequest(t *testing.T) { } } -func TestAskRefusesMoreThanOneReceiver(t *testing.T) { +// Several receivers is a call for proposals, which is a tender rather +// than a mistake. +func TestAskAcceptsMoreThanOneReceiver(t *testing.T) { b, _, _, _, _, _ := newTestBus(t) ctx := testCtx() - _, err := b.Ask(ctx, AskParams{ + res, err := b.Ask(ctx, AskParams{ SendParams: SendParams{ - Sender: Address{Agent: "planner"}, - Receivers: []Address{{Agent: "w1"}, {Agent: "w2"}}, - Content: "?", + Sender: Address{Agent: "planner"}, + Receivers: []Address{{Agent: "w1"}, {Agent: "w2"}}, + Performative: CFP, + Content: "who can take this?", }, AskerRunID: id.NewAgentRunID(), ToolCallID: "c1", }) - if !errorsIs(err, ErrAskNeedsOneReceiver) { - t.Fatalf("err = %v, want ErrAskNeedsOneReceiver", err) + if err != nil { + t.Fatalf("Ask: %v", err) + } + if res.ReplyWith == "" { + t.Fatal("a tender needs a token to collect answers against") } } diff --git a/a2a/bus_cancel_test.go b/a2a/bus_cancel_test.go index 69232b0..ee00e7b 100644 --- a/a2a/bus_cancel_test.go +++ b/a2a/bus_cancel_test.go @@ -56,8 +56,8 @@ func TestCancelClosesTheConversationAndFailsWaitingAsks(t *testing.T) { if err := json.Unmarshal([]byte(resumer.last().Result), &payload); err != nil { t.Fatalf("unmarshal resume payload: %v", err) } - if payload.Performative != string(Failure) { - t.Fatalf("a cancelled ask must resume with a failure, got %s", payload.Performative) + if !lastReplyContains(payload, "cancelled") { + t.Fatalf("a cancelled ask must resume saying so: %+v", payload.Replies) } } diff --git a/a2a/bus_reply_test.go b/a2a/bus_reply_test.go index 02fdf66..9d55768 100644 --- a/a2a/bus_reply_test.go +++ b/a2a/bus_reply_test.go @@ -38,7 +38,7 @@ func TestReplyResumesTheWaitingRun(t *testing.T) { if err := json.Unmarshal([]byte(got.Result), &payload); err != nil { t.Fatalf("the resume result must be JSON the tool can return: %v", err) } - if payload.Content != "all clear" || payload.Performative != string(Inform) || payload.Sender != "w1" { + if firstReply(t, payload).Content != "all clear" || firstReply(t, payload).Performative != string(Inform) || firstReply(t, payload).Sender != "w1" { t.Fatalf("reply payload is wrong: %+v", payload) } } diff --git a/a2a/contractnet.go b/a2a/contractnet.go new file mode 100644 index 0000000..7aff4c7 --- /dev/null +++ b/a2a/contractnet.go @@ -0,0 +1,115 @@ +package a2a + +import ( + "context" + "time" + + "github.com/xraph/cortex/id" +) + +// The FIPA interaction protocol names, as they travel on Envelope.Protocol. +// +// Stamping one lets a reader of a stored conversation tell a tender from +// an ordinary exchange, and gives a remote peer the name the FIPA +// specification uses rather than one cortex made up. +const ( + ProtocolContractNet = "fipa-contract-net" + ProtocolRequest = "fipa-request" + ProtocolQuery = "fipa-query" +) + +// Proposal is one participant's answer to a call for proposals. +type Proposal struct { + From Address `json:"from"` + Content string `json:"content"` +} + +// Tender is the state of one call for proposals. +type Tender struct { + ConversationID id.ConversationID `json:"conversation_id"` + ReplyWith string `json:"reply_with"` + Proposals []Proposal `json:"proposals"` + Refusals []Proposal `json:"refusals"` + // Complete says every agent the call went to has answered. + Complete bool `json:"complete"` +} + +// ContractNetParams is one call for proposals. +type ContractNetParams struct { + Initiator Address + Recipients []Address + Content string + Ontology string + ReplyBy *time.Time + AskerRunID id.AgentRunID + ToolCallID string +} + +// ContractNet announces a task to several agents at once. +// +// It sends the call and returns the handle to collect against; it does +// not wait, and it does not choose. Waiting is what the asking run's own +// suspension does, and choosing is a judgement this package has no +// business making. An initiator picks its contractor and then awards with +// accept-proposal, which is an ordinary directive. +// +// This is a convenience for hosts driving a tender in Go. An agent needs +// nothing from here: a call for proposals is agent_ask with several +// recipients and the cfp performative, which is the whole point of +// building the protocol out of the primitives rather than beside them. +func ContractNet(ctx context.Context, b *Bus, p ContractNetParams) (*Tender, error) { + res, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: p.Initiator, + Receivers: p.Recipients, + Performative: CFP, + Content: p.Content, + Ontology: p.Ontology, + Protocol: ProtocolContractNet, + ReplyBy: p.ReplyBy, + }, + AskerRunID: p.AskerRunID, + ToolCallID: p.ToolCallID, + }) + if err != nil { + return nil, err + } + return &Tender{ConversationID: res.ConversationID, ReplyWith: res.ReplyWith}, nil +} + +// CollectTender reads what a call for proposals has gathered so far, +// separating the bids from the declines. +// +// It reads the conversation rather than any state of its own, so it is +// safe to call at any point and after a restart. +func CollectTender(ctx context.Context, b *Bus, convID id.ConversationID, replyWith string) (*Tender, error) { + msgs, err := b.store.ListMessages(ctx, &MessageListFilter{ConversationID: convID}) + if err != nil { + return nil, err + } + + tender := &Tender{ConversationID: convID, ReplyWith: replyWith} + var expected int + seen := make(map[string]bool, len(msgs)) + + for _, m := range msgs { + if m.ReplyWith == replyWith { + expected = len(m.Receivers) + continue + } + if m.InReplyTo != replyWith || seen[m.Sender.String()] { + continue + } + switch m.Performative { + case Propose: + seen[m.Sender.String()] = true + tender.Proposals = append(tender.Proposals, Proposal{From: m.Sender, Content: m.Content}) + case Refuse: + seen[m.Sender.String()] = true + tender.Refusals = append(tender.Refusals, Proposal{From: m.Sender, Content: m.Content}) + } + } + + tender.Complete = expected > 0 && len(seen) >= expected + return tender, nil +} diff --git a/a2a/contractnet_test.go b/a2a/contractnet_test.go new file mode 100644 index 0000000..4fdc775 --- /dev/null +++ b/a2a/contractnet_test.go @@ -0,0 +1,317 @@ +package a2a + +import ( + "encoding/json" + "strings" + "testing" + "time" + + "github.com/xraph/cortex/id" +) + +// firstReply is the one-answer case every single-recipient test reads. +func firstReply(t *testing.T, payload AskReply) AskReplyItem { + t.Helper() + if len(payload.Replies) == 0 { + t.Fatalf("the payload carries no replies: %+v", payload) + } + return payload.Replies[0] +} + +// lastReplyContains looks for text in any answer, which is what a +// timeout or a failure test wants: the reason is appended after whatever +// had already arrived. +func lastReplyContains(payload AskReply, text string) bool { + for _, r := range payload.Replies { + if strings.Contains(r.Content, text) { + return true + } + } + return false +} + +func decodeAskReply(t *testing.T, raw string) AskReply { + t.Helper() + var payload AskReply + if err := json.Unmarshal([]byte(raw), &payload); err != nil { + t.Fatalf("the resume result must be JSON the tool can return: %v", err) + } + return payload +} + +// A call for proposals goes to several agents at once, which is exactly +// what the one-receiver rule used to forbid. +func TestAskAcceptsSeveralReceivers(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + res, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "initiator"}, + Receivers: []Address{{Agent: "w1"}, {Agent: "w2"}, {Agent: "w3"}}, + Performative: CFP, + Content: "who can review this migration?", + Protocol: ProtocolContractNet, + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + + msg, err := st.GetMessage(ctx, res.MessageID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if len(msg.Receivers) != 3 { + t.Fatalf("the cfp reached %d agents, want 3", len(msg.Receivers)) + } + if msg.Protocol != ProtocolContractNet { + t.Errorf("protocol = %q, want the FIPA name so a reader can tell a tender from a chat", msg.Protocol) + } +} + +// The initiator waits for the whole field, not the first one back. +func TestAskWithSeveralReceiversResolvesOnTheLastAnswer(t *testing.T) { + b, _, _, resumer, _, _ := newTestBus(t) + ctx := testCtx() + + ask, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "initiator"}, Receivers: []Address{{Agent: "w1"}, {Agent: "w2"}}, + Performative: CFP, Content: "who can take this?", Protocol: ProtocolContractNet, + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + + reply := func(from, content string, p Performative) { + t.Helper() + if _, sendErr := b.Send(ctx, SendParams{ + Sender: Address{Agent: from}, Receivers: []Address{{Agent: "initiator"}}, + Performative: p, Content: content, + ConversationID: ask.ConversationID, InReplyTo: ask.ReplyWith, + }); sendErr != nil { + t.Fatalf("reply from %s: %v", from, sendErr) + } + } + + reply("w1", "I can, by 5pm", Propose) + if resumer.count() != 0 { + t.Fatal("the initiator resumed before the field had answered") + } + + reply("w2", "I can, by 3pm", Propose) + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want 1 once everyone answered", resumer.count()) + } + + payload := decodeAskReply(t, resumer.last().Result) + if !payload.Complete { + t.Error("every recipient answered, so the tender is complete") + } + if len(payload.Replies) != 2 { + t.Fatalf("got %d replies, want both proposals", len(payload.Replies)) + } +} + +// One participant declining must not un-pause an initiator that is still +// waiting on the others. +func TestRefuseDoesNotResolveAMultiRecipientAsk(t *testing.T) { + b, _, _, resumer, _, _ := newTestBus(t) + ctx := testCtx() + + ask, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "initiator"}, Receivers: []Address{{Agent: "w1"}, {Agent: "w2"}}, + Performative: CFP, Content: "anyone?", Protocol: ProtocolContractNet, + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + + if _, refuseErr := b.Send(ctx, SendParams{ + Sender: Address{Agent: "w1"}, Receivers: []Address{{Agent: "initiator"}}, + Performative: Refuse, Content: "too busy", + ConversationID: ask.ConversationID, InReplyTo: ask.ReplyWith, + }); refuseErr != nil { + t.Fatalf("refusal: %v", refuseErr) + } + if resumer.count() != 0 { + t.Fatal("one refusal ended a tender the others were still answering") + } + + if _, proposeErr := b.Send(ctx, SendParams{ + Sender: Address{Agent: "w2"}, Receivers: []Address{{Agent: "initiator"}}, + Performative: Propose, Content: "I can do it", + ConversationID: ask.ConversationID, InReplyTo: ask.ReplyWith, + }); proposeErr != nil { + t.Fatalf("proposal: %v", proposeErr) + } + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want 1", resumer.count()) + } + + payload := decodeAskReply(t, resumer.last().Result) + if len(payload.Replies) != 2 { + t.Fatalf("got %d replies, want the refusal and the proposal", len(payload.Replies)) + } + var sawRefusal bool + for _, r := range payload.Replies { + if r.Performative == string(Refuse) { + sawRefusal = true + } + } + if !sawRefusal { + t.Error("a refusal is an answer and belongs in the result the initiator reads") + } +} + +// A participant that never answers is ordinary in a tender. The deadline +// resolves with whoever did. +func TestAMultiRecipientAskResolvesOnItsDeadlineWithPartialAnswers(t *testing.T) { + b, _, _, resumer, _, clk := newTestBus(t) + ctx := testCtx() + + ask, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{ + Sender: Address{Agent: "initiator"}, Receivers: []Address{{Agent: "w1"}, {Agent: "w2"}, {Agent: "w3"}}, + Performative: CFP, Content: "anyone?", Protocol: ProtocolContractNet, + }, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("Ask: %v", err) + } + + if _, proposeErr := b.Send(ctx, SendParams{ + Sender: Address{Agent: "w1"}, Receivers: []Address{{Agent: "initiator"}}, + Performative: Propose, Content: "I can", + ConversationID: ask.ConversationID, InReplyTo: ask.ReplyWith, + }); proposeErr != nil { + t.Fatalf("proposal: %v", proposeErr) + } + + clk.advance(DefaultReplyBy + time.Minute) + n, err := b.SweepExpiredAsks(ctx) + if err != nil { + t.Fatalf("SweepExpiredAsks: %v", err) + } + if n != 1 { + t.Fatalf("swept %d, want the overdue tender", n) + } + + payload := decodeAskReply(t, resumer.last().Result) + if payload.Complete { + t.Error("two of three never answered, so the tender is not complete") + } + // Whoever answered comes back, and the reason is appended after them. + // An initiator that heard from one of three should get that one + // rather than an empty result and a sentence. + var sawProposal bool + for _, r := range payload.Replies { + if r.Content == "I can" && r.Performative == string(Propose) { + sawProposal = true + } + } + if !sawProposal { + t.Fatalf("the initiator must get whoever did answer: %+v", payload.Replies) + } + if !lastReplyContains(payload, "deadline") { + t.Fatalf("the initiator must be told why it stopped waiting: %+v", payload.Replies) + } +} + +// The single-recipient case still works, and carries one entry rather +// than changing shape. +func TestASingleRecipientAskCarriesOneReply(t *testing.T) { + b, st, runner, resumer, _, _ := newTestBus(t) + ctx := testCtx() + runner.setOutput("w1", "all clear") + + if _, err := b.Ask(ctx, AskParams{ + SendParams: SendParams{Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, Content: "status?"}, + AskerRunID: id.NewAgentRunID(), ToolCallID: "call-1", + }); err != nil { + t.Fatalf("Ask: %v", err) + } + queued, _ := st.ListQueuedDeliveries(ctx, 10) + if err := b.deliverOne(ctx, queued[0].ID); err != nil { + t.Fatalf("deliverOne: %v", err) + } + + if resumer.count() != 1 { + t.Fatalf("resumed %d times, want 1", resumer.count()) + } + payload := decodeAskReply(t, resumer.last().Result) + if !payload.Complete || len(payload.Replies) != 1 { + t.Fatalf("payload = %+v", payload) + } + if payload.Replies[0].Content != "all clear" { + t.Fatalf("content = %q", payload.Replies[0].Content) + } +} + +func TestAskStillNeedsAtLeastOneReceiver(t *testing.T) { + b, _, _, _, _, _ := newTestBus(t) + _, err := b.Ask(testCtx(), AskParams{ + SendParams: SendParams{Sender: Address{Agent: "planner"}, Content: "?"}, + AskerRunID: id.NewAgentRunID(), ToolCallID: "c1", + }) + if !errorsIs(err, ErrAskNeedsReceivers) { + t.Fatalf("err = %v, want ErrAskNeedsReceivers", err) + } +} + +// The Go helper is for hosts driving a tender themselves. It collects and +// reports; it does not choose, because choosing is a judgement. +func TestContractNetHelperSeparatesProposalsFromRefusals(t *testing.T) { + b, _, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + tender, err := ContractNet(ctx, b, ContractNetParams{ + Initiator: Address{Agent: "initiator"}, + Recipients: []Address{{Agent: "w1"}, {Agent: "w2"}}, + Content: "who can take this?", + AskerRunID: id.NewAgentRunID(), + ToolCallID: "call-1", + }) + if err != nil { + t.Fatalf("ContractNet: %v", err) + } + if tender.ReplyWith == "" || tender.ConversationID.IsNil() { + t.Fatalf("a tender needs a handle to collect against: %+v", tender) + } + + for _, r := range []struct { + from string + p Performative + text string + }{ + {"w1", Propose, "I can, by 5pm"}, + {"w2", Refuse, "not today"}, + } { + if _, replyErr := b.Send(ctx, SendParams{ + Sender: Address{Agent: r.from}, Receivers: []Address{{Agent: "initiator"}}, + Performative: r.p, Content: r.text, + ConversationID: tender.ConversationID, InReplyTo: tender.ReplyWith, + }); replyErr != nil { + t.Fatalf("reply from %s: %v", r.from, replyErr) + } + } + + collected, err := CollectTender(ctx, b, tender.ConversationID, tender.ReplyWith) + if err != nil { + t.Fatalf("CollectTender: %v", err) + } + if len(collected.Proposals) != 1 || collected.Proposals[0].From.Agent != "w1" { + t.Fatalf("proposals = %+v", collected.Proposals) + } + if len(collected.Refusals) != 1 || collected.Refusals[0].From.Agent != "w2" { + t.Fatalf("refusals = %+v", collected.Refusals) + } +} diff --git a/a2a/deliver.go b/a2a/deliver.go index 80495eb..a06047e 100644 --- a/a2a/deliver.go +++ b/a2a/deliver.go @@ -161,6 +161,28 @@ func (b *Bus) handleControl(ctx context.Context, e *Envelope) error { return b.store.UpdateConversation(ctx, conv) } +// collected reads the answers an ask already has, so a failure or a +// timeout hands back what arrived rather than throwing it away. +func (b *Bus) collected(ctx context.Context, ask *PendingAsk) ([]AskReplyItem, error) { + if ask.ConversationID.IsNil() { + return nil, nil + } + msgs, err := b.store.ListMessages(ctx, &MessageListFilter{ConversationID: ask.ConversationID}) + if err != nil { + return nil, err + } + out := make([]AskReplyItem, 0, len(msgs)) + seen := make(map[string]bool, len(msgs)) + for _, m := range msgs { + if m.InReplyTo != ask.ReplyWith || !m.Performative.AnswersAsk() || seen[m.Sender.String()] { + continue + } + seen[m.Sender.String()] = true + out = append(out, askReplyItem(m)) + } + return out, nil +} + // resolveAskWithFailure un-pauses a waiting ask with a failure the asking // agent can read: a timeout, a cancelled conversation, a reply that could // not be sent. It is the sweep's and the cancel path's way in. @@ -178,12 +200,22 @@ func (b *Bus) resolveAskWithFailure(ctx context.Context, replyWith, reason strin if b.resumer == nil { return nil } - payload, err := json.Marshal(AskReply{ + // A failure carries whatever the ask had collected, plus the reason. + // An initiator whose tender timed out with two proposals in hand + // should get those two, not an empty result and a sentence. + // A read failure here costs the collected answers, not the resume: an + // asker told nothing is worse than an asker told only the reason. + replies, collectErr := b.collected(ctx, ask) + if collectErr != nil { + replies = nil + } + replies = append(replies, AskReplyItem{ Performative: string(Failure), Sender: ask.Expected.String(), Content: reason, ConversationID: ask.ConversationID.String(), }) + payload, err := json.Marshal(AskReply{Replies: replies, Complete: false}) if err != nil { return err } diff --git a/a2a/fakes_test.go b/a2a/fakes_test.go index ebc8a4f..de89fe3 100644 --- a/a2a/fakes_test.go +++ b/a2a/fakes_test.go @@ -65,6 +65,9 @@ func (f *fakeRunner) setErr(err error) { f.err = err } +// what stops the next test having to change the double. +// +//nolint:unparam // every current caller answers as w1; the parameter is func (f *fakeRunner) setOutput(agent, out string) { f.mu.Lock() defer f.mu.Unlock() diff --git a/a2a/performative.go b/a2a/performative.go index 0a14d22..0f261fa 100644 --- a/a2a/performative.go +++ b/a2a/performative.go @@ -97,14 +97,20 @@ func (p Performative) Valid() bool { return ok } -// ResolvesAsk reports whether a reply carrying p un-pauses a waiting ask. +// AnswersAsk reports whether a reply carrying p counts as an answer to a +// waiting ask. // // agree is deliberately excluded. It means the peer accepted the task and -// is still working on it, so an asker that treated it as an answer would -// resume on a message carrying no answer. -func (p Performative) ResolvesAsk() bool { +// is still working on it, so an asker that counted it would resume on a +// message carrying no answer. +// +// refuse and reject-proposal DO count. Declining is an answer: in a +// tender, a participant that will not bid has told the initiator what it +// needed to know about that participant. +func (p Performative) AnswersAsk() bool { switch p { - case Inform, InformIf, InformRef, Confirm, Disconfirm, Refuse, Failure, NotUnderstood, RejectProposal: + case Inform, InformIf, InformRef, Confirm, Disconfirm, + Refuse, Failure, NotUnderstood, RejectProposal, Propose: return true default: return false diff --git a/a2a/performative_test.go b/a2a/performative_test.go index f6788e1..95e6593 100644 --- a/a2a/performative_test.go +++ b/a2a/performative_test.go @@ -70,16 +70,24 @@ func TestUnknownPerformativeIsNotClassified(t *testing.T) { // ResolvesAsk is the pair most likely to be got backwards: agree means the // peer took the job and is still working, so it must not un-pause the asker. -func TestResolvesAsk(t *testing.T) { - resolving := []Performative{Inform, InformIf, InformRef, Confirm, Disconfirm, Refuse, Failure, NotUnderstood, RejectProposal} - for _, p := range resolving { - if !p.ResolvesAsk() { - t.Errorf("%s should resolve a waiting ask", p) +func TestAnswersAsk(t *testing.T) { + // propose is an answer to a call for proposals, and refusing is an + // answer too: a participant that will not bid has told the initiator + // what it needed to know about that participant. + answering := []Performative{ + Inform, InformIf, InformRef, Confirm, Disconfirm, + Refuse, Failure, NotUnderstood, RejectProposal, Propose, + } + for _, p := range answering { + if !p.AnswersAsk() { + t.Errorf("%s should count as an answer", p) } } + // agree means "working on it", so counting it would resume an asker + // on a message carrying no answer. for _, p := range []Performative{Agree, Subscribe, Request, CFP} { - if p.ResolvesAsk() { - t.Errorf("%s must not resolve a waiting ask", p) + if p.AnswersAsk() { + t.Errorf("%s must not count as an answer", p) } } } diff --git a/a2a/sweep.go b/a2a/sweep.go index 1b09420..67edf65 100644 --- a/a2a/sweep.go +++ b/a2a/sweep.go @@ -24,11 +24,27 @@ func (b *Bus) SweepExpiredAsks(ctx context.Context) (int, error) { } var n int for _, a := range asks { - reason := fmt.Sprintf("no reply from %s before the deadline", a.Expected) - if err := b.resolveAskWithFailure(ctx, a.ReplyWith, reason); err != nil { + if err := b.resolveAskWithFailure(ctx, a.ReplyWith, b.timeoutReason(ctx, a)); err != nil { return n, err } n++ } return n, nil } + +// timeoutReason phrases an overdue ask for the agent that has to read it. +// +// Naming one agent is right when only one was asked and wrong when +// several were: an initiator whose tender timed out with two of three +// bids in hand should not be told that a particular agent went quiet, as +// though it were the only one it was waiting on. +func (b *Bus) timeoutReason(ctx context.Context, a *PendingAsk) string { + if a.MessageID.IsNil() { + return "no reply before the deadline" + } + e, err := b.store.GetMessage(ctx, a.MessageID) + if err != nil || len(e.Receivers) <= 1 { + return fmt.Sprintf("no reply from %s before the deadline", a.Expected) + } + return "not every agent answered before the deadline" +} diff --git a/a2a/sweep_test.go b/a2a/sweep_test.go index 389467d..5fa63c5 100644 --- a/a2a/sweep_test.go +++ b/a2a/sweep_test.go @@ -2,7 +2,6 @@ package a2a import ( "encoding/json" - "strings" "testing" "time" @@ -48,8 +47,8 @@ func TestSweepResolvesAnOverdueAskIntoAFailure(t *testing.T) { if err := json.Unmarshal([]byte(resumer.last().Result), &payload); err != nil { t.Fatalf("unmarshal: %v", err) } - if payload.Performative != string(Failure) || !strings.Contains(payload.Content, "deadline") { - t.Fatalf("a swept ask must resume with a timeout failure, got %+v", payload) + if !lastReplyContains(payload, "deadline") { + t.Fatalf("a swept ask must resume with a timeout failure, got %+v", payload.Replies) } } diff --git a/engine/a2a_contractnet_test.go b/engine/a2a_contractnet_test.go new file mode 100644 index 0000000..e5f1148 --- /dev/null +++ b/engine/a2a_contractnet_test.go @@ -0,0 +1,200 @@ +package engine + +import ( + "context" + "encoding/json" + "errors" + "strings" + "sync" + "testing" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/llm" + "github.com/xraph/cortex/run" +) + +// tenderLLM plays every part in a contract net: the initiator that calls +// for proposals and then awards, and three participants that bid, +// decline, or bid worse. +type tenderLLM struct { + mu sync.Mutex + stage int + awarded chan struct{} + once sync.Once +} + +func (l *tenderLLM) Complete(_ context.Context, req *llm.Request) (*llm.Response, error) { + l.mu.Lock() + defer l.mu.Unlock() + + system := req.System + switch { + case strings.Contains(system, "fast-reviewer"): + return &llm.Response{Content: "I can review it by 3pm"}, nil + case strings.Contains(system, "slow-reviewer"): + return &llm.Response{Content: "I can review it by 9pm"}, nil + case strings.Contains(system, "busy-reviewer"): + return &llm.Response{Content: "not today, I am full"}, nil + } + + // The initiator's turns, in order. + l.stage++ + switch l.stage { + case 1: + return &llm.Response{ToolCalls: []llm.ToolCall{{ + ID: "cfp-1", + Name: toolAgentAsk, + Arguments: `{"to":["fast-reviewer","slow-reviewer","busy-reviewer"],` + + `"performative":"cfp","protocol":"fipa-contract-net",` + + `"content":"who can review the migration today?"}`, + }}}, nil + case 2: + // Resumed with the field's answers. Award to the fastest. + return &llm.Response{ToolCalls: []llm.ToolCall{{ + ID: "award-1", + Name: toolAgentSend, + Arguments: `{"to":["fast-reviewer"],"performative":"accept-proposal",` + + `"protocol":"fipa-contract-net","content":"yours, by 3pm please"}`, + }}}, nil + default: + l.once.Do(func() { close(l.awarded) }) + return &llm.Response{Content: "awarded to fast-reviewer"}, nil + } +} + +func (l *tenderLLM) CompleteStream(context.Context, *llm.Request) (llm.Stream, error) { + return nil, errors.New("not supported") +} + +// TestContractNetEndToEnd runs a whole tender: a call for proposals to +// three agents, two bids and a refusal, and an award to the one the +// initiator picked. +// +// The protocol is the primitives in the order FIPA describes, so this +// test is really asking whether those primitives compose the way the +// design claims they do. +func TestContractNetEndToEnd(t *testing.T) { + ctx := cortex.WithScope(context.Background(), cortex.Scope{ + Levels: []cortex.Level{{Key: "workspace", Value: "ws_x"}}, + }) + st := newApprovalStore(ctx, t) + model := &tenderLLM{awarded: make(chan struct{})} + + e, err := New( + WithStore(st), + WithLLM(model), + WithA2A(a2a.Options{HopCeiling: 12, Workers: 1}), + ) + if err != nil { + t.Fatalf("New: %v", err) + } + for _, name := range []string{"initiator", "fast-reviewer", "slow-reviewer", "busy-reviewer"} { + if createErr := e.CreateAgent(ctx, &agent.Config{ + ID: id.NewAgentID(), Name: name, SystemPrompt: "you are the " + name, + Model: "test-model", MaxSteps: 6, + }); createErr != nil { + t.Fatalf("CreateAgent %s: %v", name, createErr) + } + } + + // 1. The call for proposals goes out, and the initiator stops. + paused, err := e.RunAgent(ctx, "initiator", "get the migration reviewed", nil) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + if paused.State != run.StatePaused { + t.Fatalf("state = %s, want paused while the field answers", paused.State) + } + + // 2. Everyone answers, and the initiator resumes once they all have. + if _, drainErr := e.A2A().Drain(ctx); drainErr != nil { + t.Fatalf("Drain: %v", drainErr) + } + + final, err := st.GetRun(ctx, paused.ID) + if err != nil { + t.Fatalf("GetRun: %v", err) + } + if final.State != run.StateCompleted { + t.Fatalf("state = %s, want completed (error: %q)", final.State, final.Error) + } + + // The tender came back as one result carrying every answer. + payload := askPayload(ctx, t, st, paused.ID) + if !payload.Complete { + t.Errorf("all three answered, so the tender is complete: %+v", payload) + } + if len(payload.Replies) != 3 { + t.Fatalf("got %d answers, want three: %+v", len(payload.Replies), payload.Replies) + } + + var bids, declines int + for _, r := range payload.Replies { + switch { + case strings.Contains(r.Content, "I can review"): + bids++ + case strings.Contains(r.Content, "not today"): + declines++ + } + } + if bids != 2 || declines != 1 { + t.Fatalf("got %d bids and %d declines, want 2 and 1: %+v", bids, declines, payload.Replies) + } + + // 3. The award reached the winner and nobody else. + msgs, err := st.ListMessages(ctx, &a2a.MessageListFilter{Limit: 20}) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + var awards int + for _, m := range msgs { + if m.Performative != a2a.AcceptProposal { + continue + } + awards++ + if len(m.Receivers) != 1 || m.Receivers[0].Agent != "fast-reviewer" { + t.Errorf("the award went to %+v, want the agent the initiator picked", m.Receivers) + } + if m.Protocol != a2a.ProtocolContractNet { + t.Errorf("the award is not stamped as part of the tender: %q", m.Protocol) + } + } + if awards != 1 { + t.Fatalf("%d awards were sent, want exactly 1", awards) + } +} + +// askPayload reads the tender's result out of the tool call it came back +// on, which is where the initiator's model actually saw it. +func askPayload(ctx context.Context, t *testing.T, st interface { + ListSteps(context.Context, id.AgentRunID) ([]*run.Step, error) + ListToolCalls(context.Context, id.StepID) ([]*run.ToolCall, error) +}, runID id.AgentRunID, +) a2a.AskReply { + t.Helper() + steps, err := st.ListSteps(ctx, runID) + if err != nil { + t.Fatalf("ListSteps: %v", err) + } + for _, s := range steps { + calls, callErr := st.ListToolCalls(ctx, s.ID) + if callErr != nil { + t.Fatalf("ListToolCalls: %v", callErr) + } + for _, c := range calls { + if c.ToolName != toolAgentAsk { + continue + } + var payload a2a.AskReply + if err := json.Unmarshal([]byte(c.Result), &payload); err != nil { + t.Fatalf("the ask result is not an AskReply: %v (%q)", err, c.Result) + } + return payload + } + } + t.Fatal("the tender never came back on a tool call") + return a2a.AskReply{} +} diff --git a/engine/a2a_tools.go b/engine/a2a_tools.go index 7446c7a..369d45a 100644 --- a/engine/a2a_tools.go +++ b/engine/a2a_tools.go @@ -36,9 +36,8 @@ func (e *Engine) a2aTools() []llm.Tool { "type": "object", "properties": map[string]any{ "to": map[string]any{ - "type": "array", - "items": map[string]any{"type": "string"}, - "description": "Names of the agents to send to", + "description": "The agent to send to, or a list of them. " + + "Address a remote agent as name@node.", }, "content": map[string]any{ "type": "string", @@ -48,7 +47,9 @@ func (e *Engine) a2aTools() []llm.Tool { "type": "string", "description": "The FIPA-ACL speech act. Defaults to inform. " + "Use inform to tell, confirm/disconfirm to answer a query, " + - "refuse to decline, failure to report that something went wrong, " + + "propose to bid on a cfp, refuse to decline it, " + + "reject-proposal to turn down a bid you did not pick, " + + "failure to report that something went wrong, " + "cancel to end a conversation.", }, "conversation_id": map[string]any{ @@ -65,15 +66,17 @@ func (e *Engine) a2aTools() []llm.Tool { }, { Name: toolAgentAsk, - Description: "Ask another agent something and wait for the answer. " + - "Your run pauses until they reply, and their answer comes back as this tool's result. " + - "The wait survives a restart, so use this whenever you genuinely need their answer.", + Description: "Ask one or more agents something and wait for the answer. " + + "Your run pauses until they reply, and their answers come back as this tool's result. " + + "Ask several at once with a cfp to put work out to tender: you get every proposal and " + + "refusal back together, and you pick. The wait survives a restart, so use this " + + "whenever you genuinely need an answer before carrying on.", Parameters: map[string]any{ "type": "object", "properties": map[string]any{ "to": map[string]any{ - "type": "string", - "description": "Name of the agent to ask", + "description": "The agent to ask, or a list of agents to put the question to at once. " + + "Address a remote agent as name@node.", }, "content": map[string]any{ "type": "string", @@ -83,7 +86,14 @@ func (e *Engine) a2aTools() []llm.Tool { "type": "string", "description": "The FIPA-ACL speech act. Defaults to request. " + "Use request to ask for work, query-if to ask whether something holds, " + - "query-ref to ask for a value, cfp to invite proposals, propose to offer one.", + "query-ref to ask for a value, cfp to invite proposals from several agents, " + + "accept-proposal to award work to whoever you picked.", + }, + "protocol": map[string]any{ + "type": "string", + "description": "Optional interaction protocol name. Use fipa-contract-net when " + + "you are running a tender: cfp to everyone, then accept-proposal to the winner " + + "and reject-proposal to the rest.", }, "conversation_id": map[string]any{ "type": "string", @@ -138,19 +148,44 @@ func (e *Engine) executeA2ATool(ctx context.Context, inv cortex.Invocation) (str } type agentSendArgs struct { - To []string `json:"to"` - Content string `json:"content"` - Performative string `json:"performative"` - ConversationID string `json:"conversation_id"` - Ontology string `json:"ontology"` + To recipients `json:"to"` + Content string `json:"content"` + Performative string `json:"performative"` + ConversationID string `json:"conversation_id"` + Ontology string `json:"ontology"` + Protocol string `json:"protocol"` } type agentAskArgs struct { - To string `json:"to"` - Content string `json:"content"` - Performative string `json:"performative"` - ConversationID string `json:"conversation_id"` - Ontology string `json:"ontology"` + To recipients `json:"to"` + Content string `json:"content"` + Performative string `json:"performative"` + ConversationID string `json:"conversation_id"` + Ontology string `json:"ontology"` + Protocol string `json:"protocol"` +} + +// recipients accepts either one name or a list of them. +// +// Both spellings exist because both readings are natural: asking one +// agent is a name, and putting work out to tender is a list. Forcing a +// model to wrap a single name in an array is the kind of papercut that +// shows up as a malformed tool call rather than as a complaint. +type recipients []string + +// UnmarshalJSON accepts "worker" and ["worker","assistant"] alike. +func (r *recipients) UnmarshalJSON(data []byte) error { + var one string + if err := json.Unmarshal(data, &one); err == nil { + *r = recipients{one} + return nil + } + var many []string + if err := json.Unmarshal(data, &many); err != nil { + return fmt.Errorf("to must be an agent name or a list of them: %w", err) + } + *r = many + return nil } type agentInboxArgs struct { @@ -177,6 +212,7 @@ func (e *Engine) executeAgentSend(ctx context.Context, inv cortex.Invocation) st Performative: performativeOr(args.Performative, a2a.Inform), Content: args.Content, Ontology: args.Ontology, + Protocol: args.Protocol, OriginRunID: inv.RunID, } if convID, convErr := parseConversationID(args.ConversationID); convErr != nil { @@ -207,7 +243,7 @@ func (e *Engine) executeAgentAsk(ctx context.Context, inv cortex.Invocation) (st if err := json.Unmarshal([]byte(inv.Call.Arguments), &args); err != nil { return jsonResult("error", "invalid arguments: "+err.Error()), outcomeFailed, true } - if args.To == "" || args.Content == "" { + if len(args.To) == 0 || args.Content == "" { return jsonResult("error", "to and content are required"), outcomeFailed, true } @@ -218,10 +254,11 @@ func (e *Engine) executeAgentAsk(ctx context.Context, inv cortex.Invocation) (st params := a2a.AskParams{ SendParams: a2a.SendParams{ Sender: sender, - Receivers: []a2a.Address{a2a.ParseAddress(args.To)}, + Receivers: addressesOf(args.To), Performative: performativeOr(args.Performative, a2a.Request), Content: args.Content, Ontology: args.Ontology, + Protocol: args.Protocol, OriginRunID: inv.RunID, }, AskerRunID: inv.RunID, diff --git a/engine/a2a_tools_test.go b/engine/a2a_tools_test.go index d788895..d4828c8 100644 --- a/engine/a2a_tools_test.go +++ b/engine/a2a_tools_test.go @@ -243,3 +243,51 @@ func TestAgentInboxReadsDeliveredMessages(t *testing.T) { t.Fatal("the inbox tool result never carried the message to the model") } } + +// A call for proposals goes to several agents at once, so the tool has +// to take a list as readily as it takes a name. +func TestAgentAskAcceptsSeveralRecipients(t *testing.T) { + e, st, ctx := a2aEngine(t, []llm.ToolCall{{ + ID: "call-1", Name: toolAgentAsk, + Arguments: `{"to":["worker","assistant"],"performative":"cfp","content":"who can take this?","protocol":"fipa-contract-net"}`, + }}) + + r, err := e.RunAgent(ctx, "planner", "put it out to tender", nil) + if err != nil { + t.Fatalf("RunAgent: %v", err) + } + if r.State != run.StatePaused { + t.Fatalf("state = %s, want paused while the field answers", r.State) + } + + msgs, err := st.ListMessages(ctx, &a2a.MessageListFilter{Limit: 10}) + if err != nil { + t.Fatalf("ListMessages: %v", err) + } + if len(msgs) != 1 { + t.Fatalf("stored %d messages, want the call for proposals", len(msgs)) + } + if len(msgs[0].Receivers) != 2 { + t.Fatalf("the call reached %d agents, want 2", len(msgs[0].Receivers)) + } + if msgs[0].Performative != a2a.CFP || msgs[0].Protocol != a2a.ProtocolContractNet { + t.Fatalf("message = %s/%s, want a contract-net cfp", msgs[0].Performative, msgs[0].Protocol) + } +} + +// The single-recipient spelling is what most asks use, and it has to +// keep working unchanged. +func TestAgentAskStillAcceptsOneName(t *testing.T) { + e, st, ctx := a2aEngine(t, []llm.ToolCall{{ + ID: "call-1", Name: toolAgentAsk, + Arguments: `{"to":"worker","content":"status?"}`, + }}) + + if _, err := e.RunAgent(ctx, "planner", "ask", nil); err != nil { + t.Fatalf("RunAgent: %v", err) + } + msgs, _ := st.ListMessages(ctx, &a2a.MessageListFilter{Limit: 10}) + if len(msgs) != 1 || len(msgs[0].Receivers) != 1 { + t.Fatalf("messages = %+v", msgs) + } +} diff --git a/engine/approval_store_test.go b/engine/approval_store_test.go index f732208..aaa7c18 100644 --- a/engine/approval_store_test.go +++ b/engine/approval_store_test.go @@ -27,8 +27,14 @@ import ( // checkpoint, because they are not what a REST caller reads. func newApprovalStore(ctx context.Context, t *testing.T) *sqlitestore.Store { t.Helper() + // The busy timeout is a requirement rather than test hygiene once + // messaging is on: the dispatcher writes on its own goroutines while + // a run writes on the caller's, and sqlite refuses a concurrent + // writer rather than waiting unless it is told to. The docs say the + // same thing to anyone deploying on sqlite. + dsn := filepath.Join(t.TempDir(), "cortex_approval.db") + "?_pragma=busy_timeout(5000)" drv := sqlitedriver.New() - if err := drv.Open(ctx, filepath.Join(t.TempDir(), "cortex_approval.db")); err != nil { + if err := drv.Open(ctx, dsn); err != nil { t.Fatalf("open sqlite driver: %v", err) } db, err := grove.Open(drv) From 838160875c6eec5e431011bd8df9a6867c2e0bc5 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 21:23:38 -0500 Subject: [PATCH 42/50] docs: document contract net --- CHANGELOG.md | 30 +++++++++++ docs/content/docs/execution/messaging.mdx | 62 +++++++++++++++++++++-- 2 files changed, 88 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 54f5181..8f59ca3 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -131,8 +131,38 @@ in. Nothing in a message body or a header can influence that. Outbound peers are configuration rather than data, so an agent's own output cannot introduce a host to call. +### Added later in this release: contract net + +An agent can put work out to tender now. Ask several agents at once with +a `cfp` and your run waits for the whole field, then resumes with every +proposal and every refusal together, and the agent picks. + +Almost none of that was new. The four Contract Net performatives were +already carried and routed, and a tender is a conversation like any +other. What was missing was an ask addressed to more than one agent, so +that is what landed: an ask to one agent still resumes on that agent's +answer, and an ask to several waits for everyone or for the deadline. + +Cortex does not choose the winner and will not. Awarding is +`accept-proposal`, which is an ordinary directive, so awarding with +`agent_ask` rather than `agent_send` gets you the work back instead of an +acknowledgement. + +`a2a.ContractNet` and `a2a.CollectTender` are there for hosts driving a +tender from Go. + +**Breaking:** the `agent_ask` tool result changed shape. It was one +reply; it is now `{"replies": [...], "complete": bool}`, with one entry +for a single-recipient ask. Shipping two shapes, one per recipient count, +would have cost every prompt forever; a list costs one sentence. + ### Fixed +- **A refusal ended a round it should not have.** `refuse` and + `reject-proposal` used to resolve a waiting ask outright. With several + recipients that is wrong: one participant declining a tender must not + un-pause an initiator that is still waiting on the others. They now + count as answers. - **Remote receivers were never carried by their transport.** The first round shipped a `Transport` seam that the delivery path did not consult, so an envelope addressed to `worker@peer.example` would have diff --git a/docs/content/docs/execution/messaging.mdx b/docs/content/docs/execution/messaging.mdx index 576f117..5fa8178 100644 --- a/docs/content/docs/execution/messaging.mdx +++ b/docs/content/docs/execution/messaging.mdx @@ -31,10 +31,11 @@ Your agents get three new tools, and the whole feature is what they do. `agent_send` posts a message and returns immediately. Use it to tell somebody something, confirm, refuse, or hand work along. Nothing waits. -`agent_ask` asks a peer a question and suspends the asking run until the answer -comes back. The answer arrives as that tool call's result, so from the model's -side it reads like any other tool that took a while. The wait is a row in your -database rather than a goroutine, so it survives a restart. +`agent_ask` asks one or more peers a question and suspends the asking run until +the answers come back. They arrive as that tool call's result, so from the +model's side it reads like any other tool that took a while. The wait is a row +in your database rather than a goroutine, so it survives a restart. Asking +several agents at once is how you run a tender, which is its own section below. `agent_inbox` drains the messages that arrived while the agent was busy. Reading marks them read, so each message comes back once. @@ -65,6 +66,59 @@ obvious abuse shape, so build it over the inbox if you want it. waiting on it with a failure they can read, rather than leaving them to sit until their deadline. +## Putting work out to tender + +When you do not know who is best placed to do something, ask everyone and +let them tell you. That is Contract Net, the oldest task-allocation +protocol in multi-agent systems, and cortex speaks it with the tools you +already have. + +Ask several agents at once and your run waits for the whole field: + +``` +agent_ask( + to: ["fast-reviewer", "slow-reviewer", "busy-reviewer"], + performative: "cfp", + protocol: "fipa-contract-net", + content: "who can review the migration today?" +) +``` + +The run resumes when everyone has answered, or when the deadline passes +with whoever did. Every proposal and every refusal comes back together: + +```json +{ + "complete": true, + "replies": [ + {"sender": "fast-reviewer", "performative": "propose", "content": "I can, by 3pm"}, + {"sender": "slow-reviewer", "performative": "propose", "content": "I can, by 9pm"}, + {"sender": "busy-reviewer", "performative": "refuse", "content": "not today"} + ] +} +``` + +Then the agent picks, and awards: + +``` +agent_send(to: ["fast-reviewer"], performative: "accept-proposal", content: "yours, by 3pm") +agent_send(to: ["slow-reviewer"], performative: "reject-proposal", content: "thanks anyway") +``` + +**Cortex never picks the winner.** Choosing a contractor is a judgement, and +an agent is what makes it. If you want the work back rather than just an +acknowledgement, award with `agent_ask` instead: `accept-proposal` is a +directive, so the winner runs and its result comes back as your answer. + +Two behaviours are worth knowing. A refusal counts as an answer, so one +agent declining does not end a round the others are still thinking about. +And a participant that never answers at all is ordinary rather than +exceptional: the deadline resolves your ask with `complete: false` and +whoever did reply, and you carry on with what you got. + +Hosts driving a tender from Go rather than through an agent's own +reasoning have `a2a.ContractNet` and `a2a.CollectTender`. + ## Who can talk to whom An address is an agent name inside the sender's scope. Cortex enforces the From 85045f26dc1b0b6dee45724b641be3ee8748ed0c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 21:26:06 -0500 Subject: [PATCH 43/50] feat(a2aremote): add the HTTP+JSON binding The paths are the protocol's own, colon verbs and all, because a client expects message:send rather than whatever a Go router would prefer. An error says the same thing twice, as an HTTP status and as the protocol's numeric code, so a client that reads only one of the two still knows what happened. --- a2aremote/rest.go | 165 ++++++++++++++++++++++++++++++++++++++ a2aremote/rest_test.go | 178 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 343 insertions(+) create mode 100644 a2aremote/rest.go create mode 100644 a2aremote/rest_test.go diff --git a/a2aremote/rest.go b/a2aremote/rest.go new file mode 100644 index 0000000..1bbb2f4 --- /dev/null +++ b/a2aremote/rest.go @@ -0,0 +1,165 @@ +package a2aremote + +import ( + "encoding/json" + "io" + "net/http" + "strings" +) + +// RESTHandler serves the HTTP+JSON binding. +// +// The paths are the protocol's own, from the http annotations on the +// normative proto: a colon verb like message:send is unusual in Go +// routing and entirely ordinary in an AIP-style API, so they are spelled +// the way a client expects rather than the way a Go router prefers. +// +// Like the JSON-RPC handler, this decodes, dispatches and encodes. +// Nothing here decides what an operation means. +func (s *Service) RESTHandler() http.Handler { + mux := http.NewServeMux() + + mux.HandleFunc("POST /{tenant}/message:send", func(w http.ResponseWriter, r *http.Request) { + var req SendMessageRequest + if !decodeREST(w, r, &req) { + return + } + req.Tenant = r.PathValue("tenant") + result, err := s.SendMessage(r.Context(), credentialsOf(r), req) + writeREST(w, result, err) + }) + + mux.HandleFunc("GET /{tenant}/tasks/{id}", func(w http.ResponseWriter, r *http.Request) { + // A colon verb rides on the id segment, so the two forms are + // told apart here rather than by the router. + id, verb := splitVerb(r.PathValue("id")) + switch verb { + case "": + task, err := s.GetTask(r.Context(), credentialsOf(r), GetTaskRequest{ + Tenant: r.PathValue("tenant"), ID: id, + }) + writeREST(w, task, err) + case "subscribe": + writeREST(w, nil, ErrUnsupportedOperation("SubscribeToTask")) + default: + writeREST(w, nil, ErrMethodNotFound(verb)) + } + }) + + mux.HandleFunc("GET /{tenant}/tasks", func(w http.ResponseWriter, r *http.Request) { + result, err := s.ListTasks(r.Context(), credentialsOf(r), ListTasksRequest{ + Tenant: r.PathValue("tenant"), + }) + writeREST(w, result, err) + }) + + mux.HandleFunc("POST /{tenant}/tasks/{id}", func(w http.ResponseWriter, r *http.Request) { + id, verb := splitVerb(r.PathValue("id")) + if verb != "cancel" { + writeREST(w, nil, ErrMethodNotFound(verb)) + return + } + task, err := s.CancelTask(r.Context(), credentialsOf(r), CancelTaskRequest{ + Tenant: r.PathValue("tenant"), ID: id, + }) + writeREST(w, task, err) + }) + + mux.HandleFunc("POST /{tenant}/message:stream", func(w http.ResponseWriter, _ *http.Request) { + writeREST(w, nil, ErrUnsupportedOperation("SendStreamingMessage")) + }) + mux.HandleFunc("GET /{tenant}/extendedAgentCard", func(w http.ResponseWriter, _ *http.Request) { + writeREST(w, nil, ErrExtendedCardNotConfigured()) + }) + // Push notification config, in all four spellings, refused in one + // place rather than four. + for _, pattern := range []string{ + "POST /{tenant}/tasks/{id}/pushNotificationConfigs", + "GET /{tenant}/tasks/{id}/pushNotificationConfigs", + "GET /{tenant}/tasks/{id}/pushNotificationConfigs/{configID}", + "DELETE /{tenant}/tasks/{id}/pushNotificationConfigs/{configID}", + } { + mux.HandleFunc(pattern, func(w http.ResponseWriter, _ *http.Request) { + writeREST(w, nil, ErrPushNotificationNotSupported()) + }) + } + + return mux +} + +// splitVerb separates an AIP colon verb from the resource id it hangs +// off: "arun_1:cancel" is the run and the verb. +func splitVerb(segment string) (id, verb string) { + at := strings.LastIndex(segment, ":") + if at < 0 { + return segment, "" + } + return segment[:at], segment[at+1:] +} + +func decodeREST(w http.ResponseWriter, r *http.Request, dest any) bool { + body, err := io.ReadAll(io.LimitReader(r.Body, maxRequestBytes)) + if err != nil { + writeREST(w, nil, ErrParse()) + return false + } + if len(body) == 0 { + return true + } + if err := json.Unmarshal(body, dest); err != nil { + writeREST(w, nil, ErrInvalidParams("the request body could not be decoded")) + return false + } + return true +} + +// writeREST renders a result or an error. +// +// A REST client reads the status code first, so an error says the same +// thing twice: the HTTP status the protocol maps it to, and the +// protocol's own numeric code in the body. A client that only understands +// one of the two still understands what happened. +func writeREST(w http.ResponseWriter, result any, err error) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set(versionHeader, ProtocolVersion) + + if err != nil { + perr := asProtocolError(err) + w.WriteHeader(httpStatusFor(perr)) + // An encode failure means the connection went away mid-write, + // which there is nothing useful to do about and nobody left to + // tell. + if encErr := json.NewEncoder(w).Encode(map[string]any{"error": perr}); encErr != nil { + return + } + return + } + if encErr := json.NewEncoder(w).Encode(result); encErr != nil { + return + } +} + +// httpStatusFor maps a protocol error to the status the specification's +// own error table gives it. +func httpStatusFor(err *Error) int { + switch err.Code { + case CodeTaskNotFound: + return http.StatusNotFound + case CodeInvalidRequest: + // The one refusal that is about the caller rather than the + // request: an unauthenticated peer gets 401 so it knows to + // present credentials rather than to fix its JSON. + if err.Message == ErrUnauthenticated().Message { + return http.StatusUnauthorized + } + return http.StatusBadRequest + case CodeTaskNotCancelable, CodePushNotificationNotSupported, CodeUnsupportedOperation, + CodeContentTypeNotSupported, CodeExtendedCardNotConfigured, CodeExtensionSupportRequired, + CodeVersionNotSupported, CodeInvalidParams, CodeParse: + return http.StatusBadRequest + case CodeMethodNotFound: + return http.StatusNotFound + default: + return http.StatusInternalServerError + } +} diff --git a/a2aremote/rest_test.go b/a2aremote/rest_test.go new file mode 100644 index 0000000..bb64330 --- /dev/null +++ b/a2aremote/rest_test.go @@ -0,0 +1,178 @@ +package a2aremote + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" +) + +func restCall(t *testing.T, h http.Handler, method, path, body string) (int, map[string]any) { + t.Helper() + var reader *bytes.Buffer + if body == "" { + reader = bytes.NewBufferString("") + } else { + reader = bytes.NewBufferString(body) + } + req := httptest.NewRequest(method, path, reader) + req.Header.Set("Content-Type", "application/json") + rec := httptest.NewRecorder() + h.ServeHTTP(rec, req) + + out := map[string]any{} + if rec.Body.Len() > 0 { + if err := json.Unmarshal(rec.Body.Bytes(), &out); err != nil { + t.Fatalf("%s %s: body is not JSON: %q", method, path, rec.Body.String()) + } + } + return rec.Code, out +} + +// The paths are the protocol's own, from the http annotations on the +// normative proto. A colon verb is unusual in Go routing and entirely +// ordinary in an AIP-style API. +func TestRESTSendMessage(t *testing.T) { + gw := newFakeGateway() + h := testService(t, gw, okResolver()).RESTHandler() + + code, body := restCall(t, h, http.MethodPost, "/worker/message:send", + `{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hello"}]}}`) + + if code != http.StatusOK { + t.Fatalf("status = %d, body = %+v", code, body) + } + if gw.calls() != 1 { + t.Fatalf("gateway called %d times, want 1", gw.calls()) + } + // The tenant comes from the path, which is where the protocol puts it. + if gw.params().Receivers[0].Agent != "worker" { + t.Fatalf("receiver = %+v, want the tenant from the path", gw.params().Receivers) + } +} + +func TestRESTGetTask(t *testing.T) { + gw := newFakeGateway() + runID := id.NewAgentRunID() + gw.addRun(&run.Run{ID: runID, State: run.StateCompleted, Output: "done"}) + h := testService(t, gw, okResolver()).RESTHandler() + + code, body := restCall(t, h, http.MethodGet, "/worker/tasks/"+runID.String(), "") + if code != http.StatusOK { + t.Fatalf("status = %d, body = %+v", code, body) + } + if body["id"] != runID.String() { + t.Fatalf("id = %v", body["id"]) + } +} + +func TestRESTListTasks(t *testing.T) { + gw := newFakeGateway() + gw.addRun(&run.Run{ID: id.NewAgentRunID(), State: run.StateRunning}) + h := testService(t, gw, okResolver()).RESTHandler() + + code, body := restCall(t, h, http.MethodGet, "/worker/tasks", "") + if code != http.StatusOK { + t.Fatalf("status = %d", code) + } + if _, ok := body["tasks"]; !ok { + t.Fatalf("body = %+v, want a tasks list", body) + } +} + +func TestRESTCancelTask(t *testing.T) { + gw := newFakeGateway() + runID := id.NewAgentRunID() + gw.addRun(&run.Run{ID: runID, State: run.StateRunning}) + h := testService(t, gw, okResolver()).RESTHandler() + + code, body := restCall(t, h, http.MethodPost, "/worker/tasks/"+runID.String()+":cancel", "") + if code != http.StatusOK { + t.Fatalf("status = %d, body = %+v", code, body) + } + status, _ := body["status"].(map[string]any) + if status["state"] != string(TaskStateCanceled) { + t.Fatalf("state = %v, want canceled", status["state"]) + } +} + +// An error keeps its meaning across the binding. REST says it with a +// status code as well as a body, because that is what a REST client +// reads first. +func TestRESTErrorsCarryTheProtocolStatus(t *testing.T) { + gw := newFakeGateway() + h := testService(t, gw, okResolver()).RESTHandler() + + code, body := restCall(t, h, http.MethodGet, "/worker/tasks/arun_notreal", "") + if code != http.StatusNotFound { + t.Fatalf("status = %d, want 404 for a task that is not there", code) + } + e, _ := body["error"].(map[string]any) + if e["code"] != float64(CodeTaskNotFound) { + t.Fatalf("error = %+v, want the protocol's own code alongside the status", e) + } +} + +func TestRESTUnauthenticatedIs401(t *testing.T) { + h := testService(t, newFakeGateway(), staticResolver{err: errTest}).RESTHandler() + code, _ := restCall(t, h, http.MethodGet, "/worker/tasks", "") + if code != http.StatusUnauthorized { + t.Fatalf("status = %d, want 401", code) + } +} + +func TestRESTUnsupportedOperations(t *testing.T) { + h := testService(t, newFakeGateway(), okResolver()).RESTHandler() + + for _, tc := range []struct{ method, path string }{ + {http.MethodPost, "/worker/message:stream"}, + {http.MethodGet, "/worker/tasks/arun_1:subscribe"}, + {http.MethodGet, "/worker/extendedAgentCard"}, + {http.MethodPost, "/worker/tasks/arun_1/pushNotificationConfigs"}, + } { + code, body := restCall(t, h, tc.method, tc.path, "{}") + if code == http.StatusOK { + t.Errorf("%s %s answered 200 for something not implemented: %+v", tc.method, tc.path, body) + } + e, _ := body["error"].(map[string]any) + if e == nil { + t.Errorf("%s %s: no error body: %+v", tc.method, tc.path, body) + } + } +} + +// The two bindings share one Service, so identical semantics must give +// identical outcomes. That is the property the shared service exists for, +// which makes it worth asserting rather than assuming. +func TestRESTAndJSONRPCAgree(t *testing.T) { + runID := id.NewAgentRunID() + + gwRPC := newFakeGateway() + gwRPC.addRun(&run.Run{ID: runID, State: run.StateCompleted, Output: "same answer"}) + rpcResp := post(t, testService(t, gwRPC, okResolver()).JSONRPCHandler(), + `{"jsonrpc":"2.0","id":1,"method":"GetTask","params":{"tenant":"worker","id":"`+runID.String()+`"}}`) + + gwREST := newFakeGateway() + gwREST.addRun(&run.Run{ID: runID, State: run.StateCompleted, Output: "same answer"}) + _, restBody := restCall(t, testService(t, gwREST, okResolver()).RESTHandler(), + http.MethodGet, "/worker/tasks/"+runID.String(), "") + + rpcResult, _ := rpcResp["result"].(map[string]any) + rpcJSON, _ := json.Marshal(rpcResult) + restJSON, _ := json.Marshal(restBody) + + // Timestamps differ by construction, so the comparison is on the + // parts that carry meaning. + var a, b Task + _ = json.Unmarshal(rpcJSON, &a) + _ = json.Unmarshal(restJSON, &b) + a.Status.Timestamp, b.Status.Timestamp = "", "" + + if a.ID != b.ID || a.Status.State != b.Status.State || len(a.Artifacts) != len(b.Artifacts) { + t.Fatalf("the bindings disagree:\n jsonrpc: %+v\n rest: %+v", a, b) + } +} From ba742566a0a2f8dcbb78617c6f677e452ed85d97 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 21:29:10 -0500 Subject: [PATCH 44/50] feat(a2aremote): add the gRPC binding Its own module, because gRPC and protobuf are a large dependency graph and a host serving JSON-RPC has no business inheriting it. Importing the module is the opt-in. The types are generated from the normative a2a.proto, vendored verbatim with the script that regenerated them, so the wire format is the specification's rather than an approximation of it. Everything in the server is translation: not one decision about what an operation means lives on that side of the boundary, which is why the sender namespacing and the scope rule hold there without being written down twice. --- a2aremote/card.go | 24 +- a2aremote/card_test.go | 38 + a2aremote/grpcbind/a2apb/a2a.pb.go | 5310 +++++++++++++++++++++++ a2aremote/grpcbind/a2apb/a2a.proto | 812 ++++ a2aremote/grpcbind/a2apb/a2a_grpc.pb.go | 559 +++ a2aremote/grpcbind/a2apb/generate.sh | 35 + a2aremote/grpcbind/go.mod | 28 + a2aremote/grpcbind/go.sum | 84 + a2aremote/grpcbind/server.go | 266 ++ a2aremote/grpcbind/server_test.go | 207 + 10 files changed, 7359 insertions(+), 4 deletions(-) create mode 100644 a2aremote/grpcbind/a2apb/a2a.pb.go create mode 100644 a2aremote/grpcbind/a2apb/a2a.proto create mode 100644 a2aremote/grpcbind/a2apb/a2a_grpc.pb.go create mode 100755 a2aremote/grpcbind/a2apb/generate.sh create mode 100644 a2aremote/grpcbind/go.mod create mode 100644 a2aremote/grpcbind/go.sum create mode 100644 a2aremote/grpcbind/server.go create mode 100644 a2aremote/grpcbind/server_test.go diff --git a/a2aremote/card.go b/a2aremote/card.go index 05c9b02..594e113 100644 --- a/a2aremote/card.go +++ b/a2aremote/card.go @@ -96,9 +96,18 @@ type CardOptions struct { Version string Provider AgentProvider DocumentationURL string - // Bindings limits which bindings the card advertises. Empty means - // JSON-RPC only, which is what this module serves today. + // Bindings names which bindings the card advertises. Empty means + // JSON-RPC only, which is what this module serves on its own. + // + // Advertise a binding only when you actually serve it. A card is a + // promise, and a client that picks GRPC because the card offered it + // has no way to recover when nothing answers. Bindings []string + // URLs gives a binding its own address, for the ones that do not + // share the HTTP endpoint. gRPC in particular is a host:port rather + // than a URL path, so BaseURL cannot describe it. A binding with no + // entry here uses BaseURL. + URLs map[string]string } // BuildCard renders one cortex agent as an A2A agent card. @@ -114,10 +123,17 @@ func BuildCard(a *agent.Config, skills []*skill.Skill, opts CardOptions) AgentCa interfaces := make([]AgentInterface, 0, len(bindings)) for _, b := range bindings { + url := opts.BaseURL + if explicit, ok := opts.URLs[b]; ok { + url = explicit + } interfaces = append(interfaces, AgentInterface{ - URL: opts.BaseURL, + URL: url, ProtocolBinding: b, - Tenant: a.Name, + // The tenant is the agent's name, which is how one endpoint + // serves many agents. It is the protocol's own mechanism + // rather than a cortex convention. + Tenant: a.Name, }) } diff --git a/a2aremote/card_test.go b/a2aremote/card_test.go index 4e952b1..5af6f81 100644 --- a/a2aremote/card_test.go +++ b/a2aremote/card_test.go @@ -163,3 +163,41 @@ func TestFetchCardRejectsANonCard(t *testing.T) { t.Fatal("a document with no name and no interfaces is not a card") } } + +// A host that serves all three bindings says so, and gRPC gets its own +// address because a host:port is not a URL path. +func TestCardAdvertisesEveryBindingItServes(t *testing.T) { + card := BuildCard(&agent.Config{Name: "worker"}, nil, CardOptions{ + BaseURL: "https://cortex.example/a2a", + Bindings: []string{BindingJSONRPC, BindingREST, BindingGRPC}, + URLs: map[string]string{BindingGRPC: "grpc.cortex.example:443"}, + }) + + if len(card.SupportedInterfaces) != 3 { + t.Fatalf("card offers %d interfaces, want 3", len(card.SupportedInterfaces)) + } + byBinding := map[string]AgentInterface{} + for _, i := range card.SupportedInterfaces { + byBinding[i.ProtocolBinding] = i + } + if byBinding[BindingGRPC].URL != "grpc.cortex.example:443" { + t.Errorf("gRPC url = %q, want its own address", byBinding[BindingGRPC].URL) + } + if byBinding[BindingREST].URL != "https://cortex.example/a2a" { + t.Errorf("REST url = %q, want the shared base", byBinding[BindingREST].URL) + } + for binding, iface := range byBinding { + if iface.Tenant != "worker" { + t.Errorf("%s: tenant = %q, want the agent name", binding, iface.Tenant) + } + } +} + +// A card that offers a binding nobody serves is a promise the server +// cannot keep, so the default stays at the one this module serves alone. +func TestCardDefaultsToJSONRPCOnly(t *testing.T) { + card := BuildCard(&agent.Config{Name: "worker"}, nil, CardOptions{BaseURL: "https://x/a2a"}) + if len(card.SupportedInterfaces) != 1 || card.SupportedInterfaces[0].ProtocolBinding != BindingJSONRPC { + t.Fatalf("interfaces = %+v", card.SupportedInterfaces) + } +} diff --git a/a2aremote/grpcbind/a2apb/a2a.pb.go b/a2aremote/grpcbind/a2apb/a2a.pb.go new file mode 100644 index 0000000..bb7c82f --- /dev/null +++ b/a2aremote/grpcbind/a2apb/a2a.pb.go @@ -0,0 +1,5310 @@ +// Older protoc compilers don't understand edition yet. + +// Code generated by protoc-gen-go. DO NOT EDIT. +// versions: +// protoc-gen-go v1.34.2 +// protoc v7.35.0 +// source: a2a.proto + +package a2apb + +import ( + _ "google.golang.org/genproto/googleapis/api/annotations" + protoreflect "google.golang.org/protobuf/reflect/protoreflect" + protoimpl "google.golang.org/protobuf/runtime/protoimpl" + emptypb "google.golang.org/protobuf/types/known/emptypb" + structpb "google.golang.org/protobuf/types/known/structpb" + timestamppb "google.golang.org/protobuf/types/known/timestamppb" + reflect "reflect" + sync "sync" +) + +const ( + // Verify that this generated code is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(20 - protoimpl.MinVersion) + // Verify that runtime/protoimpl is sufficiently up-to-date. + _ = protoimpl.EnforceVersion(protoimpl.MaxVersion - 20) +) + +// Defines the possible lifecycle states of a `Task`. +type TaskState int32 + +const ( + // The task is in an unknown or indeterminate state. + TaskState_TASK_STATE_UNSPECIFIED TaskState = 0 + // Indicates that a task has been successfully submitted and acknowledged. + TaskState_TASK_STATE_SUBMITTED TaskState = 1 + // Indicates that a task is actively being processed by the agent. + TaskState_TASK_STATE_WORKING TaskState = 2 + // Indicates that a task has finished successfully. This is a terminal state. + TaskState_TASK_STATE_COMPLETED TaskState = 3 + // Indicates that a task has finished with an error. This is a terminal state. + TaskState_TASK_STATE_FAILED TaskState = 4 + // Indicates that a task was canceled before completion. This is a terminal state. + TaskState_TASK_STATE_CANCELED TaskState = 5 + // Indicates that the agent requires additional user input to proceed. This is an interrupted state. + TaskState_TASK_STATE_INPUT_REQUIRED TaskState = 6 + // Indicates that the agent has decided to not perform the task. + // This may be done during initial task creation or later once an agent + // has determined it can't or won't proceed. This is a terminal state. + TaskState_TASK_STATE_REJECTED TaskState = 7 + // Indicates that authentication is required to proceed. This is an interrupted state. + TaskState_TASK_STATE_AUTH_REQUIRED TaskState = 8 +) + +// Enum value maps for TaskState. +var ( + TaskState_name = map[int32]string{ + 0: "TASK_STATE_UNSPECIFIED", + 1: "TASK_STATE_SUBMITTED", + 2: "TASK_STATE_WORKING", + 3: "TASK_STATE_COMPLETED", + 4: "TASK_STATE_FAILED", + 5: "TASK_STATE_CANCELED", + 6: "TASK_STATE_INPUT_REQUIRED", + 7: "TASK_STATE_REJECTED", + 8: "TASK_STATE_AUTH_REQUIRED", + } + TaskState_value = map[string]int32{ + "TASK_STATE_UNSPECIFIED": 0, + "TASK_STATE_SUBMITTED": 1, + "TASK_STATE_WORKING": 2, + "TASK_STATE_COMPLETED": 3, + "TASK_STATE_FAILED": 4, + "TASK_STATE_CANCELED": 5, + "TASK_STATE_INPUT_REQUIRED": 6, + "TASK_STATE_REJECTED": 7, + "TASK_STATE_AUTH_REQUIRED": 8, + } +) + +func (x TaskState) Enum() *TaskState { + p := new(TaskState) + *p = x + return p +} + +func (x TaskState) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (TaskState) Descriptor() protoreflect.EnumDescriptor { + return file_a2a_proto_enumTypes[0].Descriptor() +} + +func (TaskState) Type() protoreflect.EnumType { + return &file_a2a_proto_enumTypes[0] +} + +func (x TaskState) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use TaskState.Descriptor instead. +func (TaskState) EnumDescriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{0} +} + +// Defines the sender of a message in A2A protocol communication. +type Role int32 + +const ( + // The role is unspecified. + Role_ROLE_UNSPECIFIED Role = 0 + // The message is from the client to the server. + Role_ROLE_USER Role = 1 + // The message is from the server to the client. + Role_ROLE_AGENT Role = 2 +) + +// Enum value maps for Role. +var ( + Role_name = map[int32]string{ + 0: "ROLE_UNSPECIFIED", + 1: "ROLE_USER", + 2: "ROLE_AGENT", + } + Role_value = map[string]int32{ + "ROLE_UNSPECIFIED": 0, + "ROLE_USER": 1, + "ROLE_AGENT": 2, + } +) + +func (x Role) Enum() *Role { + p := new(Role) + *p = x + return p +} + +func (x Role) String() string { + return protoimpl.X.EnumStringOf(x.Descriptor(), protoreflect.EnumNumber(x)) +} + +func (Role) Descriptor() protoreflect.EnumDescriptor { + return file_a2a_proto_enumTypes[1].Descriptor() +} + +func (Role) Type() protoreflect.EnumType { + return &file_a2a_proto_enumTypes[1] +} + +func (x Role) Number() protoreflect.EnumNumber { + return protoreflect.EnumNumber(x) +} + +// Deprecated: Use Role.Descriptor instead. +func (Role) EnumDescriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{1} +} + +// Configuration of a send message request. +type SendMessageConfiguration struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // A list of media types the client is prepared to accept for response parts. + // Agents SHOULD use this to tailor their output. + AcceptedOutputModes []string `protobuf:"bytes,1,rep,name=accepted_output_modes,json=acceptedOutputModes,proto3" json:"accepted_output_modes,omitempty"` + // Configuration for the agent to send push notifications for task updates. + // Task id should be empty when sending this configuration in a `SendMessage` request. + TaskPushNotificationConfig *TaskPushNotificationConfig `protobuf:"bytes,2,opt,name=task_push_notification_config,json=taskPushNotificationConfig,proto3" json:"task_push_notification_config,omitempty"` + // The maximum number of most recent messages from the task's history to retrieve in + // the response. An unset value means the client does not impose any limit. A + // value of zero is a request to not include any messages. The server MUST NOT + // return more messages than the provided value, but MAY apply a lower limit. + HistoryLength *int32 `protobuf:"varint,3,opt,name=history_length,json=historyLength,proto3,oneof" json:"history_length,omitempty"` + // If `true`, the operation returns immediately after creating the task, + // even if processing is still in progress. + // If `false` (default), the operation MUST wait until the task reaches a + // terminal (`COMPLETED`, `FAILED`, `CANCELED`, `REJECTED`) or interrupted + // (`INPUT_REQUIRED`, `AUTH_REQUIRED`) state before returning. + ReturnImmediately bool `protobuf:"varint,4,opt,name=return_immediately,json=returnImmediately,proto3" json:"return_immediately,omitempty"` +} + +func (x *SendMessageConfiguration) Reset() { + *x = SendMessageConfiguration{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[0] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SendMessageConfiguration) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendMessageConfiguration) ProtoMessage() {} + +func (x *SendMessageConfiguration) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[0] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendMessageConfiguration.ProtoReflect.Descriptor instead. +func (*SendMessageConfiguration) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{0} +} + +func (x *SendMessageConfiguration) GetAcceptedOutputModes() []string { + if x != nil { + return x.AcceptedOutputModes + } + return nil +} + +func (x *SendMessageConfiguration) GetTaskPushNotificationConfig() *TaskPushNotificationConfig { + if x != nil { + return x.TaskPushNotificationConfig + } + return nil +} + +func (x *SendMessageConfiguration) GetHistoryLength() int32 { + if x != nil && x.HistoryLength != nil { + return *x.HistoryLength + } + return 0 +} + +func (x *SendMessageConfiguration) GetReturnImmediately() bool { + if x != nil { + return x.ReturnImmediately + } + return false +} + +// `Task` is the core unit of action for A2A. It has a current status +// and when results are created for the task they are stored in the +// artifact. If there are multiple turns for a task, these are stored in +// history. +type Task struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Unique identifier (e.g. UUID) for the task, generated by the server for a + // new task. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // Unique identifier (e.g. UUID) for the contextual collection of interactions + // (tasks and messages). + ContextId string `protobuf:"bytes,2,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` + // The current status of a `Task`, including `state` and a `message`. + Status *TaskStatus `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + // A set of output artifacts for a `Task`. + Artifacts []*Artifact `protobuf:"bytes,4,rep,name=artifacts,proto3" json:"artifacts,omitempty"` + // protolint:disable REPEATED_FIELD_NAMES_PLURALIZED + // The history of interactions from a `Task`. + History []*Message `protobuf:"bytes,5,rep,name=history,proto3" json:"history,omitempty"` + // protolint:enable REPEATED_FIELD_NAMES_PLURALIZED + // A key/value object to store custom metadata about a task. + Metadata *structpb.Struct `protobuf:"bytes,6,opt,name=metadata,proto3" json:"metadata,omitempty"` +} + +func (x *Task) Reset() { + *x = Task{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[1] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Task) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Task) ProtoMessage() {} + +func (x *Task) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[1] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Task.ProtoReflect.Descriptor instead. +func (*Task) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{1} +} + +func (x *Task) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *Task) GetContextId() string { + if x != nil { + return x.ContextId + } + return "" +} + +func (x *Task) GetStatus() *TaskStatus { + if x != nil { + return x.Status + } + return nil +} + +func (x *Task) GetArtifacts() []*Artifact { + if x != nil { + return x.Artifacts + } + return nil +} + +func (x *Task) GetHistory() []*Message { + if x != nil { + return x.History + } + return nil +} + +func (x *Task) GetMetadata() *structpb.Struct { + if x != nil { + return x.Metadata + } + return nil +} + +// A container for the status of a task +type TaskStatus struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The current state of this task. + State TaskState `protobuf:"varint,1,opt,name=state,proto3,enum=lf.a2a.v1.TaskState" json:"state,omitempty"` + // A message associated with the status. + Message *Message `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + // ISO 8601 Timestamp when the status was recorded. + // Example: "2023-10-27T10:00:00Z" + Timestamp *timestamppb.Timestamp `protobuf:"bytes,3,opt,name=timestamp,proto3" json:"timestamp,omitempty"` +} + +func (x *TaskStatus) Reset() { + *x = TaskStatus{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[2] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TaskStatus) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TaskStatus) ProtoMessage() {} + +func (x *TaskStatus) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[2] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TaskStatus.ProtoReflect.Descriptor instead. +func (*TaskStatus) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{2} +} + +func (x *TaskStatus) GetState() TaskState { + if x != nil { + return x.State + } + return TaskState_TASK_STATE_UNSPECIFIED +} + +func (x *TaskStatus) GetMessage() *Message { + if x != nil { + return x.Message + } + return nil +} + +func (x *TaskStatus) GetTimestamp() *timestamppb.Timestamp { + if x != nil { + return x.Timestamp + } + return nil +} + +// `Part` represents a container for a section of communication content. +// Parts can be purely textual, some sort of file (image, video, etc) or +// a structured data blob (i.e. JSON). +type Part struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Content: + // + // *Part_Text + // *Part_Raw + // *Part_Url + // *Part_Data + Content isPart_Content `protobuf_oneof:"content"` + // Optional. metadata associated with this part. + Metadata *structpb.Struct `protobuf:"bytes,5,opt,name=metadata,proto3" json:"metadata,omitempty"` + // An optional `filename` for the file (e.g., "document.pdf"). + Filename string `protobuf:"bytes,6,opt,name=filename,proto3" json:"filename,omitempty"` + // The `media_type` (MIME type) of the part content (e.g., "text/plain", "application/json", "image/png"). + // This field is available for all part types. + MediaType string `protobuf:"bytes,7,opt,name=media_type,json=mediaType,proto3" json:"media_type,omitempty"` +} + +func (x *Part) Reset() { + *x = Part{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[3] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Part) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Part) ProtoMessage() {} + +func (x *Part) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[3] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Part.ProtoReflect.Descriptor instead. +func (*Part) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{3} +} + +func (m *Part) GetContent() isPart_Content { + if m != nil { + return m.Content + } + return nil +} + +func (x *Part) GetText() string { + if x, ok := x.GetContent().(*Part_Text); ok { + return x.Text + } + return "" +} + +func (x *Part) GetRaw() []byte { + if x, ok := x.GetContent().(*Part_Raw); ok { + return x.Raw + } + return nil +} + +func (x *Part) GetUrl() string { + if x, ok := x.GetContent().(*Part_Url); ok { + return x.Url + } + return "" +} + +func (x *Part) GetData() *structpb.Value { + if x, ok := x.GetContent().(*Part_Data); ok { + return x.Data + } + return nil +} + +func (x *Part) GetMetadata() *structpb.Struct { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Part) GetFilename() string { + if x != nil { + return x.Filename + } + return "" +} + +func (x *Part) GetMediaType() string { + if x != nil { + return x.MediaType + } + return "" +} + +type isPart_Content interface { + isPart_Content() +} + +type Part_Text struct { + // The string content of the `text` part. + Text string `protobuf:"bytes,1,opt,name=text,proto3,oneof"` +} + +type Part_Raw struct { + // The `raw` byte content of a file. In JSON serialization, this is encoded as a base64 string. + Raw []byte `protobuf:"bytes,2,opt,name=raw,proto3,oneof"` +} + +type Part_Url struct { + // A `url` pointing to the file's content. + Url string `protobuf:"bytes,3,opt,name=url,proto3,oneof"` +} + +type Part_Data struct { + // Arbitrary structured `data` as a JSON value (object, array, string, number, boolean, or null). + Data *structpb.Value `protobuf:"bytes,4,opt,name=data,proto3,oneof"` +} + +func (*Part_Text) isPart_Content() {} + +func (*Part_Raw) isPart_Content() {} + +func (*Part_Url) isPart_Content() {} + +func (*Part_Data) isPart_Content() {} + +// `Message` is one unit of communication between client and server. It can be +// associated with a context and/or a task. For server messages, `context_id` must +// be provided, and `task_id` only if a task was created. For client messages, both +// fields are optional, with the caveat that if both are provided, they have to +// match (the `context_id` has to be the one that is set on the task). If only +// `task_id` is provided, the server will infer `context_id` from it. +type Message struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The unique identifier (e.g. UUID) of the message. This is created by the message creator. + MessageId string `protobuf:"bytes,1,opt,name=message_id,json=messageId,proto3" json:"message_id,omitempty"` + // Optional. The context id of the message. If set, the message will be associated with the given context. + ContextId string `protobuf:"bytes,2,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` + // Optional. The task id of the message. If set, the message will be associated with the given task. + TaskId string `protobuf:"bytes,3,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + // Identifies the sender of the message. + Role Role `protobuf:"varint,4,opt,name=role,proto3,enum=lf.a2a.v1.Role" json:"role,omitempty"` + // Parts is the container of the message content. + Parts []*Part `protobuf:"bytes,5,rep,name=parts,proto3" json:"parts,omitempty"` + // Optional. Any metadata to provide along with the message. + Metadata *structpb.Struct `protobuf:"bytes,6,opt,name=metadata,proto3" json:"metadata,omitempty"` + // The URIs of extensions that are present or contributed to this Message. + Extensions []string `protobuf:"bytes,7,rep,name=extensions,proto3" json:"extensions,omitempty"` + // A list of task IDs that this message references for additional context. + ReferenceTaskIds []string `protobuf:"bytes,8,rep,name=reference_task_ids,json=referenceTaskIds,proto3" json:"reference_task_ids,omitempty"` +} + +func (x *Message) Reset() { + *x = Message{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[4] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Message) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Message) ProtoMessage() {} + +func (x *Message) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[4] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Message.ProtoReflect.Descriptor instead. +func (*Message) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{4} +} + +func (x *Message) GetMessageId() string { + if x != nil { + return x.MessageId + } + return "" +} + +func (x *Message) GetContextId() string { + if x != nil { + return x.ContextId + } + return "" +} + +func (x *Message) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *Message) GetRole() Role { + if x != nil { + return x.Role + } + return Role_ROLE_UNSPECIFIED +} + +func (x *Message) GetParts() []*Part { + if x != nil { + return x.Parts + } + return nil +} + +func (x *Message) GetMetadata() *structpb.Struct { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Message) GetExtensions() []string { + if x != nil { + return x.Extensions + } + return nil +} + +func (x *Message) GetReferenceTaskIds() []string { + if x != nil { + return x.ReferenceTaskIds + } + return nil +} + +// Artifacts represent task outputs. +type Artifact struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Unique identifier (e.g. UUID) for the artifact. It must be unique within a task. + ArtifactId string `protobuf:"bytes,1,opt,name=artifact_id,json=artifactId,proto3" json:"artifact_id,omitempty"` + // A human readable name for the artifact. + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // Optional. A human readable description of the artifact. + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + // The content of the artifact. Must contain at least one part. + Parts []*Part `protobuf:"bytes,4,rep,name=parts,proto3" json:"parts,omitempty"` + // Optional. Metadata included with the artifact. + Metadata *structpb.Struct `protobuf:"bytes,5,opt,name=metadata,proto3" json:"metadata,omitempty"` + // The URIs of extensions that are present or contributed to this Artifact. + Extensions []string `protobuf:"bytes,6,rep,name=extensions,proto3" json:"extensions,omitempty"` +} + +func (x *Artifact) Reset() { + *x = Artifact{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[5] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *Artifact) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*Artifact) ProtoMessage() {} + +func (x *Artifact) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[5] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use Artifact.ProtoReflect.Descriptor instead. +func (*Artifact) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{5} +} + +func (x *Artifact) GetArtifactId() string { + if x != nil { + return x.ArtifactId + } + return "" +} + +func (x *Artifact) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *Artifact) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *Artifact) GetParts() []*Part { + if x != nil { + return x.Parts + } + return nil +} + +func (x *Artifact) GetMetadata() *structpb.Struct { + if x != nil { + return x.Metadata + } + return nil +} + +func (x *Artifact) GetExtensions() []string { + if x != nil { + return x.Extensions + } + return nil +} + +// An event sent by the agent to notify the client of a change in a task's status. +type TaskStatusUpdateEvent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The ID of the task that has changed. + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + // The ID of the context that the task belongs to. + ContextId string `protobuf:"bytes,2,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` + // The new status of the task. + Status *TaskStatus `protobuf:"bytes,3,opt,name=status,proto3" json:"status,omitempty"` + // Optional. Metadata associated with the task update. + Metadata *structpb.Struct `protobuf:"bytes,4,opt,name=metadata,proto3" json:"metadata,omitempty"` +} + +func (x *TaskStatusUpdateEvent) Reset() { + *x = TaskStatusUpdateEvent{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[6] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TaskStatusUpdateEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TaskStatusUpdateEvent) ProtoMessage() {} + +func (x *TaskStatusUpdateEvent) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[6] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TaskStatusUpdateEvent.ProtoReflect.Descriptor instead. +func (*TaskStatusUpdateEvent) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{6} +} + +func (x *TaskStatusUpdateEvent) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *TaskStatusUpdateEvent) GetContextId() string { + if x != nil { + return x.ContextId + } + return "" +} + +func (x *TaskStatusUpdateEvent) GetStatus() *TaskStatus { + if x != nil { + return x.Status + } + return nil +} + +func (x *TaskStatusUpdateEvent) GetMetadata() *structpb.Struct { + if x != nil { + return x.Metadata + } + return nil +} + +// A task delta where an artifact has been generated. +type TaskArtifactUpdateEvent struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The ID of the task for this artifact. + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + // The ID of the context that this task belongs to. + ContextId string `protobuf:"bytes,2,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` + // The artifact that was generated or updated. + Artifact *Artifact `protobuf:"bytes,3,opt,name=artifact,proto3" json:"artifact,omitempty"` + // If true, the content of this artifact should be appended to a previously + // sent artifact with the same ID. + Append bool `protobuf:"varint,4,opt,name=append,proto3" json:"append,omitempty"` + // If true, this is the final chunk of the artifact. + LastChunk bool `protobuf:"varint,5,opt,name=last_chunk,json=lastChunk,proto3" json:"last_chunk,omitempty"` + // Optional. Metadata associated with the artifact update. + Metadata *structpb.Struct `protobuf:"bytes,6,opt,name=metadata,proto3" json:"metadata,omitempty"` +} + +func (x *TaskArtifactUpdateEvent) Reset() { + *x = TaskArtifactUpdateEvent{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[7] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TaskArtifactUpdateEvent) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TaskArtifactUpdateEvent) ProtoMessage() {} + +func (x *TaskArtifactUpdateEvent) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[7] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TaskArtifactUpdateEvent.ProtoReflect.Descriptor instead. +func (*TaskArtifactUpdateEvent) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{7} +} + +func (x *TaskArtifactUpdateEvent) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *TaskArtifactUpdateEvent) GetContextId() string { + if x != nil { + return x.ContextId + } + return "" +} + +func (x *TaskArtifactUpdateEvent) GetArtifact() *Artifact { + if x != nil { + return x.Artifact + } + return nil +} + +func (x *TaskArtifactUpdateEvent) GetAppend() bool { + if x != nil { + return x.Append + } + return false +} + +func (x *TaskArtifactUpdateEvent) GetLastChunk() bool { + if x != nil { + return x.LastChunk + } + return false +} + +func (x *TaskArtifactUpdateEvent) GetMetadata() *structpb.Struct { + if x != nil { + return x.Metadata + } + return nil +} + +// Defines authentication details, used for push notifications. +type AuthenticationInfo struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // HTTP Authentication Scheme from the [IANA registry](https://www.iana.org/assignments/http-authschemes/). + // Examples: `Bearer`, `Basic`, `Digest`. + // Scheme names are case-insensitive per [RFC 9110 Section 11.1](https://www.rfc-editor.org/rfc/rfc9110#section-11.1). + Scheme string `protobuf:"bytes,1,opt,name=scheme,proto3" json:"scheme,omitempty"` + // Push Notification credentials. Format depends on the scheme (e.g., token for Bearer). + Credentials string `protobuf:"bytes,2,opt,name=credentials,proto3" json:"credentials,omitempty"` +} + +func (x *AuthenticationInfo) Reset() { + *x = AuthenticationInfo{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[8] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AuthenticationInfo) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthenticationInfo) ProtoMessage() {} + +func (x *AuthenticationInfo) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[8] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthenticationInfo.ProtoReflect.Descriptor instead. +func (*AuthenticationInfo) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{8} +} + +func (x *AuthenticationInfo) GetScheme() string { + if x != nil { + return x.Scheme + } + return "" +} + +func (x *AuthenticationInfo) GetCredentials() string { + if x != nil { + return x.Credentials + } + return "" +} + +// Declares a combination of a target URL, transport and protocol version for interacting with the agent. +// This allows agents to expose the same functionality over multiple protocol binding mechanisms. +type AgentInterface struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The URL or address where this interface is available. For HTTP-based transports, must be a valid absolute + // HTTPS URL in production. For gRPC, the address should be in the format "hostname:port". + // Example: "https://api.example.com/a2a/v1", "grpc.example.com:443" + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + // The protocol binding supported at this URL. This is an open form string, to be + // easily extended for other protocol bindings. The core ones officially + // supported are `JSONRPC`, `GRPC` and `HTTP+JSON`. + ProtocolBinding string `protobuf:"bytes,2,opt,name=protocol_binding,json=protocolBinding,proto3" json:"protocol_binding,omitempty"` + // Optional. An opaque string used for routing requests to a specific agent + // or tenant when multiple agents are served behind a single A2A endpoint. + // When set, clients MUST include this value in the `tenant` field of all + // request messages sent to this interface. The server is responsible for + // interpreting the value and routing requests accordingly; the protocol + // does not define its format or semantics. + Tenant string `protobuf:"bytes,3,opt,name=tenant,proto3" json:"tenant,omitempty"` + // The version of the A2A protocol this interface exposes. + // Use the latest supported minor version per major version. + // Examples: "0.3", "1.0" + ProtocolVersion string `protobuf:"bytes,4,opt,name=protocol_version,json=protocolVersion,proto3" json:"protocol_version,omitempty"` +} + +func (x *AgentInterface) Reset() { + *x = AgentInterface{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[9] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentInterface) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentInterface) ProtoMessage() {} + +func (x *AgentInterface) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[9] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentInterface.ProtoReflect.Descriptor instead. +func (*AgentInterface) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{9} +} + +func (x *AgentInterface) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *AgentInterface) GetProtocolBinding() string { + if x != nil { + return x.ProtocolBinding + } + return "" +} + +func (x *AgentInterface) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *AgentInterface) GetProtocolVersion() string { + if x != nil { + return x.ProtocolVersion + } + return "" +} + +// A self-describing manifest for an agent. It provides essential +// metadata including the agent's identity, capabilities, skills, supported +// communication methods, and security requirements. +// Next ID: 20 +type AgentCard struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // A human readable name for the agent. + // Example: "Recipe Agent" + Name string `protobuf:"bytes,1,opt,name=name,proto3" json:"name,omitempty"` + // A human-readable description of the agent, assisting users and other agents + // in understanding its purpose. + // Example: "Agent that helps users with recipes and cooking." + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + // Ordered list of supported interfaces. The first entry is preferred. + SupportedInterfaces []*AgentInterface `protobuf:"bytes,3,rep,name=supported_interfaces,json=supportedInterfaces,proto3" json:"supported_interfaces,omitempty"` + // The service provider of the agent. + Provider *AgentProvider `protobuf:"bytes,4,opt,name=provider,proto3" json:"provider,omitempty"` + // The version of the agent. + // Example: "1.0.0" + Version string `protobuf:"bytes,5,opt,name=version,proto3" json:"version,omitempty"` + // A URL providing additional documentation about the agent. + DocumentationUrl *string `protobuf:"bytes,6,opt,name=documentation_url,json=documentationUrl,proto3,oneof" json:"documentation_url,omitempty"` + // A2A Capability set supported by the agent. + Capabilities *AgentCapabilities `protobuf:"bytes,7,opt,name=capabilities,proto3" json:"capabilities,omitempty"` + // The security scheme details used for authenticating with this agent. + SecuritySchemes map[string]*SecurityScheme `protobuf:"bytes,8,rep,name=security_schemes,json=securitySchemes,proto3" json:"security_schemes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Security requirements for contacting the agent. + SecurityRequirements []*SecurityRequirement `protobuf:"bytes,9,rep,name=security_requirements,json=securityRequirements,proto3" json:"security_requirements,omitempty"` + // protolint:enable REPEATED_FIELD_NAMES_PLURALIZED + // The set of interaction modes that the agent supports across all skills. + // This can be overridden per skill. Defined as media types. + DefaultInputModes []string `protobuf:"bytes,10,rep,name=default_input_modes,json=defaultInputModes,proto3" json:"default_input_modes,omitempty"` + // The media types supported as outputs from this agent. + DefaultOutputModes []string `protobuf:"bytes,11,rep,name=default_output_modes,json=defaultOutputModes,proto3" json:"default_output_modes,omitempty"` + // Skills represent the abilities of an agent. + // It is largely a descriptive concept but represents a more focused set of behaviors that the + // agent is likely to succeed at. + Skills []*AgentSkill `protobuf:"bytes,12,rep,name=skills,proto3" json:"skills,omitempty"` + // JSON Web Signatures computed for this `AgentCard`. + Signatures []*AgentCardSignature `protobuf:"bytes,13,rep,name=signatures,proto3" json:"signatures,omitempty"` + // Optional. A URL to an icon for the agent. + IconUrl *string `protobuf:"bytes,14,opt,name=icon_url,json=iconUrl,proto3,oneof" json:"icon_url,omitempty"` +} + +func (x *AgentCard) Reset() { + *x = AgentCard{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[10] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentCard) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentCard) ProtoMessage() {} + +func (x *AgentCard) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[10] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentCard.ProtoReflect.Descriptor instead. +func (*AgentCard) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{10} +} + +func (x *AgentCard) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AgentCard) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *AgentCard) GetSupportedInterfaces() []*AgentInterface { + if x != nil { + return x.SupportedInterfaces + } + return nil +} + +func (x *AgentCard) GetProvider() *AgentProvider { + if x != nil { + return x.Provider + } + return nil +} + +func (x *AgentCard) GetVersion() string { + if x != nil { + return x.Version + } + return "" +} + +func (x *AgentCard) GetDocumentationUrl() string { + if x != nil && x.DocumentationUrl != nil { + return *x.DocumentationUrl + } + return "" +} + +func (x *AgentCard) GetCapabilities() *AgentCapabilities { + if x != nil { + return x.Capabilities + } + return nil +} + +func (x *AgentCard) GetSecuritySchemes() map[string]*SecurityScheme { + if x != nil { + return x.SecuritySchemes + } + return nil +} + +func (x *AgentCard) GetSecurityRequirements() []*SecurityRequirement { + if x != nil { + return x.SecurityRequirements + } + return nil +} + +func (x *AgentCard) GetDefaultInputModes() []string { + if x != nil { + return x.DefaultInputModes + } + return nil +} + +func (x *AgentCard) GetDefaultOutputModes() []string { + if x != nil { + return x.DefaultOutputModes + } + return nil +} + +func (x *AgentCard) GetSkills() []*AgentSkill { + if x != nil { + return x.Skills + } + return nil +} + +func (x *AgentCard) GetSignatures() []*AgentCardSignature { + if x != nil { + return x.Signatures + } + return nil +} + +func (x *AgentCard) GetIconUrl() string { + if x != nil && x.IconUrl != nil { + return *x.IconUrl + } + return "" +} + +// Represents the service provider of an agent. +type AgentProvider struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // A URL for the agent provider's website or relevant documentation. + // Example: "https://ai.google.dev" + Url string `protobuf:"bytes,1,opt,name=url,proto3" json:"url,omitempty"` + // The name of the agent provider's organization. + // Example: "Google" + Organization string `protobuf:"bytes,2,opt,name=organization,proto3" json:"organization,omitempty"` +} + +func (x *AgentProvider) Reset() { + *x = AgentProvider{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[11] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentProvider) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentProvider) ProtoMessage() {} + +func (x *AgentProvider) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[11] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentProvider.ProtoReflect.Descriptor instead. +func (*AgentProvider) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{11} +} + +func (x *AgentProvider) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *AgentProvider) GetOrganization() string { + if x != nil { + return x.Organization + } + return "" +} + +// Defines optional capabilities supported by an agent. +type AgentCapabilities struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Indicates if the agent supports streaming responses. + Streaming *bool `protobuf:"varint,1,opt,name=streaming,proto3,oneof" json:"streaming,omitempty"` + // Indicates if the agent supports sending push notifications for asynchronous task updates. + PushNotifications *bool `protobuf:"varint,2,opt,name=push_notifications,json=pushNotifications,proto3,oneof" json:"push_notifications,omitempty"` + // A list of protocol extensions supported by the agent. + Extensions []*AgentExtension `protobuf:"bytes,3,rep,name=extensions,proto3" json:"extensions,omitempty"` + // Indicates if the agent supports providing an extended agent card when authenticated. + ExtendedAgentCard *bool `protobuf:"varint,4,opt,name=extended_agent_card,json=extendedAgentCard,proto3,oneof" json:"extended_agent_card,omitempty"` +} + +func (x *AgentCapabilities) Reset() { + *x = AgentCapabilities{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[12] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentCapabilities) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentCapabilities) ProtoMessage() {} + +func (x *AgentCapabilities) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[12] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentCapabilities.ProtoReflect.Descriptor instead. +func (*AgentCapabilities) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{12} +} + +func (x *AgentCapabilities) GetStreaming() bool { + if x != nil && x.Streaming != nil { + return *x.Streaming + } + return false +} + +func (x *AgentCapabilities) GetPushNotifications() bool { + if x != nil && x.PushNotifications != nil { + return *x.PushNotifications + } + return false +} + +func (x *AgentCapabilities) GetExtensions() []*AgentExtension { + if x != nil { + return x.Extensions + } + return nil +} + +func (x *AgentCapabilities) GetExtendedAgentCard() bool { + if x != nil && x.ExtendedAgentCard != nil { + return *x.ExtendedAgentCard + } + return false +} + +// A declaration of a protocol extension supported by an Agent. +type AgentExtension struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The unique URI identifying the extension. + Uri string `protobuf:"bytes,1,opt,name=uri,proto3" json:"uri,omitempty"` + // A human-readable description of how this agent uses the extension. + Description string `protobuf:"bytes,2,opt,name=description,proto3" json:"description,omitempty"` + // If true, the client must understand and comply with the extension's requirements. + Required bool `protobuf:"varint,3,opt,name=required,proto3" json:"required,omitempty"` + // Optional. Extension-specific configuration parameters. + Params *structpb.Struct `protobuf:"bytes,4,opt,name=params,proto3" json:"params,omitempty"` +} + +func (x *AgentExtension) Reset() { + *x = AgentExtension{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[13] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentExtension) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentExtension) ProtoMessage() {} + +func (x *AgentExtension) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[13] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentExtension.ProtoReflect.Descriptor instead. +func (*AgentExtension) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{13} +} + +func (x *AgentExtension) GetUri() string { + if x != nil { + return x.Uri + } + return "" +} + +func (x *AgentExtension) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *AgentExtension) GetRequired() bool { + if x != nil { + return x.Required + } + return false +} + +func (x *AgentExtension) GetParams() *structpb.Struct { + if x != nil { + return x.Params + } + return nil +} + +// Represents a distinct capability or function that an agent can perform. +type AgentSkill struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // A unique identifier for the agent's skill. + Id string `protobuf:"bytes,1,opt,name=id,proto3" json:"id,omitempty"` + // A human-readable name for the skill. + Name string `protobuf:"bytes,2,opt,name=name,proto3" json:"name,omitempty"` + // A detailed description of the skill. + Description string `protobuf:"bytes,3,opt,name=description,proto3" json:"description,omitempty"` + // A set of keywords describing the skill's capabilities. + Tags []string `protobuf:"bytes,4,rep,name=tags,proto3" json:"tags,omitempty"` + // Example prompts or scenarios that this skill can handle. + Examples []string `protobuf:"bytes,5,rep,name=examples,proto3" json:"examples,omitempty"` + // The set of supported input media types for this skill, overriding the agent's defaults. + InputModes []string `protobuf:"bytes,6,rep,name=input_modes,json=inputModes,proto3" json:"input_modes,omitempty"` + // The set of supported output media types for this skill, overriding the agent's defaults. + OutputModes []string `protobuf:"bytes,7,rep,name=output_modes,json=outputModes,proto3" json:"output_modes,omitempty"` + // Security schemes necessary for this skill. + SecurityRequirements []*SecurityRequirement `protobuf:"bytes,8,rep,name=security_requirements,json=securityRequirements,proto3" json:"security_requirements,omitempty"` +} + +func (x *AgentSkill) Reset() { + *x = AgentSkill{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[14] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentSkill) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentSkill) ProtoMessage() {} + +func (x *AgentSkill) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[14] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentSkill.ProtoReflect.Descriptor instead. +func (*AgentSkill) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{14} +} + +func (x *AgentSkill) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *AgentSkill) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +func (x *AgentSkill) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *AgentSkill) GetTags() []string { + if x != nil { + return x.Tags + } + return nil +} + +func (x *AgentSkill) GetExamples() []string { + if x != nil { + return x.Examples + } + return nil +} + +func (x *AgentSkill) GetInputModes() []string { + if x != nil { + return x.InputModes + } + return nil +} + +func (x *AgentSkill) GetOutputModes() []string { + if x != nil { + return x.OutputModes + } + return nil +} + +func (x *AgentSkill) GetSecurityRequirements() []*SecurityRequirement { + if x != nil { + return x.SecurityRequirements + } + return nil +} + +// AgentCardSignature represents a JWS signature of an AgentCard. +// This follows the JSON format of an RFC 7515 JSON Web Signature (JWS). +type AgentCardSignature struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // (-- api-linter: core::0140::reserved-words=disabled + // + // aip.dev/not-precedent: Backwards compatibility --) + // + // Required. The protected JWS header for the signature. This is always a + // base64url-encoded JSON object. + Protected string `protobuf:"bytes,1,opt,name=protected,proto3" json:"protected,omitempty"` + // Required. The computed signature, base64url-encoded. + Signature string `protobuf:"bytes,2,opt,name=signature,proto3" json:"signature,omitempty"` + // The unprotected JWS header values. + Header *structpb.Struct `protobuf:"bytes,3,opt,name=header,proto3" json:"header,omitempty"` +} + +func (x *AgentCardSignature) Reset() { + *x = AgentCardSignature{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[15] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AgentCardSignature) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AgentCardSignature) ProtoMessage() {} + +func (x *AgentCardSignature) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[15] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AgentCardSignature.ProtoReflect.Descriptor instead. +func (*AgentCardSignature) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{15} +} + +func (x *AgentCardSignature) GetProtected() string { + if x != nil { + return x.Protected + } + return "" +} + +func (x *AgentCardSignature) GetSignature() string { + if x != nil { + return x.Signature + } + return "" +} + +func (x *AgentCardSignature) GetHeader() *structpb.Struct { + if x != nil { + return x.Header + } + return nil +} + +// A container associating a push notification configuration with a specific task. +type TaskPushNotificationConfig struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` + // The push notification configuration details. + // A unique identifier (e.g. UUID) for this push notification configuration. + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + // The ID of the task this configuration is associated with. + TaskId string `protobuf:"bytes,3,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + // The URL where the notification should be sent. + Url string `protobuf:"bytes,4,opt,name=url,proto3" json:"url,omitempty"` + // A token unique for this task or session. + Token string `protobuf:"bytes,5,opt,name=token,proto3" json:"token,omitempty"` + // Authentication information required to send the notification. + Authentication *AuthenticationInfo `protobuf:"bytes,6,opt,name=authentication,proto3" json:"authentication,omitempty"` +} + +func (x *TaskPushNotificationConfig) Reset() { + *x = TaskPushNotificationConfig{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[16] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *TaskPushNotificationConfig) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*TaskPushNotificationConfig) ProtoMessage() {} + +func (x *TaskPushNotificationConfig) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[16] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use TaskPushNotificationConfig.ProtoReflect.Descriptor instead. +func (*TaskPushNotificationConfig) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{16} +} + +func (x *TaskPushNotificationConfig) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *TaskPushNotificationConfig) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *TaskPushNotificationConfig) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *TaskPushNotificationConfig) GetUrl() string { + if x != nil { + return x.Url + } + return "" +} + +func (x *TaskPushNotificationConfig) GetToken() string { + if x != nil { + return x.Token + } + return "" +} + +func (x *TaskPushNotificationConfig) GetAuthentication() *AuthenticationInfo { + if x != nil { + return x.Authentication + } + return nil +} + +// protolint:disable REPEATED_FIELD_NAMES_PLURALIZED +// A list of strings. +type StringList struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The individual string values. + List []string `protobuf:"bytes,1,rep,name=list,proto3" json:"list,omitempty"` +} + +func (x *StringList) Reset() { + *x = StringList{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[17] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *StringList) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StringList) ProtoMessage() {} + +func (x *StringList) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[17] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StringList.ProtoReflect.Descriptor instead. +func (*StringList) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{17} +} + +func (x *StringList) GetList() []string { + if x != nil { + return x.List + } + return nil +} + +// Defines the security requirements for an agent. +type SecurityRequirement struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // A map of security schemes to the required scopes. + Schemes map[string]*StringList `protobuf:"bytes,1,rep,name=schemes,proto3" json:"schemes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *SecurityRequirement) Reset() { + *x = SecurityRequirement{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[18] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SecurityRequirement) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecurityRequirement) ProtoMessage() {} + +func (x *SecurityRequirement) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[18] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecurityRequirement.ProtoReflect.Descriptor instead. +func (*SecurityRequirement) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{18} +} + +func (x *SecurityRequirement) GetSchemes() map[string]*StringList { + if x != nil { + return x.Schemes + } + return nil +} + +// Defines a security scheme that can be used to secure an agent's endpoints. +// This is a discriminated union type based on the OpenAPI 3.2 Security Scheme Object. +// See: https://spec.openapis.org/oas/v3.2.0.html#security-scheme-object +type SecurityScheme struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Scheme: + // + // *SecurityScheme_ApiKeySecurityScheme + // *SecurityScheme_HttpAuthSecurityScheme + // *SecurityScheme_Oauth2SecurityScheme + // *SecurityScheme_OpenIdConnectSecurityScheme + // *SecurityScheme_MtlsSecurityScheme + Scheme isSecurityScheme_Scheme `protobuf_oneof:"scheme"` +} + +func (x *SecurityScheme) Reset() { + *x = SecurityScheme{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[19] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SecurityScheme) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SecurityScheme) ProtoMessage() {} + +func (x *SecurityScheme) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[19] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SecurityScheme.ProtoReflect.Descriptor instead. +func (*SecurityScheme) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{19} +} + +func (m *SecurityScheme) GetScheme() isSecurityScheme_Scheme { + if m != nil { + return m.Scheme + } + return nil +} + +func (x *SecurityScheme) GetApiKeySecurityScheme() *APIKeySecurityScheme { + if x, ok := x.GetScheme().(*SecurityScheme_ApiKeySecurityScheme); ok { + return x.ApiKeySecurityScheme + } + return nil +} + +func (x *SecurityScheme) GetHttpAuthSecurityScheme() *HTTPAuthSecurityScheme { + if x, ok := x.GetScheme().(*SecurityScheme_HttpAuthSecurityScheme); ok { + return x.HttpAuthSecurityScheme + } + return nil +} + +func (x *SecurityScheme) GetOauth2SecurityScheme() *OAuth2SecurityScheme { + if x, ok := x.GetScheme().(*SecurityScheme_Oauth2SecurityScheme); ok { + return x.Oauth2SecurityScheme + } + return nil +} + +func (x *SecurityScheme) GetOpenIdConnectSecurityScheme() *OpenIdConnectSecurityScheme { + if x, ok := x.GetScheme().(*SecurityScheme_OpenIdConnectSecurityScheme); ok { + return x.OpenIdConnectSecurityScheme + } + return nil +} + +func (x *SecurityScheme) GetMtlsSecurityScheme() *MutualTlsSecurityScheme { + if x, ok := x.GetScheme().(*SecurityScheme_MtlsSecurityScheme); ok { + return x.MtlsSecurityScheme + } + return nil +} + +type isSecurityScheme_Scheme interface { + isSecurityScheme_Scheme() +} + +type SecurityScheme_ApiKeySecurityScheme struct { + // API key-based authentication. + ApiKeySecurityScheme *APIKeySecurityScheme `protobuf:"bytes,1,opt,name=api_key_security_scheme,json=apiKeySecurityScheme,proto3,oneof"` +} + +type SecurityScheme_HttpAuthSecurityScheme struct { + // HTTP authentication (Basic, Bearer, etc.). + HttpAuthSecurityScheme *HTTPAuthSecurityScheme `protobuf:"bytes,2,opt,name=http_auth_security_scheme,json=httpAuthSecurityScheme,proto3,oneof"` +} + +type SecurityScheme_Oauth2SecurityScheme struct { + // OAuth 2.0 authentication. + Oauth2SecurityScheme *OAuth2SecurityScheme `protobuf:"bytes,3,opt,name=oauth2_security_scheme,json=oauth2SecurityScheme,proto3,oneof"` +} + +type SecurityScheme_OpenIdConnectSecurityScheme struct { + // OpenID Connect authentication. + OpenIdConnectSecurityScheme *OpenIdConnectSecurityScheme `protobuf:"bytes,4,opt,name=open_id_connect_security_scheme,json=openIdConnectSecurityScheme,proto3,oneof"` +} + +type SecurityScheme_MtlsSecurityScheme struct { + // Mutual TLS authentication. + MtlsSecurityScheme *MutualTlsSecurityScheme `protobuf:"bytes,5,opt,name=mtls_security_scheme,json=mtlsSecurityScheme,proto3,oneof"` +} + +func (*SecurityScheme_ApiKeySecurityScheme) isSecurityScheme_Scheme() {} + +func (*SecurityScheme_HttpAuthSecurityScheme) isSecurityScheme_Scheme() {} + +func (*SecurityScheme_Oauth2SecurityScheme) isSecurityScheme_Scheme() {} + +func (*SecurityScheme_OpenIdConnectSecurityScheme) isSecurityScheme_Scheme() {} + +func (*SecurityScheme_MtlsSecurityScheme) isSecurityScheme_Scheme() {} + +// Defines a security scheme using an API key. +type APIKeySecurityScheme struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // An optional description for the security scheme. + Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"` + // The location of the API key. Valid values are "query", "header", or "cookie". + Location string `protobuf:"bytes,2,opt,name=location,proto3" json:"location,omitempty"` + // The name of the header, query, or cookie parameter to be used. + Name string `protobuf:"bytes,3,opt,name=name,proto3" json:"name,omitempty"` +} + +func (x *APIKeySecurityScheme) Reset() { + *x = APIKeySecurityScheme{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[20] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *APIKeySecurityScheme) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*APIKeySecurityScheme) ProtoMessage() {} + +func (x *APIKeySecurityScheme) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[20] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use APIKeySecurityScheme.ProtoReflect.Descriptor instead. +func (*APIKeySecurityScheme) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{20} +} + +func (x *APIKeySecurityScheme) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *APIKeySecurityScheme) GetLocation() string { + if x != nil { + return x.Location + } + return "" +} + +func (x *APIKeySecurityScheme) GetName() string { + if x != nil { + return x.Name + } + return "" +} + +// Defines a security scheme using HTTP authentication. +type HTTPAuthSecurityScheme struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // An optional description for the security scheme. + Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"` + // The name of the HTTP Authentication scheme to be used in the Authorization header, + // as defined in RFC7235 (e.g., "Bearer"). + // This value should be registered in the IANA Authentication Scheme registry. + Scheme string `protobuf:"bytes,2,opt,name=scheme,proto3" json:"scheme,omitempty"` + // A hint to the client to identify how the bearer token is formatted (e.g., "JWT"). + // Primarily for documentation purposes. + BearerFormat string `protobuf:"bytes,3,opt,name=bearer_format,json=bearerFormat,proto3" json:"bearer_format,omitempty"` +} + +func (x *HTTPAuthSecurityScheme) Reset() { + *x = HTTPAuthSecurityScheme{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[21] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *HTTPAuthSecurityScheme) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*HTTPAuthSecurityScheme) ProtoMessage() {} + +func (x *HTTPAuthSecurityScheme) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[21] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use HTTPAuthSecurityScheme.ProtoReflect.Descriptor instead. +func (*HTTPAuthSecurityScheme) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{21} +} + +func (x *HTTPAuthSecurityScheme) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *HTTPAuthSecurityScheme) GetScheme() string { + if x != nil { + return x.Scheme + } + return "" +} + +func (x *HTTPAuthSecurityScheme) GetBearerFormat() string { + if x != nil { + return x.BearerFormat + } + return "" +} + +// Defines a security scheme using OAuth 2.0. +type OAuth2SecurityScheme struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // An optional description for the security scheme. + Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"` + // An object containing configuration information for the supported OAuth 2.0 flows. + Flows *OAuthFlows `protobuf:"bytes,2,opt,name=flows,proto3" json:"flows,omitempty"` + // URL to the OAuth2 authorization server metadata [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414). + // TLS is required. + Oauth2MetadataUrl string `protobuf:"bytes,3,opt,name=oauth2_metadata_url,json=oauth2MetadataUrl,proto3" json:"oauth2_metadata_url,omitempty"` +} + +func (x *OAuth2SecurityScheme) Reset() { + *x = OAuth2SecurityScheme{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[22] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OAuth2SecurityScheme) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OAuth2SecurityScheme) ProtoMessage() {} + +func (x *OAuth2SecurityScheme) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[22] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OAuth2SecurityScheme.ProtoReflect.Descriptor instead. +func (*OAuth2SecurityScheme) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{22} +} + +func (x *OAuth2SecurityScheme) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *OAuth2SecurityScheme) GetFlows() *OAuthFlows { + if x != nil { + return x.Flows + } + return nil +} + +func (x *OAuth2SecurityScheme) GetOauth2MetadataUrl() string { + if x != nil { + return x.Oauth2MetadataUrl + } + return "" +} + +// Defines a security scheme using OpenID Connect. +type OpenIdConnectSecurityScheme struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // An optional description for the security scheme. + Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"` + // The [OpenID Connect Discovery URL](https://openid.net/specs/openid-connect-discovery-1_0.html) for the OIDC provider's metadata. + OpenIdConnectUrl string `protobuf:"bytes,2,opt,name=open_id_connect_url,json=openIdConnectUrl,proto3" json:"open_id_connect_url,omitempty"` +} + +func (x *OpenIdConnectSecurityScheme) Reset() { + *x = OpenIdConnectSecurityScheme{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[23] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OpenIdConnectSecurityScheme) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OpenIdConnectSecurityScheme) ProtoMessage() {} + +func (x *OpenIdConnectSecurityScheme) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[23] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OpenIdConnectSecurityScheme.ProtoReflect.Descriptor instead. +func (*OpenIdConnectSecurityScheme) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{23} +} + +func (x *OpenIdConnectSecurityScheme) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +func (x *OpenIdConnectSecurityScheme) GetOpenIdConnectUrl() string { + if x != nil { + return x.OpenIdConnectUrl + } + return "" +} + +// Defines a security scheme using mTLS authentication. +type MutualTlsSecurityScheme struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // An optional description for the security scheme. + Description string `protobuf:"bytes,1,opt,name=description,proto3" json:"description,omitempty"` +} + +func (x *MutualTlsSecurityScheme) Reset() { + *x = MutualTlsSecurityScheme{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[24] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *MutualTlsSecurityScheme) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*MutualTlsSecurityScheme) ProtoMessage() {} + +func (x *MutualTlsSecurityScheme) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[24] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use MutualTlsSecurityScheme.ProtoReflect.Descriptor instead. +func (*MutualTlsSecurityScheme) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{24} +} + +func (x *MutualTlsSecurityScheme) GetDescription() string { + if x != nil { + return x.Description + } + return "" +} + +// Defines the configuration for the supported OAuth 2.0 flows. +type OAuthFlows struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Types that are assignable to Flow: + // + // *OAuthFlows_AuthorizationCode + // *OAuthFlows_ClientCredentials + // *OAuthFlows_Implicit + // *OAuthFlows_Password + // *OAuthFlows_DeviceCode + Flow isOAuthFlows_Flow `protobuf_oneof:"flow"` +} + +func (x *OAuthFlows) Reset() { + *x = OAuthFlows{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[25] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *OAuthFlows) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*OAuthFlows) ProtoMessage() {} + +func (x *OAuthFlows) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[25] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use OAuthFlows.ProtoReflect.Descriptor instead. +func (*OAuthFlows) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{25} +} + +func (m *OAuthFlows) GetFlow() isOAuthFlows_Flow { + if m != nil { + return m.Flow + } + return nil +} + +func (x *OAuthFlows) GetAuthorizationCode() *AuthorizationCodeOAuthFlow { + if x, ok := x.GetFlow().(*OAuthFlows_AuthorizationCode); ok { + return x.AuthorizationCode + } + return nil +} + +func (x *OAuthFlows) GetClientCredentials() *ClientCredentialsOAuthFlow { + if x, ok := x.GetFlow().(*OAuthFlows_ClientCredentials); ok { + return x.ClientCredentials + } + return nil +} + +// Deprecated: Marked as deprecated in a2a.proto. +func (x *OAuthFlows) GetImplicit() *ImplicitOAuthFlow { + if x, ok := x.GetFlow().(*OAuthFlows_Implicit); ok { + return x.Implicit + } + return nil +} + +// Deprecated: Marked as deprecated in a2a.proto. +func (x *OAuthFlows) GetPassword() *PasswordOAuthFlow { + if x, ok := x.GetFlow().(*OAuthFlows_Password); ok { + return x.Password + } + return nil +} + +func (x *OAuthFlows) GetDeviceCode() *DeviceCodeOAuthFlow { + if x, ok := x.GetFlow().(*OAuthFlows_DeviceCode); ok { + return x.DeviceCode + } + return nil +} + +type isOAuthFlows_Flow interface { + isOAuthFlows_Flow() +} + +type OAuthFlows_AuthorizationCode struct { + // Configuration for the OAuth Authorization Code flow. + AuthorizationCode *AuthorizationCodeOAuthFlow `protobuf:"bytes,1,opt,name=authorization_code,json=authorizationCode,proto3,oneof"` +} + +type OAuthFlows_ClientCredentials struct { + // Configuration for the OAuth Client Credentials flow. + ClientCredentials *ClientCredentialsOAuthFlow `protobuf:"bytes,2,opt,name=client_credentials,json=clientCredentials,proto3,oneof"` +} + +type OAuthFlows_Implicit struct { + // Deprecated: Use Authorization Code + PKCE instead. + // + // Deprecated: Marked as deprecated in a2a.proto. + Implicit *ImplicitOAuthFlow `protobuf:"bytes,3,opt,name=implicit,proto3,oneof"` +} + +type OAuthFlows_Password struct { + // Deprecated: Use Authorization Code + PKCE or Device Code. + // + // Deprecated: Marked as deprecated in a2a.proto. + Password *PasswordOAuthFlow `protobuf:"bytes,4,opt,name=password,proto3,oneof"` +} + +type OAuthFlows_DeviceCode struct { + // Configuration for the OAuth Device Code flow. + DeviceCode *DeviceCodeOAuthFlow `protobuf:"bytes,5,opt,name=device_code,json=deviceCode,proto3,oneof"` +} + +func (*OAuthFlows_AuthorizationCode) isOAuthFlows_Flow() {} + +func (*OAuthFlows_ClientCredentials) isOAuthFlows_Flow() {} + +func (*OAuthFlows_Implicit) isOAuthFlows_Flow() {} + +func (*OAuthFlows_Password) isOAuthFlows_Flow() {} + +func (*OAuthFlows_DeviceCode) isOAuthFlows_Flow() {} + +// Defines configuration details for the OAuth 2.0 Authorization Code flow. +type AuthorizationCodeOAuthFlow struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The authorization URL to be used for this flow. + AuthorizationUrl string `protobuf:"bytes,1,opt,name=authorization_url,json=authorizationUrl,proto3" json:"authorization_url,omitempty"` + // The token URL to be used for this flow. + TokenUrl string `protobuf:"bytes,2,opt,name=token_url,json=tokenUrl,proto3" json:"token_url,omitempty"` + // The URL to be used for obtaining refresh tokens. + RefreshUrl string `protobuf:"bytes,3,opt,name=refresh_url,json=refreshUrl,proto3" json:"refresh_url,omitempty"` + // The available scopes for the OAuth2 security scheme. + Scopes map[string]string `protobuf:"bytes,4,rep,name=scopes,proto3" json:"scopes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` + // Indicates if PKCE (RFC 7636) is required for this flow. + // PKCE should always be used for public clients and is recommended for all clients. + PkceRequired bool `protobuf:"varint,5,opt,name=pkce_required,json=pkceRequired,proto3" json:"pkce_required,omitempty"` +} + +func (x *AuthorizationCodeOAuthFlow) Reset() { + *x = AuthorizationCodeOAuthFlow{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[26] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *AuthorizationCodeOAuthFlow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*AuthorizationCodeOAuthFlow) ProtoMessage() {} + +func (x *AuthorizationCodeOAuthFlow) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[26] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use AuthorizationCodeOAuthFlow.ProtoReflect.Descriptor instead. +func (*AuthorizationCodeOAuthFlow) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{26} +} + +func (x *AuthorizationCodeOAuthFlow) GetAuthorizationUrl() string { + if x != nil { + return x.AuthorizationUrl + } + return "" +} + +func (x *AuthorizationCodeOAuthFlow) GetTokenUrl() string { + if x != nil { + return x.TokenUrl + } + return "" +} + +func (x *AuthorizationCodeOAuthFlow) GetRefreshUrl() string { + if x != nil { + return x.RefreshUrl + } + return "" +} + +func (x *AuthorizationCodeOAuthFlow) GetScopes() map[string]string { + if x != nil { + return x.Scopes + } + return nil +} + +func (x *AuthorizationCodeOAuthFlow) GetPkceRequired() bool { + if x != nil { + return x.PkceRequired + } + return false +} + +// Defines configuration details for the OAuth 2.0 Client Credentials flow. +type ClientCredentialsOAuthFlow struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The token URL to be used for this flow. + TokenUrl string `protobuf:"bytes,1,opt,name=token_url,json=tokenUrl,proto3" json:"token_url,omitempty"` + // The URL to be used for obtaining refresh tokens. + RefreshUrl string `protobuf:"bytes,2,opt,name=refresh_url,json=refreshUrl,proto3" json:"refresh_url,omitempty"` + // The available scopes for the OAuth2 security scheme. + Scopes map[string]string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *ClientCredentialsOAuthFlow) Reset() { + *x = ClientCredentialsOAuthFlow{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[27] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ClientCredentialsOAuthFlow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ClientCredentialsOAuthFlow) ProtoMessage() {} + +func (x *ClientCredentialsOAuthFlow) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[27] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ClientCredentialsOAuthFlow.ProtoReflect.Descriptor instead. +func (*ClientCredentialsOAuthFlow) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{27} +} + +func (x *ClientCredentialsOAuthFlow) GetTokenUrl() string { + if x != nil { + return x.TokenUrl + } + return "" +} + +func (x *ClientCredentialsOAuthFlow) GetRefreshUrl() string { + if x != nil { + return x.RefreshUrl + } + return "" +} + +func (x *ClientCredentialsOAuthFlow) GetScopes() map[string]string { + if x != nil { + return x.Scopes + } + return nil +} + +// Deprecated: Use Authorization Code + PKCE instead. +type ImplicitOAuthFlow struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The authorization URL to be used for this flow. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS + AuthorizationUrl string `protobuf:"bytes,1,opt,name=authorization_url,json=authorizationUrl,proto3" json:"authorization_url,omitempty"` + // The URL to be used for obtaining refresh tokens. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS. + RefreshUrl string `protobuf:"bytes,2,opt,name=refresh_url,json=refreshUrl,proto3" json:"refresh_url,omitempty"` + // The available scopes for the OAuth2 security scheme. A map between the + // scope name and a short description for it. The map MAY be empty. + Scopes map[string]string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *ImplicitOAuthFlow) Reset() { + *x = ImplicitOAuthFlow{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[28] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ImplicitOAuthFlow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ImplicitOAuthFlow) ProtoMessage() {} + +func (x *ImplicitOAuthFlow) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[28] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ImplicitOAuthFlow.ProtoReflect.Descriptor instead. +func (*ImplicitOAuthFlow) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{28} +} + +func (x *ImplicitOAuthFlow) GetAuthorizationUrl() string { + if x != nil { + return x.AuthorizationUrl + } + return "" +} + +func (x *ImplicitOAuthFlow) GetRefreshUrl() string { + if x != nil { + return x.RefreshUrl + } + return "" +} + +func (x *ImplicitOAuthFlow) GetScopes() map[string]string { + if x != nil { + return x.Scopes + } + return nil +} + +// Deprecated: Use Authorization Code + PKCE or Device Code. +type PasswordOAuthFlow struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The token URL to be used for this flow. This MUST be in the form of a URL. + // The OAuth2 standard requires the use of TLS. + TokenUrl string `protobuf:"bytes,1,opt,name=token_url,json=tokenUrl,proto3" json:"token_url,omitempty"` + // The URL to be used for obtaining refresh tokens. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS. + RefreshUrl string `protobuf:"bytes,2,opt,name=refresh_url,json=refreshUrl,proto3" json:"refresh_url,omitempty"` + // The available scopes for the OAuth2 security scheme. A map between the + // scope name and a short description for it. The map MAY be empty. + Scopes map[string]string `protobuf:"bytes,3,rep,name=scopes,proto3" json:"scopes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *PasswordOAuthFlow) Reset() { + *x = PasswordOAuthFlow{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[29] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *PasswordOAuthFlow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*PasswordOAuthFlow) ProtoMessage() {} + +func (x *PasswordOAuthFlow) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[29] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use PasswordOAuthFlow.ProtoReflect.Descriptor instead. +func (*PasswordOAuthFlow) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{29} +} + +func (x *PasswordOAuthFlow) GetTokenUrl() string { + if x != nil { + return x.TokenUrl + } + return "" +} + +func (x *PasswordOAuthFlow) GetRefreshUrl() string { + if x != nil { + return x.RefreshUrl + } + return "" +} + +func (x *PasswordOAuthFlow) GetScopes() map[string]string { + if x != nil { + return x.Scopes + } + return nil +} + +// Defines configuration details for the OAuth 2.0 Device Code flow (RFC 8628). +// This flow is designed for input-constrained devices such as IoT devices, +// and CLI tools where the user authenticates on a separate device. +type DeviceCodeOAuthFlow struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The device authorization endpoint URL. + DeviceAuthorizationUrl string `protobuf:"bytes,1,opt,name=device_authorization_url,json=deviceAuthorizationUrl,proto3" json:"device_authorization_url,omitempty"` + // The token URL to be used for this flow. + TokenUrl string `protobuf:"bytes,2,opt,name=token_url,json=tokenUrl,proto3" json:"token_url,omitempty"` + // The URL to be used for obtaining refresh tokens. + RefreshUrl string `protobuf:"bytes,3,opt,name=refresh_url,json=refreshUrl,proto3" json:"refresh_url,omitempty"` + // The available scopes for the OAuth2 security scheme. + Scopes map[string]string `protobuf:"bytes,4,rep,name=scopes,proto3" json:"scopes,omitempty" protobuf_key:"bytes,1,opt,name=key,proto3" protobuf_val:"bytes,2,opt,name=value,proto3"` +} + +func (x *DeviceCodeOAuthFlow) Reset() { + *x = DeviceCodeOAuthFlow{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[30] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeviceCodeOAuthFlow) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeviceCodeOAuthFlow) ProtoMessage() {} + +func (x *DeviceCodeOAuthFlow) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[30] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeviceCodeOAuthFlow.ProtoReflect.Descriptor instead. +func (*DeviceCodeOAuthFlow) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{30} +} + +func (x *DeviceCodeOAuthFlow) GetDeviceAuthorizationUrl() string { + if x != nil { + return x.DeviceAuthorizationUrl + } + return "" +} + +func (x *DeviceCodeOAuthFlow) GetTokenUrl() string { + if x != nil { + return x.TokenUrl + } + return "" +} + +func (x *DeviceCodeOAuthFlow) GetRefreshUrl() string { + if x != nil { + return x.RefreshUrl + } + return "" +} + +func (x *DeviceCodeOAuthFlow) GetScopes() map[string]string { + if x != nil { + return x.Scopes + } + return nil +} + +// Represents a request for the `SendMessage` method. +type SendMessageRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` + // The message to send to the agent. + Message *Message `protobuf:"bytes,2,opt,name=message,proto3" json:"message,omitempty"` + // Configuration for the send request. + Configuration *SendMessageConfiguration `protobuf:"bytes,3,opt,name=configuration,proto3" json:"configuration,omitempty"` + // A flexible key-value map for passing additional context or parameters. + Metadata *structpb.Struct `protobuf:"bytes,4,opt,name=metadata,proto3" json:"metadata,omitempty"` +} + +func (x *SendMessageRequest) Reset() { + *x = SendMessageRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[31] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SendMessageRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendMessageRequest) ProtoMessage() {} + +func (x *SendMessageRequest) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[31] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendMessageRequest.ProtoReflect.Descriptor instead. +func (*SendMessageRequest) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{31} +} + +func (x *SendMessageRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *SendMessageRequest) GetMessage() *Message { + if x != nil { + return x.Message + } + return nil +} + +func (x *SendMessageRequest) GetConfiguration() *SendMessageConfiguration { + if x != nil { + return x.Configuration + } + return nil +} + +func (x *SendMessageRequest) GetMetadata() *structpb.Struct { + if x != nil { + return x.Metadata + } + return nil +} + +// Represents a request for the `GetTask` method. +type GetTaskRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` + // The resource ID of the task to retrieve. + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + // The maximum number of most recent messages from the task's history to retrieve. An + // unset value means the client does not impose any limit. A value of zero is + // a request to not include any messages. The server MUST NOT return more + // messages than the provided value, but MAY apply a lower limit. + HistoryLength *int32 `protobuf:"varint,3,opt,name=history_length,json=historyLength,proto3,oneof" json:"history_length,omitempty"` +} + +func (x *GetTaskRequest) Reset() { + *x = GetTaskRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[32] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetTaskRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTaskRequest) ProtoMessage() {} + +func (x *GetTaskRequest) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[32] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTaskRequest.ProtoReflect.Descriptor instead. +func (*GetTaskRequest) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{32} +} + +func (x *GetTaskRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *GetTaskRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *GetTaskRequest) GetHistoryLength() int32 { + if x != nil && x.HistoryLength != nil { + return *x.HistoryLength + } + return 0 +} + +// Parameters for listing tasks with optional filtering criteria. +type ListTasksRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` + // Filter tasks by context ID to get tasks from a specific conversation or session. + ContextId string `protobuf:"bytes,2,opt,name=context_id,json=contextId,proto3" json:"context_id,omitempty"` + // Filter tasks by their current status state. + Status TaskState `protobuf:"varint,3,opt,name=status,proto3,enum=lf.a2a.v1.TaskState" json:"status,omitempty"` + // The maximum number of tasks to return. The service may return fewer than this value. + // If unspecified, at most 50 tasks will be returned. + // The minimum value is 1. + // The maximum value is 100. + PageSize *int32 `protobuf:"varint,4,opt,name=page_size,json=pageSize,proto3,oneof" json:"page_size,omitempty"` + // A page token, received from a previous `ListTasks` call. + // `ListTasksResponse.next_page_token`. + // Provide this to retrieve the subsequent page. + PageToken string `protobuf:"bytes,5,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` + // The maximum number of messages to include in each task's history. + HistoryLength *int32 `protobuf:"varint,6,opt,name=history_length,json=historyLength,proto3,oneof" json:"history_length,omitempty"` + // Filter tasks which have a status updated after the provided timestamp in ISO 8601 format (e.g., "2023-10-27T10:00:00Z"). + // Only tasks with a status timestamp time greater than or equal to this value will be returned. + StatusTimestampAfter *timestamppb.Timestamp `protobuf:"bytes,7,opt,name=status_timestamp_after,json=statusTimestampAfter,proto3" json:"status_timestamp_after,omitempty"` + // Whether to include artifacts in the returned tasks. + // Defaults to false to reduce payload size. + IncludeArtifacts *bool `protobuf:"varint,8,opt,name=include_artifacts,json=includeArtifacts,proto3,oneof" json:"include_artifacts,omitempty"` +} + +func (x *ListTasksRequest) Reset() { + *x = ListTasksRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[33] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListTasksRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTasksRequest) ProtoMessage() {} + +func (x *ListTasksRequest) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[33] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTasksRequest.ProtoReflect.Descriptor instead. +func (*ListTasksRequest) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{33} +} + +func (x *ListTasksRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *ListTasksRequest) GetContextId() string { + if x != nil { + return x.ContextId + } + return "" +} + +func (x *ListTasksRequest) GetStatus() TaskState { + if x != nil { + return x.Status + } + return TaskState_TASK_STATE_UNSPECIFIED +} + +func (x *ListTasksRequest) GetPageSize() int32 { + if x != nil && x.PageSize != nil { + return *x.PageSize + } + return 0 +} + +func (x *ListTasksRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +func (x *ListTasksRequest) GetHistoryLength() int32 { + if x != nil && x.HistoryLength != nil { + return *x.HistoryLength + } + return 0 +} + +func (x *ListTasksRequest) GetStatusTimestampAfter() *timestamppb.Timestamp { + if x != nil { + return x.StatusTimestampAfter + } + return nil +} + +func (x *ListTasksRequest) GetIncludeArtifacts() bool { + if x != nil && x.IncludeArtifacts != nil { + return *x.IncludeArtifacts + } + return false +} + +// Result object for `ListTasks` method containing an array of tasks and pagination information. +type ListTasksResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Array of tasks matching the specified criteria. + Tasks []*Task `protobuf:"bytes,1,rep,name=tasks,proto3" json:"tasks,omitempty"` + // A token to retrieve the next page of results, or empty if there are no more results in the list. + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` + // The page size used for this response. + PageSize int32 `protobuf:"varint,3,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // Total number of tasks available (before pagination). + TotalSize int32 `protobuf:"varint,4,opt,name=total_size,json=totalSize,proto3" json:"total_size,omitempty"` +} + +func (x *ListTasksResponse) Reset() { + *x = ListTasksResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[34] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListTasksResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTasksResponse) ProtoMessage() {} + +func (x *ListTasksResponse) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[34] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTasksResponse.ProtoReflect.Descriptor instead. +func (*ListTasksResponse) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{34} +} + +func (x *ListTasksResponse) GetTasks() []*Task { + if x != nil { + return x.Tasks + } + return nil +} + +func (x *ListTasksResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +func (x *ListTasksResponse) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListTasksResponse) GetTotalSize() int32 { + if x != nil { + return x.TotalSize + } + return 0 +} + +// Represents a request for the `CancelTask` method. +type CancelTaskRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` + // The resource ID of the task to cancel. + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` + // A flexible key-value map for passing additional context or parameters. + Metadata *structpb.Struct `protobuf:"bytes,3,opt,name=metadata,proto3" json:"metadata,omitempty"` +} + +func (x *CancelTaskRequest) Reset() { + *x = CancelTaskRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[35] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *CancelTaskRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*CancelTaskRequest) ProtoMessage() {} + +func (x *CancelTaskRequest) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[35] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use CancelTaskRequest.ProtoReflect.Descriptor instead. +func (*CancelTaskRequest) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{35} +} + +func (x *CancelTaskRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *CancelTaskRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +func (x *CancelTaskRequest) GetMetadata() *structpb.Struct { + if x != nil { + return x.Metadata + } + return nil +} + +// Represents a request for the `GetTaskPushNotificationConfig` method. +type GetTaskPushNotificationConfigRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` + // The parent task resource ID. + TaskId string `protobuf:"bytes,2,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + // The resource ID of the configuration to retrieve. + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *GetTaskPushNotificationConfigRequest) Reset() { + *x = GetTaskPushNotificationConfigRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[36] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetTaskPushNotificationConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetTaskPushNotificationConfigRequest) ProtoMessage() {} + +func (x *GetTaskPushNotificationConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[36] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetTaskPushNotificationConfigRequest.ProtoReflect.Descriptor instead. +func (*GetTaskPushNotificationConfigRequest) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{36} +} + +func (x *GetTaskPushNotificationConfigRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *GetTaskPushNotificationConfigRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *GetTaskPushNotificationConfigRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// Represents a request for the `DeleteTaskPushNotificationConfig` method. +type DeleteTaskPushNotificationConfigRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` + // The parent task resource ID. + TaskId string `protobuf:"bytes,2,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + // The resource ID of the configuration to delete. + Id string `protobuf:"bytes,3,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *DeleteTaskPushNotificationConfigRequest) Reset() { + *x = DeleteTaskPushNotificationConfigRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[37] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *DeleteTaskPushNotificationConfigRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*DeleteTaskPushNotificationConfigRequest) ProtoMessage() {} + +func (x *DeleteTaskPushNotificationConfigRequest) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[37] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use DeleteTaskPushNotificationConfigRequest.ProtoReflect.Descriptor instead. +func (*DeleteTaskPushNotificationConfigRequest) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{37} +} + +func (x *DeleteTaskPushNotificationConfigRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *DeleteTaskPushNotificationConfigRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *DeleteTaskPushNotificationConfigRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// Represents a request for the `SubscribeToTask` method. +type SubscribeToTaskRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` + // The resource ID of the task to subscribe to. + Id string `protobuf:"bytes,2,opt,name=id,proto3" json:"id,omitempty"` +} + +func (x *SubscribeToTaskRequest) Reset() { + *x = SubscribeToTaskRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[38] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SubscribeToTaskRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SubscribeToTaskRequest) ProtoMessage() {} + +func (x *SubscribeToTaskRequest) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[38] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SubscribeToTaskRequest.ProtoReflect.Descriptor instead. +func (*SubscribeToTaskRequest) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{38} +} + +func (x *SubscribeToTaskRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *SubscribeToTaskRequest) GetId() string { + if x != nil { + return x.Id + } + return "" +} + +// Represents a request for the `ListTaskPushNotificationConfigs` method. +type ListTaskPushNotificationConfigsRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + Tenant string `protobuf:"bytes,4,opt,name=tenant,proto3" json:"tenant,omitempty"` + // The parent task resource ID. + TaskId string `protobuf:"bytes,1,opt,name=task_id,json=taskId,proto3" json:"task_id,omitempty"` + // The maximum number of configurations to return. + PageSize int32 `protobuf:"varint,2,opt,name=page_size,json=pageSize,proto3" json:"page_size,omitempty"` + // A page token received from a previous `ListTaskPushNotificationConfigsRequest` call. + PageToken string `protobuf:"bytes,3,opt,name=page_token,json=pageToken,proto3" json:"page_token,omitempty"` +} + +func (x *ListTaskPushNotificationConfigsRequest) Reset() { + *x = ListTaskPushNotificationConfigsRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[39] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListTaskPushNotificationConfigsRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTaskPushNotificationConfigsRequest) ProtoMessage() {} + +func (x *ListTaskPushNotificationConfigsRequest) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[39] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTaskPushNotificationConfigsRequest.ProtoReflect.Descriptor instead. +func (*ListTaskPushNotificationConfigsRequest) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{39} +} + +func (x *ListTaskPushNotificationConfigsRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +func (x *ListTaskPushNotificationConfigsRequest) GetTaskId() string { + if x != nil { + return x.TaskId + } + return "" +} + +func (x *ListTaskPushNotificationConfigsRequest) GetPageSize() int32 { + if x != nil { + return x.PageSize + } + return 0 +} + +func (x *ListTaskPushNotificationConfigsRequest) GetPageToken() string { + if x != nil { + return x.PageToken + } + return "" +} + +// Represents a request for the `GetExtendedAgentCard` method. +type GetExtendedAgentCardRequest struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + Tenant string `protobuf:"bytes,1,opt,name=tenant,proto3" json:"tenant,omitempty"` +} + +func (x *GetExtendedAgentCardRequest) Reset() { + *x = GetExtendedAgentCardRequest{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[40] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *GetExtendedAgentCardRequest) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*GetExtendedAgentCardRequest) ProtoMessage() {} + +func (x *GetExtendedAgentCardRequest) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[40] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use GetExtendedAgentCardRequest.ProtoReflect.Descriptor instead. +func (*GetExtendedAgentCardRequest) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{40} +} + +func (x *GetExtendedAgentCardRequest) GetTenant() string { + if x != nil { + return x.Tenant + } + return "" +} + +// Represents the response for the `SendMessage` method. +type SendMessageResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The payload of the response. + // + // Types that are assignable to Payload: + // + // *SendMessageResponse_Task + // *SendMessageResponse_Message + Payload isSendMessageResponse_Payload `protobuf_oneof:"payload"` +} + +func (x *SendMessageResponse) Reset() { + *x = SendMessageResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[41] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *SendMessageResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*SendMessageResponse) ProtoMessage() {} + +func (x *SendMessageResponse) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[41] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use SendMessageResponse.ProtoReflect.Descriptor instead. +func (*SendMessageResponse) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{41} +} + +func (m *SendMessageResponse) GetPayload() isSendMessageResponse_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *SendMessageResponse) GetTask() *Task { + if x, ok := x.GetPayload().(*SendMessageResponse_Task); ok { + return x.Task + } + return nil +} + +func (x *SendMessageResponse) GetMessage() *Message { + if x, ok := x.GetPayload().(*SendMessageResponse_Message); ok { + return x.Message + } + return nil +} + +type isSendMessageResponse_Payload interface { + isSendMessageResponse_Payload() +} + +type SendMessageResponse_Task struct { + // The task created or updated by the message. + Task *Task `protobuf:"bytes,1,opt,name=task,proto3,oneof"` +} + +type SendMessageResponse_Message struct { + // A message from the agent. + Message *Message `protobuf:"bytes,2,opt,name=message,proto3,oneof"` +} + +func (*SendMessageResponse_Task) isSendMessageResponse_Payload() {} + +func (*SendMessageResponse_Message) isSendMessageResponse_Payload() {} + +// A wrapper object used in streaming operations to encapsulate different types of response data. +type StreamResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The payload of the stream response. + // + // Types that are assignable to Payload: + // + // *StreamResponse_Task + // *StreamResponse_Message + // *StreamResponse_StatusUpdate + // *StreamResponse_ArtifactUpdate + Payload isStreamResponse_Payload `protobuf_oneof:"payload"` +} + +func (x *StreamResponse) Reset() { + *x = StreamResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[42] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *StreamResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*StreamResponse) ProtoMessage() {} + +func (x *StreamResponse) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[42] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use StreamResponse.ProtoReflect.Descriptor instead. +func (*StreamResponse) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{42} +} + +func (m *StreamResponse) GetPayload() isStreamResponse_Payload { + if m != nil { + return m.Payload + } + return nil +} + +func (x *StreamResponse) GetTask() *Task { + if x, ok := x.GetPayload().(*StreamResponse_Task); ok { + return x.Task + } + return nil +} + +func (x *StreamResponse) GetMessage() *Message { + if x, ok := x.GetPayload().(*StreamResponse_Message); ok { + return x.Message + } + return nil +} + +func (x *StreamResponse) GetStatusUpdate() *TaskStatusUpdateEvent { + if x, ok := x.GetPayload().(*StreamResponse_StatusUpdate); ok { + return x.StatusUpdate + } + return nil +} + +func (x *StreamResponse) GetArtifactUpdate() *TaskArtifactUpdateEvent { + if x, ok := x.GetPayload().(*StreamResponse_ArtifactUpdate); ok { + return x.ArtifactUpdate + } + return nil +} + +type isStreamResponse_Payload interface { + isStreamResponse_Payload() +} + +type StreamResponse_Task struct { + // A Task object containing the current state of the task. + Task *Task `protobuf:"bytes,1,opt,name=task,proto3,oneof"` +} + +type StreamResponse_Message struct { + // A Message object containing a message from the agent. + Message *Message `protobuf:"bytes,2,opt,name=message,proto3,oneof"` +} + +type StreamResponse_StatusUpdate struct { + // An event indicating a task status update. + StatusUpdate *TaskStatusUpdateEvent `protobuf:"bytes,3,opt,name=status_update,json=statusUpdate,proto3,oneof"` +} + +type StreamResponse_ArtifactUpdate struct { + // An event indicating a task artifact update. + ArtifactUpdate *TaskArtifactUpdateEvent `protobuf:"bytes,4,opt,name=artifact_update,json=artifactUpdate,proto3,oneof"` +} + +func (*StreamResponse_Task) isStreamResponse_Payload() {} + +func (*StreamResponse_Message) isStreamResponse_Payload() {} + +func (*StreamResponse_StatusUpdate) isStreamResponse_Payload() {} + +func (*StreamResponse_ArtifactUpdate) isStreamResponse_Payload() {} + +// Represents a successful response for the `ListTaskPushNotificationConfigs` +// method. +type ListTaskPushNotificationConfigsResponse struct { + state protoimpl.MessageState + sizeCache protoimpl.SizeCache + unknownFields protoimpl.UnknownFields + + // The list of push notification configurations. + Configs []*TaskPushNotificationConfig `protobuf:"bytes,1,rep,name=configs,proto3" json:"configs,omitempty"` + // A token to retrieve the next page of results, or empty if there are no more results in the list. + NextPageToken string `protobuf:"bytes,2,opt,name=next_page_token,json=nextPageToken,proto3" json:"next_page_token,omitempty"` +} + +func (x *ListTaskPushNotificationConfigsResponse) Reset() { + *x = ListTaskPushNotificationConfigsResponse{} + if protoimpl.UnsafeEnabled { + mi := &file_a2a_proto_msgTypes[43] + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + ms.StoreMessageInfo(mi) + } +} + +func (x *ListTaskPushNotificationConfigsResponse) String() string { + return protoimpl.X.MessageStringOf(x) +} + +func (*ListTaskPushNotificationConfigsResponse) ProtoMessage() {} + +func (x *ListTaskPushNotificationConfigsResponse) ProtoReflect() protoreflect.Message { + mi := &file_a2a_proto_msgTypes[43] + if protoimpl.UnsafeEnabled && x != nil { + ms := protoimpl.X.MessageStateOf(protoimpl.Pointer(x)) + if ms.LoadMessageInfo() == nil { + ms.StoreMessageInfo(mi) + } + return ms + } + return mi.MessageOf(x) +} + +// Deprecated: Use ListTaskPushNotificationConfigsResponse.ProtoReflect.Descriptor instead. +func (*ListTaskPushNotificationConfigsResponse) Descriptor() ([]byte, []int) { + return file_a2a_proto_rawDescGZIP(), []int{43} +} + +func (x *ListTaskPushNotificationConfigsResponse) GetConfigs() []*TaskPushNotificationConfig { + if x != nil { + return x.Configs + } + return nil +} + +func (x *ListTaskPushNotificationConfigsResponse) GetNextPageToken() string { + if x != nil { + return x.NextPageToken + } + return "" +} + +var File_a2a_proto protoreflect.FileDescriptor + +var file_a2a_proto_rawDesc = []byte{ + 0x0a, 0x09, 0x61, 0x32, 0x61, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x12, 0x09, 0x6c, 0x66, 0x2e, + 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, + 0x70, 0x69, 0x2f, 0x61, 0x6e, 0x6e, 0x6f, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x2e, 0x70, + 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x17, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, + 0x2f, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, + 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x61, 0x70, 0x69, 0x2f, 0x66, 0x69, 0x65, 0x6c, 0x64, 0x5f, + 0x62, 0x65, 0x68, 0x61, 0x76, 0x69, 0x6f, 0x72, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1b, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, + 0x65, 0x6d, 0x70, 0x74, 0x79, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1c, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x73, 0x74, 0x72, + 0x75, 0x63, 0x74, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x1a, 0x1f, 0x67, 0x6f, 0x6f, 0x67, 0x6c, + 0x65, 0x2f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2f, 0x74, 0x69, 0x6d, 0x65, 0x73, + 0x74, 0x61, 0x6d, 0x70, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x22, 0xa6, 0x02, 0x0a, 0x18, 0x53, + 0x65, 0x6e, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x32, 0x0a, 0x15, 0x61, 0x63, 0x63, 0x65, 0x70, + 0x74, 0x65, 0x64, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x09, 0x52, 0x13, 0x61, 0x63, 0x63, 0x65, 0x70, 0x74, 0x65, 0x64, + 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x68, 0x0a, 0x1d, 0x74, + 0x61, 0x73, 0x6b, 0x5f, 0x70, 0x75, 0x73, 0x68, 0x5f, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x54, + 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x1a, 0x74, 0x61, 0x73, 0x6b, 0x50, + 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2a, 0x0a, 0x0e, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, + 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, + 0x0d, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x88, 0x01, + 0x01, 0x12, 0x2d, 0x0a, 0x12, 0x72, 0x65, 0x74, 0x75, 0x72, 0x6e, 0x5f, 0x69, 0x6d, 0x6d, 0x65, + 0x64, 0x69, 0x61, 0x74, 0x65, 0x6c, 0x79, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x11, 0x72, + 0x65, 0x74, 0x75, 0x72, 0x6e, 0x49, 0x6d, 0x6d, 0x65, 0x64, 0x69, 0x61, 0x74, 0x65, 0x6c, 0x79, + 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x5f, 0x6c, 0x65, 0x6e, + 0x67, 0x74, 0x68, 0x22, 0x84, 0x02, 0x0a, 0x04, 0x54, 0x61, 0x73, 0x6b, 0x12, 0x13, 0x0a, 0x02, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x02, 0x69, + 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x49, 0x64, + 0x12, 0x32, 0x0a, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x15, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, + 0x6b, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x73, 0x74, + 0x61, 0x74, 0x75, 0x73, 0x12, 0x31, 0x0a, 0x09, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, + 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, + 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x52, 0x09, 0x61, 0x72, + 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x12, 0x2c, 0x0a, 0x07, 0x68, 0x69, 0x73, 0x74, 0x6f, + 0x72, 0x79, 0x18, 0x05, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, + 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x07, 0x68, 0x69, + 0x73, 0x74, 0x6f, 0x72, 0x79, 0x12, 0x33, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x61, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, + 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0xa5, 0x01, 0x0a, 0x0a, 0x54, + 0x61, 0x73, 0x6b, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x2f, 0x0a, 0x05, 0x73, 0x74, 0x61, + 0x74, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x14, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, + 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x53, 0x74, 0x61, 0x74, 0x65, 0x42, 0x03, + 0xe0, 0x41, 0x02, 0x52, 0x05, 0x73, 0x74, 0x61, 0x74, 0x65, 0x12, 0x2c, 0x0a, 0x07, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6c, 0x66, + 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, + 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x38, 0x0a, 0x09, 0x74, 0x69, 0x6d, 0x65, + 0x73, 0x74, 0x61, 0x6d, 0x70, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, + 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, + 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x52, 0x09, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x22, 0xed, 0x01, 0x0a, 0x04, 0x50, 0x61, 0x72, 0x74, 0x12, 0x14, 0x0a, 0x04, 0x74, + 0x65, 0x78, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x04, 0x74, 0x65, 0x78, + 0x74, 0x12, 0x12, 0x0a, 0x03, 0x72, 0x61, 0x77, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0c, 0x48, 0x00, + 0x52, 0x03, 0x72, 0x61, 0x77, 0x12, 0x12, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x48, 0x00, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x2c, 0x0a, 0x04, 0x64, 0x61, 0x74, + 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, + 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x56, 0x61, 0x6c, 0x75, 0x65, 0x48, + 0x00, 0x52, 0x04, 0x64, 0x61, 0x74, 0x61, 0x12, 0x33, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, + 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, + 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, + 0x63, 0x74, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1a, 0x0a, 0x08, + 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, + 0x66, 0x69, 0x6c, 0x65, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x6d, 0x65, 0x64, 0x69, + 0x61, 0x5f, 0x74, 0x79, 0x70, 0x65, 0x18, 0x07, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x6d, 0x65, + 0x64, 0x69, 0x61, 0x54, 0x79, 0x70, 0x65, 0x42, 0x09, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x74, 0x65, + 0x6e, 0x74, 0x22, 0xbe, 0x02, 0x0a, 0x07, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x12, 0x22, + 0x0a, 0x0a, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x09, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x49, 0x64, 0x12, 0x1d, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x49, + 0x64, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x28, 0x0a, 0x04, 0x72, 0x6f, + 0x6c, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x0f, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, + 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x52, 0x6f, 0x6c, 0x65, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x04, + 0x72, 0x6f, 0x6c, 0x65, 0x12, 0x2a, 0x0a, 0x05, 0x70, 0x61, 0x72, 0x74, 0x73, 0x18, 0x05, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, + 0x50, 0x61, 0x72, 0x74, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x05, 0x70, 0x61, 0x72, 0x74, 0x73, + 0x12, 0x33, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x08, 0x6d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1e, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, + 0x6f, 0x6e, 0x73, 0x18, 0x07, 0x20, 0x03, 0x28, 0x09, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, + 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x2c, 0x0a, 0x12, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, + 0x63, 0x65, 0x5f, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x10, 0x72, 0x65, 0x66, 0x65, 0x72, 0x65, 0x6e, 0x63, 0x65, 0x54, 0x61, 0x73, 0x6b, + 0x49, 0x64, 0x73, 0x22, 0xe7, 0x01, 0x0a, 0x08, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, + 0x12, 0x24, 0x0a, 0x0b, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x5f, 0x69, 0x64, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x0a, 0x61, 0x72, 0x74, 0x69, + 0x66, 0x61, 0x63, 0x74, 0x49, 0x64, 0x12, 0x12, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, + 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x2a, 0x0a, 0x05, + 0x70, 0x61, 0x72, 0x74, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6c, 0x66, + 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x50, 0x61, 0x72, 0x74, 0x42, 0x03, 0xe0, 0x41, + 0x02, 0x52, 0x05, 0x70, 0x61, 0x72, 0x74, 0x73, 0x12, 0x33, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, + 0x75, 0x63, 0x74, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x12, 0x1e, 0x0a, + 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x22, 0xc2, 0x01, + 0x0a, 0x15, 0x54, 0x61, 0x73, 0x6b, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, + 0x74, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, + 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x74, + 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0a, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, + 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x09, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x49, 0x64, 0x12, 0x32, 0x0a, 0x06, 0x73, 0x74, 0x61, + 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6c, 0x66, 0x2e, 0x61, + 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x53, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x33, 0x0a, + 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x22, 0xfd, 0x01, 0x0a, 0x17, 0x54, 0x61, 0x73, 0x6b, 0x41, 0x72, 0x74, 0x69, 0x66, + 0x61, 0x63, 0x74, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x12, 0x1c, + 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x22, 0x0a, 0x0a, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x49, 0x64, + 0x12, 0x34, 0x0a, 0x08, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x13, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, + 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x08, 0x61, 0x72, + 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x64, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x52, 0x06, 0x61, 0x70, 0x70, 0x65, 0x6e, 0x64, 0x12, 0x1d, + 0x0a, 0x0a, 0x6c, 0x61, 0x73, 0x74, 0x5f, 0x63, 0x68, 0x75, 0x6e, 0x6b, 0x18, 0x05, 0x20, 0x01, + 0x28, 0x08, 0x52, 0x09, 0x6c, 0x61, 0x73, 0x74, 0x43, 0x68, 0x75, 0x6e, 0x6b, 0x12, 0x33, 0x0a, + 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, + 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, + 0x74, 0x61, 0x22, 0x53, 0x0a, 0x12, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x12, 0x1b, 0x0a, 0x06, 0x73, 0x63, 0x68, 0x65, + 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x73, + 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, + 0x69, 0x61, 0x6c, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x63, 0x72, 0x65, 0x64, + 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x22, 0x9f, 0x01, 0x0a, 0x0e, 0x41, 0x67, 0x65, 0x6e, + 0x74, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x12, 0x15, 0x0a, 0x03, 0x75, 0x72, + 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x03, 0x75, 0x72, + 0x6c, 0x12, 0x2e, 0x0a, 0x10, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x5f, 0x62, 0x69, + 0x6e, 0x64, 0x69, 0x6e, 0x67, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, + 0x52, 0x0f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x42, 0x69, 0x6e, 0x64, 0x69, 0x6e, + 0x67, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x12, 0x2e, 0x0a, 0x10, 0x70, 0x72, 0x6f, + 0x74, 0x6f, 0x63, 0x6f, 0x6c, 0x5f, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x04, 0x20, + 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x0f, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x63, + 0x6f, 0x6c, 0x56, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x22, 0x98, 0x07, 0x0a, 0x09, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x43, 0x61, 0x72, 0x64, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x12, 0x25, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, + 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x51, 0x0a, 0x14, 0x73, 0x75, 0x70, 0x70, 0x6f, + 0x72, 0x74, 0x65, 0x64, 0x5f, 0x69, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x73, 0x18, + 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, + 0x31, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, + 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x13, 0x73, 0x75, 0x70, 0x70, 0x6f, 0x72, 0x74, 0x65, 0x64, + 0x49, 0x6e, 0x74, 0x65, 0x72, 0x66, 0x61, 0x63, 0x65, 0x73, 0x12, 0x34, 0x0a, 0x08, 0x70, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x18, 0x2e, 0x6c, + 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x50, 0x72, + 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, 0x52, 0x08, 0x70, 0x72, 0x6f, 0x76, 0x69, 0x64, 0x65, 0x72, + 0x12, 0x1d, 0x0a, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x07, 0x76, 0x65, 0x72, 0x73, 0x69, 0x6f, 0x6e, 0x12, + 0x30, 0x0a, 0x11, 0x64, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x06, 0x20, 0x01, 0x28, 0x09, 0x48, 0x00, 0x52, 0x10, 0x64, 0x6f, + 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x88, 0x01, + 0x01, 0x12, 0x45, 0x0a, 0x0c, 0x63, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, + 0x73, 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, + 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, + 0x69, 0x74, 0x69, 0x65, 0x73, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x0c, 0x63, 0x61, 0x70, 0x61, + 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x54, 0x0a, 0x10, 0x73, 0x65, 0x63, 0x75, + 0x72, 0x69, 0x74, 0x79, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x73, 0x18, 0x08, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x29, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, + 0x67, 0x65, 0x6e, 0x74, 0x43, 0x61, 0x72, 0x64, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, + 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x0f, 0x73, + 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x73, 0x12, 0x53, + 0x0a, 0x15, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, + 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x09, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1e, 0x2e, + 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, + 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, 0x14, 0x73, + 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x73, 0x12, 0x33, 0x0a, 0x13, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x5f, 0x69, + 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x0a, 0x20, 0x03, 0x28, 0x09, + 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x11, 0x64, 0x65, 0x66, 0x61, 0x75, 0x6c, 0x74, 0x49, 0x6e, + 0x70, 0x75, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x35, 0x0a, 0x14, 0x64, 0x65, 0x66, 0x61, + 0x75, 0x6c, 0x74, 0x5f, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x73, + 0x18, 0x0b, 0x20, 0x03, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x12, 0x64, 0x65, 0x66, + 0x61, 0x75, 0x6c, 0x74, 0x4f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x73, 0x12, + 0x32, 0x0a, 0x06, 0x73, 0x6b, 0x69, 0x6c, 0x6c, 0x73, 0x18, 0x0c, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x15, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x67, 0x65, 0x6e, + 0x74, 0x53, 0x6b, 0x69, 0x6c, 0x6c, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x73, 0x6b, 0x69, + 0x6c, 0x6c, 0x73, 0x12, 0x3d, 0x0a, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, + 0x73, 0x18, 0x0d, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, + 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, 0x61, 0x72, 0x64, 0x53, 0x69, 0x67, + 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x52, 0x0a, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, + 0x65, 0x73, 0x12, 0x1e, 0x0a, 0x08, 0x69, 0x63, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x0e, + 0x20, 0x01, 0x28, 0x09, 0x48, 0x01, 0x52, 0x07, 0x69, 0x63, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x88, + 0x01, 0x01, 0x1a, 0x5d, 0x0a, 0x14, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, + 0x68, 0x65, 0x6d, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, + 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2f, 0x0a, 0x05, + 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6c, 0x66, + 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, + 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x42, 0x14, 0x0a, 0x12, 0x5f, 0x64, 0x6f, 0x63, 0x75, 0x6d, 0x65, 0x6e, 0x74, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x42, 0x0b, 0x0a, 0x09, 0x5f, 0x69, 0x63, 0x6f, 0x6e, + 0x5f, 0x75, 0x72, 0x6c, 0x22, 0x4f, 0x0a, 0x0d, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x50, 0x72, 0x6f, + 0x76, 0x69, 0x64, 0x65, 0x72, 0x12, 0x15, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x27, 0x0a, 0x0c, + 0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x0c, 0x6f, 0x72, 0x67, 0x61, 0x6e, 0x69, 0x7a, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x97, 0x02, 0x0a, 0x11, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, + 0x61, 0x70, 0x61, 0x62, 0x69, 0x6c, 0x69, 0x74, 0x69, 0x65, 0x73, 0x12, 0x21, 0x0a, 0x09, 0x73, + 0x74, 0x72, 0x65, 0x61, 0x6d, 0x69, 0x6e, 0x67, 0x18, 0x01, 0x20, 0x01, 0x28, 0x08, 0x48, 0x00, + 0x52, 0x09, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x69, 0x6e, 0x67, 0x88, 0x01, 0x01, 0x12, 0x32, + 0x0a, 0x12, 0x70, 0x75, 0x73, 0x68, 0x5f, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x08, 0x48, 0x01, 0x52, 0x11, 0x70, 0x75, + 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x88, + 0x01, 0x01, 0x12, 0x39, 0x0a, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, + 0x18, 0x03, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x19, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, + 0x76, 0x31, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, + 0x6e, 0x52, 0x0a, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, 0x6f, 0x6e, 0x73, 0x12, 0x33, 0x0a, + 0x13, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, + 0x63, 0x61, 0x72, 0x64, 0x18, 0x04, 0x20, 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, 0x11, 0x65, 0x78, + 0x74, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, 0x61, 0x72, 0x64, 0x88, + 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x69, 0x6e, 0x67, + 0x42, 0x15, 0x0a, 0x13, 0x5f, 0x70, 0x75, 0x73, 0x68, 0x5f, 0x6e, 0x6f, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x73, 0x42, 0x16, 0x0a, 0x14, 0x5f, 0x65, 0x78, 0x74, 0x65, + 0x6e, 0x64, 0x65, 0x64, 0x5f, 0x61, 0x67, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x61, 0x72, 0x64, 0x22, + 0x91, 0x01, 0x0a, 0x0e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x73, 0x69, + 0x6f, 0x6e, 0x12, 0x10, 0x0a, 0x03, 0x75, 0x72, 0x69, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x03, 0x75, 0x72, 0x69, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, + 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1a, 0x0a, 0x08, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, + 0x65, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x08, 0x52, 0x08, 0x72, 0x65, 0x71, 0x75, 0x69, 0x72, + 0x65, 0x64, 0x12, 0x2f, 0x0a, 0x06, 0x70, 0x61, 0x72, 0x61, 0x6d, 0x73, 0x18, 0x04, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x06, 0x70, 0x61, 0x72, + 0x61, 0x6d, 0x73, 0x22, 0xaf, 0x02, 0x0a, 0x0a, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x53, 0x6b, 0x69, + 0x6c, 0x6c, 0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, + 0xe0, 0x41, 0x02, 0x52, 0x02, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, + 0x12, 0x25, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, + 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x17, 0x0a, 0x04, 0x74, 0x61, 0x67, 0x73, 0x18, + 0x04, 0x20, 0x03, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x04, 0x74, 0x61, 0x67, 0x73, + 0x12, 0x1a, 0x0a, 0x08, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x18, 0x05, 0x20, 0x03, + 0x28, 0x09, 0x52, 0x08, 0x65, 0x78, 0x61, 0x6d, 0x70, 0x6c, 0x65, 0x73, 0x12, 0x1f, 0x0a, 0x0b, + 0x69, 0x6e, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x06, 0x20, 0x03, 0x28, + 0x09, 0x52, 0x0a, 0x69, 0x6e, 0x70, 0x75, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x73, 0x12, 0x21, 0x0a, + 0x0c, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x5f, 0x6d, 0x6f, 0x64, 0x65, 0x73, 0x18, 0x07, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x0b, 0x6f, 0x75, 0x74, 0x70, 0x75, 0x74, 0x4d, 0x6f, 0x64, 0x65, 0x73, + 0x12, 0x53, 0x0a, 0x15, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x72, 0x65, 0x71, + 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x18, 0x08, 0x20, 0x03, 0x28, 0x0b, 0x32, + 0x1e, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x63, 0x75, + 0x72, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, 0x74, 0x52, + 0x14, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, + 0x6d, 0x65, 0x6e, 0x74, 0x73, 0x22, 0x8b, 0x01, 0x0a, 0x12, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, + 0x61, 0x72, 0x64, 0x53, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x12, 0x21, 0x0a, 0x09, + 0x70, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x03, 0xe0, 0x41, 0x02, 0x52, 0x09, 0x70, 0x72, 0x6f, 0x74, 0x65, 0x63, 0x74, 0x65, 0x64, 0x12, + 0x21, 0x0a, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, 0x72, 0x65, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x09, 0x73, 0x69, 0x67, 0x6e, 0x61, 0x74, 0x75, + 0x72, 0x65, 0x12, 0x2f, 0x0a, 0x06, 0x68, 0x65, 0x61, 0x64, 0x65, 0x72, 0x18, 0x03, 0x20, 0x01, + 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, + 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x06, 0x68, 0x65, 0x61, + 0x64, 0x65, 0x72, 0x22, 0xd1, 0x01, 0x0a, 0x1a, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, + 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, + 0x69, 0x67, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x12, 0x0e, 0x0a, 0x02, 0x69, 0x64, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x02, 0x69, 0x64, 0x12, 0x17, 0x0a, 0x07, 0x74, 0x61, + 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x61, 0x73, + 0x6b, 0x49, 0x64, 0x12, 0x15, 0x0a, 0x03, 0x75, 0x72, 0x6c, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x03, 0x75, 0x72, 0x6c, 0x12, 0x14, 0x0a, 0x05, 0x74, 0x6f, + 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x12, 0x45, 0x0a, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x06, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1d, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, + 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x49, 0x6e, 0x66, 0x6f, 0x52, 0x0e, 0x61, 0x75, 0x74, 0x68, 0x65, 0x6e, 0x74, + 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x20, 0x0a, 0x0a, 0x53, 0x74, 0x72, 0x69, 0x6e, + 0x67, 0x4c, 0x69, 0x73, 0x74, 0x12, 0x12, 0x0a, 0x04, 0x6c, 0x69, 0x73, 0x74, 0x18, 0x01, 0x20, + 0x03, 0x28, 0x09, 0x52, 0x04, 0x6c, 0x69, 0x73, 0x74, 0x22, 0xaf, 0x01, 0x0a, 0x13, 0x53, 0x65, + 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, 0x6e, + 0x74, 0x12, 0x45, 0x0a, 0x07, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x73, 0x18, 0x01, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x2b, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x53, + 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x52, 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x6d, 0x65, + 0x6e, 0x74, 0x2e, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, + 0x07, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x73, 0x1a, 0x51, 0x0a, 0x0c, 0x53, 0x63, 0x68, 0x65, + 0x6d, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x2b, 0x0a, 0x05, 0x76, 0x61, + 0x6c, 0x75, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x15, 0x2e, 0x6c, 0x66, 0x2e, 0x61, + 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x69, 0x6e, 0x67, 0x4c, 0x69, 0x73, 0x74, + 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, 0xf5, 0x03, 0x0a, 0x0e, + 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x58, + 0x0a, 0x17, 0x61, 0x70, 0x69, 0x5f, 0x6b, 0x65, 0x79, 0x5f, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, + 0x74, 0x79, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x1f, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x50, 0x49, 0x4b, + 0x65, 0x79, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, + 0x48, 0x00, 0x52, 0x14, 0x61, 0x70, 0x69, 0x4b, 0x65, 0x79, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, + 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x5e, 0x0a, 0x19, 0x68, 0x74, 0x74, 0x70, + 0x5f, 0x61, 0x75, 0x74, 0x68, 0x5f, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x73, + 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x21, 0x2e, 0x6c, 0x66, + 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x48, 0x54, 0x54, 0x50, 0x41, 0x75, 0x74, 0x68, + 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x48, 0x00, + 0x52, 0x16, 0x68, 0x74, 0x74, 0x70, 0x41, 0x75, 0x74, 0x68, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, + 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x57, 0x0a, 0x16, 0x6f, 0x61, 0x75, 0x74, + 0x68, 0x32, 0x5f, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x73, 0x63, 0x68, 0x65, + 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1f, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, + 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x32, 0x53, 0x65, 0x63, 0x75, 0x72, + 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x48, 0x00, 0x52, 0x14, 0x6f, 0x61, 0x75, + 0x74, 0x68, 0x32, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, + 0x65, 0x12, 0x6e, 0x0a, 0x1f, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x69, 0x64, 0x5f, 0x63, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x5f, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x5f, 0x73, 0x63, + 0x68, 0x65, 0x6d, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x26, 0x2e, 0x6c, 0x66, 0x2e, + 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x70, 0x65, 0x6e, 0x49, 0x64, 0x43, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, + 0x6d, 0x65, 0x48, 0x00, 0x52, 0x1b, 0x6f, 0x70, 0x65, 0x6e, 0x49, 0x64, 0x43, 0x6f, 0x6e, 0x6e, + 0x65, 0x63, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, + 0x65, 0x12, 0x56, 0x0a, 0x14, 0x6d, 0x74, 0x6c, 0x73, 0x5f, 0x73, 0x65, 0x63, 0x75, 0x72, 0x69, + 0x74, 0x79, 0x5f, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x22, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x75, 0x74, 0x75, + 0x61, 0x6c, 0x54, 0x6c, 0x73, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, + 0x65, 0x6d, 0x65, 0x48, 0x00, 0x52, 0x12, 0x6d, 0x74, 0x6c, 0x73, 0x53, 0x65, 0x63, 0x75, 0x72, + 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x42, 0x08, 0x0a, 0x06, 0x73, 0x63, 0x68, + 0x65, 0x6d, 0x65, 0x22, 0x72, 0x0a, 0x14, 0x41, 0x50, 0x49, 0x4b, 0x65, 0x79, 0x53, 0x65, 0x63, + 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, + 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x1f, 0x0a, + 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x03, 0xe0, 0x41, 0x02, 0x52, 0x08, 0x6c, 0x6f, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x17, + 0x0a, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, + 0x02, 0x52, 0x04, 0x6e, 0x61, 0x6d, 0x65, 0x22, 0x7c, 0x0a, 0x16, 0x48, 0x54, 0x54, 0x50, 0x41, + 0x75, 0x74, 0x68, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, + 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x1b, 0x0a, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x18, 0x02, 0x20, + 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x73, 0x63, 0x68, 0x65, 0x6d, 0x65, + 0x12, 0x23, 0x0a, 0x0d, 0x62, 0x65, 0x61, 0x72, 0x65, 0x72, 0x5f, 0x66, 0x6f, 0x72, 0x6d, 0x61, + 0x74, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0c, 0x62, 0x65, 0x61, 0x72, 0x65, 0x72, 0x46, + 0x6f, 0x72, 0x6d, 0x61, 0x74, 0x22, 0x9a, 0x01, 0x0a, 0x14, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x32, + 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, 0x65, 0x12, 0x20, + 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x12, 0x30, 0x0a, 0x05, 0x66, 0x6c, 0x6f, 0x77, 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x15, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4f, 0x41, 0x75, 0x74, + 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x73, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x05, 0x66, 0x6c, 0x6f, + 0x77, 0x73, 0x12, 0x2e, 0x0a, 0x13, 0x6f, 0x61, 0x75, 0x74, 0x68, 0x32, 0x5f, 0x6d, 0x65, 0x74, + 0x61, 0x64, 0x61, 0x74, 0x61, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x11, 0x6f, 0x61, 0x75, 0x74, 0x68, 0x32, 0x4d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x55, + 0x72, 0x6c, 0x22, 0x73, 0x0a, 0x1b, 0x4f, 0x70, 0x65, 0x6e, 0x49, 0x64, 0x43, 0x6f, 0x6e, 0x6e, + 0x65, 0x63, 0x74, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, 0x6d, + 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, 0x6e, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, + 0x69, 0x6f, 0x6e, 0x12, 0x32, 0x0a, 0x13, 0x6f, 0x70, 0x65, 0x6e, 0x5f, 0x69, 0x64, 0x5f, 0x63, + 0x6f, 0x6e, 0x6e, 0x65, 0x63, 0x74, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x10, 0x6f, 0x70, 0x65, 0x6e, 0x49, 0x64, 0x43, 0x6f, 0x6e, + 0x6e, 0x65, 0x63, 0x74, 0x55, 0x72, 0x6c, 0x22, 0x3b, 0x0a, 0x17, 0x4d, 0x75, 0x74, 0x75, 0x61, + 0x6c, 0x54, 0x6c, 0x73, 0x53, 0x65, 0x63, 0x75, 0x72, 0x69, 0x74, 0x79, 0x53, 0x63, 0x68, 0x65, + 0x6d, 0x65, 0x12, 0x20, 0x0a, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, 0x74, 0x69, 0x6f, + 0x6e, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0b, 0x64, 0x65, 0x73, 0x63, 0x72, 0x69, 0x70, + 0x74, 0x69, 0x6f, 0x6e, 0x22, 0x87, 0x03, 0x0a, 0x0a, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, + 0x6f, 0x77, 0x73, 0x12, 0x56, 0x0a, 0x12, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x63, 0x6f, 0x64, 0x65, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x25, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, 0x75, 0x74, 0x68, + 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, 0x4f, 0x41, 0x75, + 0x74, 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x48, 0x00, 0x52, 0x11, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, + 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, 0x12, 0x56, 0x0a, 0x12, 0x63, + 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x5f, 0x63, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, + 0x73, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, + 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, + 0x74, 0x69, 0x61, 0x6c, 0x73, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x48, 0x00, + 0x52, 0x11, 0x63, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, + 0x61, 0x6c, 0x73, 0x12, 0x3e, 0x0a, 0x08, 0x69, 0x6d, 0x70, 0x6c, 0x69, 0x63, 0x69, 0x74, 0x18, + 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, + 0x31, 0x2e, 0x49, 0x6d, 0x70, 0x6c, 0x69, 0x63, 0x69, 0x74, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, + 0x6c, 0x6f, 0x77, 0x42, 0x02, 0x18, 0x01, 0x48, 0x00, 0x52, 0x08, 0x69, 0x6d, 0x70, 0x6c, 0x69, + 0x63, 0x69, 0x74, 0x12, 0x3e, 0x0a, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x18, + 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1c, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, + 0x31, 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, + 0x6c, 0x6f, 0x77, 0x42, 0x02, 0x18, 0x01, 0x48, 0x00, 0x52, 0x08, 0x70, 0x61, 0x73, 0x73, 0x77, + 0x6f, 0x72, 0x64, 0x12, 0x41, 0x0a, 0x0b, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x5f, 0x63, 0x6f, + 0x64, 0x65, 0x18, 0x05, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1e, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, + 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x4f, + 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x48, 0x00, 0x52, 0x0a, 0x64, 0x65, 0x76, 0x69, + 0x63, 0x65, 0x43, 0x6f, 0x64, 0x65, 0x42, 0x06, 0x0a, 0x04, 0x66, 0x6c, 0x6f, 0x77, 0x22, 0xc1, + 0x02, 0x0a, 0x1a, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x43, 0x6f, 0x64, 0x65, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x30, 0x0a, + 0x11, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x75, + 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x10, 0x61, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, + 0x20, 0x0a, 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, + 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x08, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x72, + 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x75, 0x72, 0x6c, + 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x55, + 0x72, 0x6c, 0x12, 0x4e, 0x0a, 0x06, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, + 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x41, + 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x64, 0x65, + 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x73, 0x63, 0x6f, 0x70, + 0x65, 0x73, 0x12, 0x23, 0x0a, 0x0d, 0x70, 0x6b, 0x63, 0x65, 0x5f, 0x72, 0x65, 0x71, 0x75, 0x69, + 0x72, 0x65, 0x64, 0x18, 0x05, 0x20, 0x01, 0x28, 0x08, 0x52, 0x0c, 0x70, 0x6b, 0x63, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x69, 0x72, 0x65, 0x64, 0x1a, 0x39, 0x0a, 0x0b, 0x53, 0x63, 0x6f, 0x70, 0x65, + 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, + 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, + 0x38, 0x01, 0x22, 0xea, 0x01, 0x0a, 0x1a, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x43, 0x72, 0x65, + 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, 0x6c, 0x73, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, 0x6f, + 0x77, 0x12, 0x20, 0x0a, 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x08, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x55, 0x72, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x75, + 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, + 0x68, 0x55, 0x72, 0x6c, 0x12, 0x4e, 0x0a, 0x06, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x31, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, + 0x2e, 0x43, 0x6c, 0x69, 0x65, 0x6e, 0x74, 0x43, 0x72, 0x65, 0x64, 0x65, 0x6e, 0x74, 0x69, 0x61, + 0x6c, 0x73, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x53, 0x63, 0x6f, 0x70, + 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x73, 0x63, + 0x6f, 0x70, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, + 0xde, 0x01, 0x0a, 0x11, 0x49, 0x6d, 0x70, 0x6c, 0x69, 0x63, 0x69, 0x74, 0x4f, 0x41, 0x75, 0x74, + 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x2b, 0x0a, 0x11, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, + 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x10, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, + 0x72, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x75, 0x72, + 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, + 0x55, 0x72, 0x6c, 0x12, 0x40, 0x0a, 0x06, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x18, 0x03, 0x20, + 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, + 0x49, 0x6d, 0x70, 0x6c, 0x69, 0x63, 0x69, 0x74, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, 0x6f, + 0x77, 0x2e, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, 0x73, + 0x63, 0x6f, 0x70, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x45, + 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, + 0x22, 0xce, 0x01, 0x0a, 0x11, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x4f, 0x41, 0x75, + 0x74, 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x1b, 0x0a, 0x09, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x5f, + 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x08, 0x74, 0x6f, 0x6b, 0x65, 0x6e, + 0x55, 0x72, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x75, + 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0a, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, + 0x68, 0x55, 0x72, 0x6c, 0x12, 0x40, 0x0a, 0x06, 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x18, 0x03, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x28, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, + 0x2e, 0x50, 0x61, 0x73, 0x73, 0x77, 0x6f, 0x72, 0x64, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, + 0x6f, 0x77, 0x2e, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x52, 0x06, + 0x73, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x73, + 0x45, 0x6e, 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, + 0x28, 0x09, 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, + 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, + 0x01, 0x22, 0x9b, 0x02, 0x0a, 0x13, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, 0x64, 0x65, + 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x12, 0x3d, 0x0a, 0x18, 0x64, 0x65, 0x76, + 0x69, 0x63, 0x65, 0x5f, 0x61, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, + 0x52, 0x16, 0x64, 0x65, 0x76, 0x69, 0x63, 0x65, 0x41, 0x75, 0x74, 0x68, 0x6f, 0x72, 0x69, 0x7a, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x20, 0x0a, 0x09, 0x74, 0x6f, 0x6b, 0x65, + 0x6e, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, + 0x52, 0x08, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x55, 0x72, 0x6c, 0x12, 0x1f, 0x0a, 0x0b, 0x72, 0x65, + 0x66, 0x72, 0x65, 0x73, 0x68, 0x5f, 0x75, 0x72, 0x6c, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x0a, 0x72, 0x65, 0x66, 0x72, 0x65, 0x73, 0x68, 0x55, 0x72, 0x6c, 0x12, 0x47, 0x0a, 0x06, 0x73, + 0x63, 0x6f, 0x70, 0x65, 0x73, 0x18, 0x04, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x2a, 0x2e, 0x6c, 0x66, + 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x76, 0x69, 0x63, 0x65, 0x43, 0x6f, + 0x64, 0x65, 0x4f, 0x41, 0x75, 0x74, 0x68, 0x46, 0x6c, 0x6f, 0x77, 0x2e, 0x53, 0x63, 0x6f, 0x70, + 0x65, 0x73, 0x45, 0x6e, 0x74, 0x72, 0x79, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x73, 0x63, + 0x6f, 0x70, 0x65, 0x73, 0x1a, 0x39, 0x0a, 0x0b, 0x53, 0x63, 0x6f, 0x70, 0x65, 0x73, 0x45, 0x6e, + 0x74, 0x72, 0x79, 0x12, 0x10, 0x0a, 0x03, 0x6b, 0x65, 0x79, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x03, 0x6b, 0x65, 0x79, 0x12, 0x14, 0x0a, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x05, 0x76, 0x61, 0x6c, 0x75, 0x65, 0x3a, 0x02, 0x38, 0x01, 0x22, + 0xdf, 0x01, 0x0a, 0x12, 0x53, 0x65, 0x6e, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x12, 0x31, + 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, + 0x12, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x12, 0x49, 0x0a, 0x0d, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x23, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, + 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x52, 0x0d, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x75, 0x72, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x12, 0x33, 0x0a, 0x08, + 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, + 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, + 0x2e, 0x53, 0x74, 0x72, 0x75, 0x63, 0x74, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, + 0x61, 0x22, 0x7c, 0x0a, 0x0e, 0x47, 0x65, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x18, 0x01, 0x20, + 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x12, 0x13, 0x0a, 0x02, 0x69, + 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x02, 0x69, 0x64, + 0x12, 0x2a, 0x0a, 0x0e, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x5f, 0x6c, 0x65, 0x6e, 0x67, + 0x74, 0x68, 0x18, 0x03, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x0d, 0x68, 0x69, 0x73, 0x74, + 0x6f, 0x72, 0x79, 0x4c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x88, 0x01, 0x01, 0x42, 0x11, 0x0a, 0x0f, + 0x5f, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x22, + 0x9f, 0x03, 0x0a, 0x10, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, 0x65, 0x71, + 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x18, 0x01, + 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x12, 0x1d, 0x0a, 0x0a, + 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x09, 0x63, 0x6f, 0x6e, 0x74, 0x65, 0x78, 0x74, 0x49, 0x64, 0x12, 0x2c, 0x0a, 0x06, 0x73, + 0x74, 0x61, 0x74, 0x75, 0x73, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0e, 0x32, 0x14, 0x2e, 0x6c, 0x66, + 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x53, 0x74, 0x61, 0x74, + 0x65, 0x52, 0x06, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x12, 0x20, 0x0a, 0x09, 0x70, 0x61, 0x67, + 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x48, 0x00, 0x52, 0x08, + 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x88, 0x01, 0x01, 0x12, 0x1d, 0x0a, 0x0a, 0x70, + 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x05, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x12, 0x2a, 0x0a, 0x0e, 0x68, 0x69, + 0x73, 0x74, 0x6f, 0x72, 0x79, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x18, 0x06, 0x20, 0x01, + 0x28, 0x05, 0x48, 0x01, 0x52, 0x0d, 0x68, 0x69, 0x73, 0x74, 0x6f, 0x72, 0x79, 0x4c, 0x65, 0x6e, + 0x67, 0x74, 0x68, 0x88, 0x01, 0x01, 0x12, 0x50, 0x0a, 0x16, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, + 0x5f, 0x74, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, 0x6d, 0x70, 0x5f, 0x61, 0x66, 0x74, 0x65, 0x72, + 0x18, 0x07, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x1a, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, + 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, 0x61, + 0x6d, 0x70, 0x52, 0x14, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x54, 0x69, 0x6d, 0x65, 0x73, 0x74, + 0x61, 0x6d, 0x70, 0x41, 0x66, 0x74, 0x65, 0x72, 0x12, 0x30, 0x0a, 0x11, 0x69, 0x6e, 0x63, 0x6c, + 0x75, 0x64, 0x65, 0x5f, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x18, 0x08, 0x20, + 0x01, 0x28, 0x08, 0x48, 0x02, 0x52, 0x10, 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x41, 0x72, + 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x73, 0x88, 0x01, 0x01, 0x42, 0x0c, 0x0a, 0x0a, 0x5f, 0x70, + 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x42, 0x11, 0x0a, 0x0f, 0x5f, 0x68, 0x69, 0x73, + 0x74, 0x6f, 0x72, 0x79, 0x5f, 0x6c, 0x65, 0x6e, 0x67, 0x74, 0x68, 0x42, 0x14, 0x0a, 0x12, 0x5f, + 0x69, 0x6e, 0x63, 0x6c, 0x75, 0x64, 0x65, 0x5f, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, + 0x73, 0x22, 0xb2, 0x01, 0x0a, 0x11, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x2a, 0x0a, 0x05, 0x74, 0x61, 0x73, 0x6b, 0x73, + 0x18, 0x01, 0x20, 0x03, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, + 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x05, 0x74, 0x61, + 0x73, 0x6b, 0x73, 0x12, 0x2b, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, 0x65, + 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, + 0x02, 0x52, 0x0d, 0x6e, 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, + 0x12, 0x20, 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x03, 0x20, + 0x01, 0x28, 0x05, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, + 0x7a, 0x65, 0x12, 0x22, 0x0a, 0x0a, 0x74, 0x6f, 0x74, 0x61, 0x6c, 0x5f, 0x73, 0x69, 0x7a, 0x65, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x05, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x09, 0x74, 0x6f, 0x74, + 0x61, 0x6c, 0x53, 0x69, 0x7a, 0x65, 0x22, 0x75, 0x0a, 0x11, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, + 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x74, + 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, + 0x61, 0x6e, 0x74, 0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, + 0x03, 0xe0, 0x41, 0x02, 0x52, 0x02, 0x69, 0x64, 0x12, 0x33, 0x0a, 0x08, 0x6d, 0x65, 0x74, 0x61, + 0x64, 0x61, 0x74, 0x61, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x17, 0x2e, 0x67, 0x6f, 0x6f, + 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, 0x75, 0x66, 0x2e, 0x53, 0x74, 0x72, + 0x75, 0x63, 0x74, 0x52, 0x08, 0x6d, 0x65, 0x74, 0x61, 0x64, 0x61, 0x74, 0x61, 0x22, 0x71, 0x0a, + 0x24, 0x47, 0x65, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, + 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, + 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x18, + 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x12, 0x1c, 0x0a, + 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, + 0xe0, 0x41, 0x02, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x13, 0x0a, 0x02, 0x69, + 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x02, 0x69, 0x64, + 0x22, 0x74, 0x0a, 0x27, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, + 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x74, + 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, + 0x61, 0x6e, 0x74, 0x12, 0x1c, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, + 0x64, 0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, + 0x41, 0x02, 0x52, 0x02, 0x69, 0x64, 0x22, 0x45, 0x0a, 0x16, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, + 0x69, 0x62, 0x65, 0x54, 0x6f, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, + 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x12, 0x13, 0x0a, 0x02, 0x69, 0x64, 0x18, 0x02, + 0x20, 0x01, 0x28, 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x02, 0x69, 0x64, 0x22, 0x9a, 0x01, + 0x0a, 0x26, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, + 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, 0x61, + 0x6e, 0x74, 0x18, 0x04, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, + 0x12, 0x1c, 0x0a, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x18, 0x01, 0x20, 0x01, 0x28, + 0x09, 0x42, 0x03, 0xe0, 0x41, 0x02, 0x52, 0x06, 0x74, 0x61, 0x73, 0x6b, 0x49, 0x64, 0x12, 0x1b, + 0x0a, 0x09, 0x70, 0x61, 0x67, 0x65, 0x5f, 0x73, 0x69, 0x7a, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, + 0x05, 0x52, 0x08, 0x70, 0x61, 0x67, 0x65, 0x53, 0x69, 0x7a, 0x65, 0x12, 0x1d, 0x0a, 0x0a, 0x70, + 0x61, 0x67, 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x03, 0x20, 0x01, 0x28, 0x09, 0x52, + 0x09, 0x70, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x22, 0x35, 0x0a, 0x1b, 0x47, 0x65, + 0x74, 0x45, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, 0x61, + 0x72, 0x64, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x12, 0x16, 0x0a, 0x06, 0x74, 0x65, 0x6e, + 0x61, 0x6e, 0x74, 0x18, 0x01, 0x20, 0x01, 0x28, 0x09, 0x52, 0x06, 0x74, 0x65, 0x6e, 0x61, 0x6e, + 0x74, 0x22, 0x77, 0x0a, 0x13, 0x53, 0x65, 0x6e, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, 0x04, 0x74, 0x61, 0x73, 0x6b, + 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, + 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x48, 0x00, 0x52, 0x04, 0x74, 0x61, 0x73, 0x6b, 0x12, + 0x2e, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, 0x02, 0x20, 0x01, 0x28, 0x0b, + 0x32, 0x12, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x42, + 0x09, 0x0a, 0x07, 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x8a, 0x02, 0x0a, 0x0e, 0x53, + 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x12, 0x25, 0x0a, + 0x04, 0x74, 0x61, 0x73, 0x6b, 0x18, 0x01, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x0f, 0x2e, 0x6c, 0x66, + 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x48, 0x00, 0x52, 0x04, + 0x74, 0x61, 0x73, 0x6b, 0x12, 0x2e, 0x0a, 0x07, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x18, + 0x02, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x12, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, + 0x31, 0x2e, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x48, 0x00, 0x52, 0x07, 0x6d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x12, 0x47, 0x0a, 0x0d, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x5f, 0x75, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x18, 0x03, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x20, 0x2e, 0x6c, 0x66, + 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x53, 0x74, 0x61, 0x74, + 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, + 0x0c, 0x73, 0x74, 0x61, 0x74, 0x75, 0x73, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x12, 0x4d, 0x0a, + 0x0f, 0x61, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x5f, 0x75, 0x70, 0x64, 0x61, 0x74, 0x65, + 0x18, 0x04, 0x20, 0x01, 0x28, 0x0b, 0x32, 0x22, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, + 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x41, 0x72, 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x55, + 0x70, 0x64, 0x61, 0x74, 0x65, 0x45, 0x76, 0x65, 0x6e, 0x74, 0x48, 0x00, 0x52, 0x0e, 0x61, 0x72, + 0x74, 0x69, 0x66, 0x61, 0x63, 0x74, 0x55, 0x70, 0x64, 0x61, 0x74, 0x65, 0x42, 0x09, 0x0a, 0x07, + 0x70, 0x61, 0x79, 0x6c, 0x6f, 0x61, 0x64, 0x22, 0x92, 0x01, 0x0a, 0x27, 0x4c, 0x69, 0x73, 0x74, + 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x12, 0x3f, 0x0a, 0x07, 0x63, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x18, 0x01, + 0x20, 0x03, 0x28, 0x0b, 0x32, 0x25, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, + 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, + 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x07, 0x63, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x73, 0x12, 0x26, 0x0a, 0x0f, 0x6e, 0x65, 0x78, 0x74, 0x5f, 0x70, 0x61, 0x67, + 0x65, 0x5f, 0x74, 0x6f, 0x6b, 0x65, 0x6e, 0x18, 0x02, 0x20, 0x01, 0x28, 0x09, 0x52, 0x0d, 0x6e, + 0x65, 0x78, 0x74, 0x50, 0x61, 0x67, 0x65, 0x54, 0x6f, 0x6b, 0x65, 0x6e, 0x2a, 0xf9, 0x01, 0x0a, + 0x09, 0x54, 0x61, 0x73, 0x6b, 0x53, 0x74, 0x61, 0x74, 0x65, 0x12, 0x1a, 0x0a, 0x16, 0x54, 0x41, + 0x53, 0x4b, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, + 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x18, 0x0a, 0x14, 0x54, 0x41, 0x53, 0x4b, 0x5f, 0x53, + 0x54, 0x41, 0x54, 0x45, 0x5f, 0x53, 0x55, 0x42, 0x4d, 0x49, 0x54, 0x54, 0x45, 0x44, 0x10, 0x01, + 0x12, 0x16, 0x0a, 0x12, 0x54, 0x41, 0x53, 0x4b, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x57, + 0x4f, 0x52, 0x4b, 0x49, 0x4e, 0x47, 0x10, 0x02, 0x12, 0x18, 0x0a, 0x14, 0x54, 0x41, 0x53, 0x4b, + 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x43, 0x4f, 0x4d, 0x50, 0x4c, 0x45, 0x54, 0x45, 0x44, + 0x10, 0x03, 0x12, 0x15, 0x0a, 0x11, 0x54, 0x41, 0x53, 0x4b, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, + 0x5f, 0x46, 0x41, 0x49, 0x4c, 0x45, 0x44, 0x10, 0x04, 0x12, 0x17, 0x0a, 0x13, 0x54, 0x41, 0x53, + 0x4b, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x43, 0x41, 0x4e, 0x43, 0x45, 0x4c, 0x45, 0x44, + 0x10, 0x05, 0x12, 0x1d, 0x0a, 0x19, 0x54, 0x41, 0x53, 0x4b, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, + 0x5f, 0x49, 0x4e, 0x50, 0x55, 0x54, 0x5f, 0x52, 0x45, 0x51, 0x55, 0x49, 0x52, 0x45, 0x44, 0x10, + 0x06, 0x12, 0x17, 0x0a, 0x13, 0x54, 0x41, 0x53, 0x4b, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, + 0x52, 0x45, 0x4a, 0x45, 0x43, 0x54, 0x45, 0x44, 0x10, 0x07, 0x12, 0x1c, 0x0a, 0x18, 0x54, 0x41, + 0x53, 0x4b, 0x5f, 0x53, 0x54, 0x41, 0x54, 0x45, 0x5f, 0x41, 0x55, 0x54, 0x48, 0x5f, 0x52, 0x45, + 0x51, 0x55, 0x49, 0x52, 0x45, 0x44, 0x10, 0x08, 0x2a, 0x3b, 0x0a, 0x04, 0x52, 0x6f, 0x6c, 0x65, + 0x12, 0x14, 0x0a, 0x10, 0x52, 0x4f, 0x4c, 0x45, 0x5f, 0x55, 0x4e, 0x53, 0x50, 0x45, 0x43, 0x49, + 0x46, 0x49, 0x45, 0x44, 0x10, 0x00, 0x12, 0x0d, 0x0a, 0x09, 0x52, 0x4f, 0x4c, 0x45, 0x5f, 0x55, + 0x53, 0x45, 0x52, 0x10, 0x01, 0x12, 0x0e, 0x0a, 0x0a, 0x52, 0x4f, 0x4c, 0x45, 0x5f, 0x41, 0x47, + 0x45, 0x4e, 0x54, 0x10, 0x02, 0x32, 0x97, 0x0f, 0x0a, 0x0a, 0x41, 0x32, 0x41, 0x53, 0x65, 0x72, + 0x76, 0x69, 0x63, 0x65, 0x12, 0x83, 0x01, 0x0a, 0x0b, 0x53, 0x65, 0x6e, 0x64, 0x4d, 0x65, 0x73, + 0x73, 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, + 0x2e, 0x53, 0x65, 0x6e, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, + 0x65, 0x73, 0x74, 0x1a, 0x1e, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, + 0x53, 0x65, 0x6e, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x73, 0x70, 0x6f, + 0x6e, 0x73, 0x65, 0x22, 0x35, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x2f, 0x3a, 0x01, 0x2a, 0x5a, 0x1b, + 0x3a, 0x01, 0x2a, 0x22, 0x16, 0x2f, 0x7b, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x7d, 0x2f, 0x6d, + 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x3a, 0x73, 0x65, 0x6e, 0x64, 0x22, 0x0d, 0x2f, 0x6d, 0x65, + 0x73, 0x73, 0x61, 0x67, 0x65, 0x3a, 0x73, 0x65, 0x6e, 0x64, 0x12, 0x8d, 0x01, 0x0a, 0x14, 0x53, + 0x65, 0x6e, 0x64, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x69, 0x6e, 0x67, 0x4d, 0x65, 0x73, 0x73, + 0x61, 0x67, 0x65, 0x12, 0x1d, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, + 0x53, 0x65, 0x6e, 0x64, 0x4d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, 0x52, 0x65, 0x71, 0x75, 0x65, + 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x53, + 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x39, 0x82, + 0xd3, 0xe4, 0x93, 0x02, 0x33, 0x3a, 0x01, 0x2a, 0x5a, 0x1d, 0x3a, 0x01, 0x2a, 0x22, 0x18, 0x2f, + 0x7b, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x7d, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, 0x65, + 0x3a, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x22, 0x0f, 0x2f, 0x6d, 0x65, 0x73, 0x73, 0x61, 0x67, + 0x65, 0x3a, 0x73, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x30, 0x01, 0x12, 0x6b, 0x0a, 0x07, 0x47, 0x65, + 0x74, 0x54, 0x61, 0x73, 0x6b, 0x12, 0x19, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, + 0x31, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x0f, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, + 0x6b, 0x22, 0x34, 0xda, 0x41, 0x02, 0x69, 0x64, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x29, 0x5a, 0x18, + 0x12, 0x16, 0x2f, 0x7b, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x7d, 0x2f, 0x74, 0x61, 0x73, 0x6b, + 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x12, 0x0d, 0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, + 0x2f, 0x7b, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x12, 0x69, 0x0a, 0x09, 0x4c, 0x69, 0x73, 0x74, 0x54, + 0x61, 0x73, 0x6b, 0x73, 0x12, 0x1b, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, + 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, + 0x74, 0x1a, 0x1c, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, + 0x73, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, + 0x21, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x1b, 0x5a, 0x11, 0x12, 0x0f, 0x2f, 0x7b, 0x74, 0x65, 0x6e, + 0x61, 0x6e, 0x74, 0x7d, 0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x12, 0x06, 0x2f, 0x74, 0x61, 0x73, + 0x6b, 0x73, 0x12, 0x80, 0x01, 0x0a, 0x0a, 0x43, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x61, 0x73, + 0x6b, 0x12, 0x1c, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x43, 0x61, + 0x6e, 0x63, 0x65, 0x6c, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, + 0x0f, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, + 0x22, 0x43, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x3d, 0x3a, 0x01, 0x2a, 0x5a, 0x22, 0x3a, 0x01, 0x2a, + 0x22, 0x1d, 0x2f, 0x7b, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x7d, 0x2f, 0x74, 0x61, 0x73, 0x6b, + 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x3a, 0x63, 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x22, + 0x14, 0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x3a, 0x63, + 0x61, 0x6e, 0x63, 0x65, 0x6c, 0x12, 0x96, 0x01, 0x0a, 0x0f, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, + 0x69, 0x62, 0x65, 0x54, 0x6f, 0x54, 0x61, 0x73, 0x6b, 0x12, 0x21, 0x2e, 0x6c, 0x66, 0x2e, 0x61, + 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x54, + 0x6f, 0x54, 0x61, 0x73, 0x6b, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x19, 0x2e, 0x6c, + 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x53, 0x74, 0x72, 0x65, 0x61, 0x6d, 0x52, + 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, 0x22, 0x43, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x3d, 0x5a, + 0x22, 0x12, 0x20, 0x2f, 0x7b, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x7d, 0x2f, 0x74, 0x61, 0x73, + 0x6b, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x3a, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, + 0x69, 0x62, 0x65, 0x12, 0x17, 0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x3d, + 0x2a, 0x7d, 0x3a, 0x73, 0x75, 0x62, 0x73, 0x63, 0x72, 0x69, 0x62, 0x65, 0x30, 0x01, 0x12, 0xf3, + 0x01, 0x0a, 0x20, 0x43, 0x72, 0x65, 0x61, 0x74, 0x65, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, + 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x12, 0x25, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, + 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x1a, 0x25, 0x2e, 0x6c, 0x66, 0x2e, + 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, + 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, + 0x67, 0x22, 0x80, 0x01, 0xda, 0x41, 0x0e, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x2c, 0x63, + 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x69, 0x3a, 0x01, 0x2a, 0x5a, 0x38, + 0x3a, 0x01, 0x2a, 0x22, 0x33, 0x2f, 0x7b, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x7d, 0x2f, 0x74, + 0x61, 0x73, 0x6b, 0x73, 0x2f, 0x7b, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x3d, 0x2a, 0x7d, + 0x2f, 0x70, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x22, 0x2a, 0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, + 0x2f, 0x7b, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x2f, 0x70, 0x75, 0x73, + 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, + 0x66, 0x69, 0x67, 0x73, 0x12, 0xfe, 0x01, 0x0a, 0x1d, 0x47, 0x65, 0x74, 0x54, 0x61, 0x73, 0x6b, + 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, + 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x2f, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, + 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, + 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, + 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x25, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, + 0x2e, 0x76, 0x31, 0x2e, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, + 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x22, 0x84, + 0x01, 0xda, 0x41, 0x0a, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x2c, 0x69, 0x64, 0x82, 0xd3, + 0xe4, 0x93, 0x02, 0x71, 0x5a, 0x3c, 0x12, 0x3a, 0x2f, 0x7b, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, + 0x7d, 0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2f, 0x7b, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, + 0x3d, 0x2a, 0x7d, 0x2f, 0x70, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, + 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x3d, + 0x2a, 0x7d, 0x12, 0x31, 0x2f, 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2f, 0x7b, 0x74, 0x61, 0x73, 0x6b, + 0x5f, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x2f, 0x70, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, + 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x2f, 0x7b, + 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x12, 0xfd, 0x01, 0x0a, 0x1f, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x61, + 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x31, 0x2e, 0x6c, 0x66, 0x2e, 0x61, + 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, + 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x32, 0x2e, 0x6c, + 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x4c, 0x69, 0x73, 0x74, 0x54, 0x61, 0x73, + 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x52, 0x65, 0x73, 0x70, 0x6f, 0x6e, 0x73, 0x65, + 0x22, 0x73, 0xda, 0x41, 0x07, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x82, 0xd3, 0xe4, 0x93, + 0x02, 0x63, 0x5a, 0x35, 0x12, 0x33, 0x2f, 0x7b, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x7d, 0x2f, + 0x74, 0x61, 0x73, 0x6b, 0x73, 0x2f, 0x7b, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x3d, 0x2a, + 0x7d, 0x2f, 0x70, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, + 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x2a, 0x2f, 0x74, 0x61, 0x73, 0x6b, + 0x73, 0x2f, 0x7b, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x2f, 0x70, 0x75, + 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x73, 0x12, 0x8f, 0x01, 0x0a, 0x14, 0x47, 0x65, 0x74, 0x45, 0x78, 0x74, + 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, 0x61, 0x72, 0x64, 0x12, 0x26, + 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x47, 0x65, 0x74, 0x45, 0x78, + 0x74, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, 0x61, 0x72, 0x64, 0x52, + 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, 0x1a, 0x14, 0x2e, 0x6c, 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, + 0x76, 0x31, 0x2e, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, 0x61, 0x72, 0x64, 0x22, 0x39, 0x82, 0xd3, + 0xe4, 0x93, 0x02, 0x33, 0x5a, 0x1d, 0x12, 0x1b, 0x2f, 0x7b, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, + 0x7d, 0x2f, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x67, 0x65, 0x6e, 0x74, 0x43, + 0x61, 0x72, 0x64, 0x12, 0x12, 0x2f, 0x65, 0x78, 0x74, 0x65, 0x6e, 0x64, 0x65, 0x64, 0x41, 0x67, + 0x65, 0x6e, 0x74, 0x43, 0x61, 0x72, 0x64, 0x12, 0xf5, 0x01, 0x0a, 0x20, 0x44, 0x65, 0x6c, 0x65, + 0x74, 0x65, 0x54, 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, + 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x12, 0x32, 0x2e, 0x6c, + 0x66, 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x2e, 0x44, 0x65, 0x6c, 0x65, 0x74, 0x65, 0x54, + 0x61, 0x73, 0x6b, 0x50, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, + 0x69, 0x6f, 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x52, 0x65, 0x71, 0x75, 0x65, 0x73, 0x74, + 0x1a, 0x16, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x62, + 0x75, 0x66, 0x2e, 0x45, 0x6d, 0x70, 0x74, 0x79, 0x22, 0x84, 0x01, 0xda, 0x41, 0x0a, 0x74, 0x61, + 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x2c, 0x69, 0x64, 0x82, 0xd3, 0xe4, 0x93, 0x02, 0x71, 0x5a, 0x3c, + 0x2a, 0x3a, 0x2f, 0x7b, 0x74, 0x65, 0x6e, 0x61, 0x6e, 0x74, 0x7d, 0x2f, 0x74, 0x61, 0x73, 0x6b, + 0x73, 0x2f, 0x7b, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x2f, 0x70, 0x75, + 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, 0x6e, 0x43, 0x6f, + 0x6e, 0x66, 0x69, 0x67, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x2a, 0x31, 0x2f, 0x74, + 0x61, 0x73, 0x6b, 0x73, 0x2f, 0x7b, 0x74, 0x61, 0x73, 0x6b, 0x5f, 0x69, 0x64, 0x3d, 0x2a, 0x7d, + 0x2f, 0x70, 0x75, 0x73, 0x68, 0x4e, 0x6f, 0x74, 0x69, 0x66, 0x69, 0x63, 0x61, 0x74, 0x69, 0x6f, + 0x6e, 0x43, 0x6f, 0x6e, 0x66, 0x69, 0x67, 0x73, 0x2f, 0x7b, 0x69, 0x64, 0x3d, 0x2a, 0x7d, 0x42, + 0x46, 0x0a, 0x14, 0x63, 0x6f, 0x6d, 0x2e, 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x6c, 0x66, + 0x2e, 0x61, 0x32, 0x61, 0x2e, 0x76, 0x31, 0x42, 0x03, 0x41, 0x32, 0x41, 0x50, 0x01, 0x5a, 0x1b, + 0x67, 0x6f, 0x6f, 0x67, 0x6c, 0x65, 0x2e, 0x67, 0x6f, 0x6c, 0x61, 0x6e, 0x67, 0x2e, 0x6f, 0x72, + 0x67, 0x2f, 0x6c, 0x66, 0x2f, 0x61, 0x32, 0x61, 0x2f, 0x76, 0x31, 0xaa, 0x02, 0x09, 0x4c, 0x66, + 0x2e, 0x41, 0x32, 0x61, 0x2e, 0x56, 0x31, 0x62, 0x06, 0x70, 0x72, 0x6f, 0x74, 0x6f, 0x33, +} + +var ( + file_a2a_proto_rawDescOnce sync.Once + file_a2a_proto_rawDescData = file_a2a_proto_rawDesc +) + +func file_a2a_proto_rawDescGZIP() []byte { + file_a2a_proto_rawDescOnce.Do(func() { + file_a2a_proto_rawDescData = protoimpl.X.CompressGZIP(file_a2a_proto_rawDescData) + }) + return file_a2a_proto_rawDescData +} + +var file_a2a_proto_enumTypes = make([]protoimpl.EnumInfo, 2) +var file_a2a_proto_msgTypes = make([]protoimpl.MessageInfo, 51) +var file_a2a_proto_goTypes = []any{ + (TaskState)(0), // 0: lf.a2a.v1.TaskState + (Role)(0), // 1: lf.a2a.v1.Role + (*SendMessageConfiguration)(nil), // 2: lf.a2a.v1.SendMessageConfiguration + (*Task)(nil), // 3: lf.a2a.v1.Task + (*TaskStatus)(nil), // 4: lf.a2a.v1.TaskStatus + (*Part)(nil), // 5: lf.a2a.v1.Part + (*Message)(nil), // 6: lf.a2a.v1.Message + (*Artifact)(nil), // 7: lf.a2a.v1.Artifact + (*TaskStatusUpdateEvent)(nil), // 8: lf.a2a.v1.TaskStatusUpdateEvent + (*TaskArtifactUpdateEvent)(nil), // 9: lf.a2a.v1.TaskArtifactUpdateEvent + (*AuthenticationInfo)(nil), // 10: lf.a2a.v1.AuthenticationInfo + (*AgentInterface)(nil), // 11: lf.a2a.v1.AgentInterface + (*AgentCard)(nil), // 12: lf.a2a.v1.AgentCard + (*AgentProvider)(nil), // 13: lf.a2a.v1.AgentProvider + (*AgentCapabilities)(nil), // 14: lf.a2a.v1.AgentCapabilities + (*AgentExtension)(nil), // 15: lf.a2a.v1.AgentExtension + (*AgentSkill)(nil), // 16: lf.a2a.v1.AgentSkill + (*AgentCardSignature)(nil), // 17: lf.a2a.v1.AgentCardSignature + (*TaskPushNotificationConfig)(nil), // 18: lf.a2a.v1.TaskPushNotificationConfig + (*StringList)(nil), // 19: lf.a2a.v1.StringList + (*SecurityRequirement)(nil), // 20: lf.a2a.v1.SecurityRequirement + (*SecurityScheme)(nil), // 21: lf.a2a.v1.SecurityScheme + (*APIKeySecurityScheme)(nil), // 22: lf.a2a.v1.APIKeySecurityScheme + (*HTTPAuthSecurityScheme)(nil), // 23: lf.a2a.v1.HTTPAuthSecurityScheme + (*OAuth2SecurityScheme)(nil), // 24: lf.a2a.v1.OAuth2SecurityScheme + (*OpenIdConnectSecurityScheme)(nil), // 25: lf.a2a.v1.OpenIdConnectSecurityScheme + (*MutualTlsSecurityScheme)(nil), // 26: lf.a2a.v1.MutualTlsSecurityScheme + (*OAuthFlows)(nil), // 27: lf.a2a.v1.OAuthFlows + (*AuthorizationCodeOAuthFlow)(nil), // 28: lf.a2a.v1.AuthorizationCodeOAuthFlow + (*ClientCredentialsOAuthFlow)(nil), // 29: lf.a2a.v1.ClientCredentialsOAuthFlow + (*ImplicitOAuthFlow)(nil), // 30: lf.a2a.v1.ImplicitOAuthFlow + (*PasswordOAuthFlow)(nil), // 31: lf.a2a.v1.PasswordOAuthFlow + (*DeviceCodeOAuthFlow)(nil), // 32: lf.a2a.v1.DeviceCodeOAuthFlow + (*SendMessageRequest)(nil), // 33: lf.a2a.v1.SendMessageRequest + (*GetTaskRequest)(nil), // 34: lf.a2a.v1.GetTaskRequest + (*ListTasksRequest)(nil), // 35: lf.a2a.v1.ListTasksRequest + (*ListTasksResponse)(nil), // 36: lf.a2a.v1.ListTasksResponse + (*CancelTaskRequest)(nil), // 37: lf.a2a.v1.CancelTaskRequest + (*GetTaskPushNotificationConfigRequest)(nil), // 38: lf.a2a.v1.GetTaskPushNotificationConfigRequest + (*DeleteTaskPushNotificationConfigRequest)(nil), // 39: lf.a2a.v1.DeleteTaskPushNotificationConfigRequest + (*SubscribeToTaskRequest)(nil), // 40: lf.a2a.v1.SubscribeToTaskRequest + (*ListTaskPushNotificationConfigsRequest)(nil), // 41: lf.a2a.v1.ListTaskPushNotificationConfigsRequest + (*GetExtendedAgentCardRequest)(nil), // 42: lf.a2a.v1.GetExtendedAgentCardRequest + (*SendMessageResponse)(nil), // 43: lf.a2a.v1.SendMessageResponse + (*StreamResponse)(nil), // 44: lf.a2a.v1.StreamResponse + (*ListTaskPushNotificationConfigsResponse)(nil), // 45: lf.a2a.v1.ListTaskPushNotificationConfigsResponse + nil, // 46: lf.a2a.v1.AgentCard.SecuritySchemesEntry + nil, // 47: lf.a2a.v1.SecurityRequirement.SchemesEntry + nil, // 48: lf.a2a.v1.AuthorizationCodeOAuthFlow.ScopesEntry + nil, // 49: lf.a2a.v1.ClientCredentialsOAuthFlow.ScopesEntry + nil, // 50: lf.a2a.v1.ImplicitOAuthFlow.ScopesEntry + nil, // 51: lf.a2a.v1.PasswordOAuthFlow.ScopesEntry + nil, // 52: lf.a2a.v1.DeviceCodeOAuthFlow.ScopesEntry + (*structpb.Struct)(nil), // 53: google.protobuf.Struct + (*timestamppb.Timestamp)(nil), // 54: google.protobuf.Timestamp + (*structpb.Value)(nil), // 55: google.protobuf.Value + (*emptypb.Empty)(nil), // 56: google.protobuf.Empty +} +var file_a2a_proto_depIdxs = []int32{ + 18, // 0: lf.a2a.v1.SendMessageConfiguration.task_push_notification_config:type_name -> lf.a2a.v1.TaskPushNotificationConfig + 4, // 1: lf.a2a.v1.Task.status:type_name -> lf.a2a.v1.TaskStatus + 7, // 2: lf.a2a.v1.Task.artifacts:type_name -> lf.a2a.v1.Artifact + 6, // 3: lf.a2a.v1.Task.history:type_name -> lf.a2a.v1.Message + 53, // 4: lf.a2a.v1.Task.metadata:type_name -> google.protobuf.Struct + 0, // 5: lf.a2a.v1.TaskStatus.state:type_name -> lf.a2a.v1.TaskState + 6, // 6: lf.a2a.v1.TaskStatus.message:type_name -> lf.a2a.v1.Message + 54, // 7: lf.a2a.v1.TaskStatus.timestamp:type_name -> google.protobuf.Timestamp + 55, // 8: lf.a2a.v1.Part.data:type_name -> google.protobuf.Value + 53, // 9: lf.a2a.v1.Part.metadata:type_name -> google.protobuf.Struct + 1, // 10: lf.a2a.v1.Message.role:type_name -> lf.a2a.v1.Role + 5, // 11: lf.a2a.v1.Message.parts:type_name -> lf.a2a.v1.Part + 53, // 12: lf.a2a.v1.Message.metadata:type_name -> google.protobuf.Struct + 5, // 13: lf.a2a.v1.Artifact.parts:type_name -> lf.a2a.v1.Part + 53, // 14: lf.a2a.v1.Artifact.metadata:type_name -> google.protobuf.Struct + 4, // 15: lf.a2a.v1.TaskStatusUpdateEvent.status:type_name -> lf.a2a.v1.TaskStatus + 53, // 16: lf.a2a.v1.TaskStatusUpdateEvent.metadata:type_name -> google.protobuf.Struct + 7, // 17: lf.a2a.v1.TaskArtifactUpdateEvent.artifact:type_name -> lf.a2a.v1.Artifact + 53, // 18: lf.a2a.v1.TaskArtifactUpdateEvent.metadata:type_name -> google.protobuf.Struct + 11, // 19: lf.a2a.v1.AgentCard.supported_interfaces:type_name -> lf.a2a.v1.AgentInterface + 13, // 20: lf.a2a.v1.AgentCard.provider:type_name -> lf.a2a.v1.AgentProvider + 14, // 21: lf.a2a.v1.AgentCard.capabilities:type_name -> lf.a2a.v1.AgentCapabilities + 46, // 22: lf.a2a.v1.AgentCard.security_schemes:type_name -> lf.a2a.v1.AgentCard.SecuritySchemesEntry + 20, // 23: lf.a2a.v1.AgentCard.security_requirements:type_name -> lf.a2a.v1.SecurityRequirement + 16, // 24: lf.a2a.v1.AgentCard.skills:type_name -> lf.a2a.v1.AgentSkill + 17, // 25: lf.a2a.v1.AgentCard.signatures:type_name -> lf.a2a.v1.AgentCardSignature + 15, // 26: lf.a2a.v1.AgentCapabilities.extensions:type_name -> lf.a2a.v1.AgentExtension + 53, // 27: lf.a2a.v1.AgentExtension.params:type_name -> google.protobuf.Struct + 20, // 28: lf.a2a.v1.AgentSkill.security_requirements:type_name -> lf.a2a.v1.SecurityRequirement + 53, // 29: lf.a2a.v1.AgentCardSignature.header:type_name -> google.protobuf.Struct + 10, // 30: lf.a2a.v1.TaskPushNotificationConfig.authentication:type_name -> lf.a2a.v1.AuthenticationInfo + 47, // 31: lf.a2a.v1.SecurityRequirement.schemes:type_name -> lf.a2a.v1.SecurityRequirement.SchemesEntry + 22, // 32: lf.a2a.v1.SecurityScheme.api_key_security_scheme:type_name -> lf.a2a.v1.APIKeySecurityScheme + 23, // 33: lf.a2a.v1.SecurityScheme.http_auth_security_scheme:type_name -> lf.a2a.v1.HTTPAuthSecurityScheme + 24, // 34: lf.a2a.v1.SecurityScheme.oauth2_security_scheme:type_name -> lf.a2a.v1.OAuth2SecurityScheme + 25, // 35: lf.a2a.v1.SecurityScheme.open_id_connect_security_scheme:type_name -> lf.a2a.v1.OpenIdConnectSecurityScheme + 26, // 36: lf.a2a.v1.SecurityScheme.mtls_security_scheme:type_name -> lf.a2a.v1.MutualTlsSecurityScheme + 27, // 37: lf.a2a.v1.OAuth2SecurityScheme.flows:type_name -> lf.a2a.v1.OAuthFlows + 28, // 38: lf.a2a.v1.OAuthFlows.authorization_code:type_name -> lf.a2a.v1.AuthorizationCodeOAuthFlow + 29, // 39: lf.a2a.v1.OAuthFlows.client_credentials:type_name -> lf.a2a.v1.ClientCredentialsOAuthFlow + 30, // 40: lf.a2a.v1.OAuthFlows.implicit:type_name -> lf.a2a.v1.ImplicitOAuthFlow + 31, // 41: lf.a2a.v1.OAuthFlows.password:type_name -> lf.a2a.v1.PasswordOAuthFlow + 32, // 42: lf.a2a.v1.OAuthFlows.device_code:type_name -> lf.a2a.v1.DeviceCodeOAuthFlow + 48, // 43: lf.a2a.v1.AuthorizationCodeOAuthFlow.scopes:type_name -> lf.a2a.v1.AuthorizationCodeOAuthFlow.ScopesEntry + 49, // 44: lf.a2a.v1.ClientCredentialsOAuthFlow.scopes:type_name -> lf.a2a.v1.ClientCredentialsOAuthFlow.ScopesEntry + 50, // 45: lf.a2a.v1.ImplicitOAuthFlow.scopes:type_name -> lf.a2a.v1.ImplicitOAuthFlow.ScopesEntry + 51, // 46: lf.a2a.v1.PasswordOAuthFlow.scopes:type_name -> lf.a2a.v1.PasswordOAuthFlow.ScopesEntry + 52, // 47: lf.a2a.v1.DeviceCodeOAuthFlow.scopes:type_name -> lf.a2a.v1.DeviceCodeOAuthFlow.ScopesEntry + 6, // 48: lf.a2a.v1.SendMessageRequest.message:type_name -> lf.a2a.v1.Message + 2, // 49: lf.a2a.v1.SendMessageRequest.configuration:type_name -> lf.a2a.v1.SendMessageConfiguration + 53, // 50: lf.a2a.v1.SendMessageRequest.metadata:type_name -> google.protobuf.Struct + 0, // 51: lf.a2a.v1.ListTasksRequest.status:type_name -> lf.a2a.v1.TaskState + 54, // 52: lf.a2a.v1.ListTasksRequest.status_timestamp_after:type_name -> google.protobuf.Timestamp + 3, // 53: lf.a2a.v1.ListTasksResponse.tasks:type_name -> lf.a2a.v1.Task + 53, // 54: lf.a2a.v1.CancelTaskRequest.metadata:type_name -> google.protobuf.Struct + 3, // 55: lf.a2a.v1.SendMessageResponse.task:type_name -> lf.a2a.v1.Task + 6, // 56: lf.a2a.v1.SendMessageResponse.message:type_name -> lf.a2a.v1.Message + 3, // 57: lf.a2a.v1.StreamResponse.task:type_name -> lf.a2a.v1.Task + 6, // 58: lf.a2a.v1.StreamResponse.message:type_name -> lf.a2a.v1.Message + 8, // 59: lf.a2a.v1.StreamResponse.status_update:type_name -> lf.a2a.v1.TaskStatusUpdateEvent + 9, // 60: lf.a2a.v1.StreamResponse.artifact_update:type_name -> lf.a2a.v1.TaskArtifactUpdateEvent + 18, // 61: lf.a2a.v1.ListTaskPushNotificationConfigsResponse.configs:type_name -> lf.a2a.v1.TaskPushNotificationConfig + 21, // 62: lf.a2a.v1.AgentCard.SecuritySchemesEntry.value:type_name -> lf.a2a.v1.SecurityScheme + 19, // 63: lf.a2a.v1.SecurityRequirement.SchemesEntry.value:type_name -> lf.a2a.v1.StringList + 33, // 64: lf.a2a.v1.A2AService.SendMessage:input_type -> lf.a2a.v1.SendMessageRequest + 33, // 65: lf.a2a.v1.A2AService.SendStreamingMessage:input_type -> lf.a2a.v1.SendMessageRequest + 34, // 66: lf.a2a.v1.A2AService.GetTask:input_type -> lf.a2a.v1.GetTaskRequest + 35, // 67: lf.a2a.v1.A2AService.ListTasks:input_type -> lf.a2a.v1.ListTasksRequest + 37, // 68: lf.a2a.v1.A2AService.CancelTask:input_type -> lf.a2a.v1.CancelTaskRequest + 40, // 69: lf.a2a.v1.A2AService.SubscribeToTask:input_type -> lf.a2a.v1.SubscribeToTaskRequest + 18, // 70: lf.a2a.v1.A2AService.CreateTaskPushNotificationConfig:input_type -> lf.a2a.v1.TaskPushNotificationConfig + 38, // 71: lf.a2a.v1.A2AService.GetTaskPushNotificationConfig:input_type -> lf.a2a.v1.GetTaskPushNotificationConfigRequest + 41, // 72: lf.a2a.v1.A2AService.ListTaskPushNotificationConfigs:input_type -> lf.a2a.v1.ListTaskPushNotificationConfigsRequest + 42, // 73: lf.a2a.v1.A2AService.GetExtendedAgentCard:input_type -> lf.a2a.v1.GetExtendedAgentCardRequest + 39, // 74: lf.a2a.v1.A2AService.DeleteTaskPushNotificationConfig:input_type -> lf.a2a.v1.DeleteTaskPushNotificationConfigRequest + 43, // 75: lf.a2a.v1.A2AService.SendMessage:output_type -> lf.a2a.v1.SendMessageResponse + 44, // 76: lf.a2a.v1.A2AService.SendStreamingMessage:output_type -> lf.a2a.v1.StreamResponse + 3, // 77: lf.a2a.v1.A2AService.GetTask:output_type -> lf.a2a.v1.Task + 36, // 78: lf.a2a.v1.A2AService.ListTasks:output_type -> lf.a2a.v1.ListTasksResponse + 3, // 79: lf.a2a.v1.A2AService.CancelTask:output_type -> lf.a2a.v1.Task + 44, // 80: lf.a2a.v1.A2AService.SubscribeToTask:output_type -> lf.a2a.v1.StreamResponse + 18, // 81: lf.a2a.v1.A2AService.CreateTaskPushNotificationConfig:output_type -> lf.a2a.v1.TaskPushNotificationConfig + 18, // 82: lf.a2a.v1.A2AService.GetTaskPushNotificationConfig:output_type -> lf.a2a.v1.TaskPushNotificationConfig + 45, // 83: lf.a2a.v1.A2AService.ListTaskPushNotificationConfigs:output_type -> lf.a2a.v1.ListTaskPushNotificationConfigsResponse + 12, // 84: lf.a2a.v1.A2AService.GetExtendedAgentCard:output_type -> lf.a2a.v1.AgentCard + 56, // 85: lf.a2a.v1.A2AService.DeleteTaskPushNotificationConfig:output_type -> google.protobuf.Empty + 75, // [75:86] is the sub-list for method output_type + 64, // [64:75] is the sub-list for method input_type + 64, // [64:64] is the sub-list for extension type_name + 64, // [64:64] is the sub-list for extension extendee + 0, // [0:64] is the sub-list for field type_name +} + +func init() { file_a2a_proto_init() } +func file_a2a_proto_init() { + if File_a2a_proto != nil { + return + } + if !protoimpl.UnsafeEnabled { + file_a2a_proto_msgTypes[0].Exporter = func(v any, i int) any { + switch v := v.(*SendMessageConfiguration); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[1].Exporter = func(v any, i int) any { + switch v := v.(*Task); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[2].Exporter = func(v any, i int) any { + switch v := v.(*TaskStatus); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[3].Exporter = func(v any, i int) any { + switch v := v.(*Part); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[4].Exporter = func(v any, i int) any { + switch v := v.(*Message); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[5].Exporter = func(v any, i int) any { + switch v := v.(*Artifact); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[6].Exporter = func(v any, i int) any { + switch v := v.(*TaskStatusUpdateEvent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[7].Exporter = func(v any, i int) any { + switch v := v.(*TaskArtifactUpdateEvent); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[8].Exporter = func(v any, i int) any { + switch v := v.(*AuthenticationInfo); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[9].Exporter = func(v any, i int) any { + switch v := v.(*AgentInterface); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[10].Exporter = func(v any, i int) any { + switch v := v.(*AgentCard); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[11].Exporter = func(v any, i int) any { + switch v := v.(*AgentProvider); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[12].Exporter = func(v any, i int) any { + switch v := v.(*AgentCapabilities); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[13].Exporter = func(v any, i int) any { + switch v := v.(*AgentExtension); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[14].Exporter = func(v any, i int) any { + switch v := v.(*AgentSkill); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[15].Exporter = func(v any, i int) any { + switch v := v.(*AgentCardSignature); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[16].Exporter = func(v any, i int) any { + switch v := v.(*TaskPushNotificationConfig); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[17].Exporter = func(v any, i int) any { + switch v := v.(*StringList); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[18].Exporter = func(v any, i int) any { + switch v := v.(*SecurityRequirement); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[19].Exporter = func(v any, i int) any { + switch v := v.(*SecurityScheme); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[20].Exporter = func(v any, i int) any { + switch v := v.(*APIKeySecurityScheme); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[21].Exporter = func(v any, i int) any { + switch v := v.(*HTTPAuthSecurityScheme); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[22].Exporter = func(v any, i int) any { + switch v := v.(*OAuth2SecurityScheme); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[23].Exporter = func(v any, i int) any { + switch v := v.(*OpenIdConnectSecurityScheme); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[24].Exporter = func(v any, i int) any { + switch v := v.(*MutualTlsSecurityScheme); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[25].Exporter = func(v any, i int) any { + switch v := v.(*OAuthFlows); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[26].Exporter = func(v any, i int) any { + switch v := v.(*AuthorizationCodeOAuthFlow); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[27].Exporter = func(v any, i int) any { + switch v := v.(*ClientCredentialsOAuthFlow); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[28].Exporter = func(v any, i int) any { + switch v := v.(*ImplicitOAuthFlow); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[29].Exporter = func(v any, i int) any { + switch v := v.(*PasswordOAuthFlow); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[30].Exporter = func(v any, i int) any { + switch v := v.(*DeviceCodeOAuthFlow); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[31].Exporter = func(v any, i int) any { + switch v := v.(*SendMessageRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[32].Exporter = func(v any, i int) any { + switch v := v.(*GetTaskRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[33].Exporter = func(v any, i int) any { + switch v := v.(*ListTasksRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[34].Exporter = func(v any, i int) any { + switch v := v.(*ListTasksResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[35].Exporter = func(v any, i int) any { + switch v := v.(*CancelTaskRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[36].Exporter = func(v any, i int) any { + switch v := v.(*GetTaskPushNotificationConfigRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[37].Exporter = func(v any, i int) any { + switch v := v.(*DeleteTaskPushNotificationConfigRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[38].Exporter = func(v any, i int) any { + switch v := v.(*SubscribeToTaskRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[39].Exporter = func(v any, i int) any { + switch v := v.(*ListTaskPushNotificationConfigsRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[40].Exporter = func(v any, i int) any { + switch v := v.(*GetExtendedAgentCardRequest); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[41].Exporter = func(v any, i int) any { + switch v := v.(*SendMessageResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[42].Exporter = func(v any, i int) any { + switch v := v.(*StreamResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + file_a2a_proto_msgTypes[43].Exporter = func(v any, i int) any { + switch v := v.(*ListTaskPushNotificationConfigsResponse); i { + case 0: + return &v.state + case 1: + return &v.sizeCache + case 2: + return &v.unknownFields + default: + return nil + } + } + } + file_a2a_proto_msgTypes[0].OneofWrappers = []any{} + file_a2a_proto_msgTypes[3].OneofWrappers = []any{ + (*Part_Text)(nil), + (*Part_Raw)(nil), + (*Part_Url)(nil), + (*Part_Data)(nil), + } + file_a2a_proto_msgTypes[10].OneofWrappers = []any{} + file_a2a_proto_msgTypes[12].OneofWrappers = []any{} + file_a2a_proto_msgTypes[19].OneofWrappers = []any{ + (*SecurityScheme_ApiKeySecurityScheme)(nil), + (*SecurityScheme_HttpAuthSecurityScheme)(nil), + (*SecurityScheme_Oauth2SecurityScheme)(nil), + (*SecurityScheme_OpenIdConnectSecurityScheme)(nil), + (*SecurityScheme_MtlsSecurityScheme)(nil), + } + file_a2a_proto_msgTypes[25].OneofWrappers = []any{ + (*OAuthFlows_AuthorizationCode)(nil), + (*OAuthFlows_ClientCredentials)(nil), + (*OAuthFlows_Implicit)(nil), + (*OAuthFlows_Password)(nil), + (*OAuthFlows_DeviceCode)(nil), + } + file_a2a_proto_msgTypes[32].OneofWrappers = []any{} + file_a2a_proto_msgTypes[33].OneofWrappers = []any{} + file_a2a_proto_msgTypes[41].OneofWrappers = []any{ + (*SendMessageResponse_Task)(nil), + (*SendMessageResponse_Message)(nil), + } + file_a2a_proto_msgTypes[42].OneofWrappers = []any{ + (*StreamResponse_Task)(nil), + (*StreamResponse_Message)(nil), + (*StreamResponse_StatusUpdate)(nil), + (*StreamResponse_ArtifactUpdate)(nil), + } + type x struct{} + out := protoimpl.TypeBuilder{ + File: protoimpl.DescBuilder{ + GoPackagePath: reflect.TypeOf(x{}).PkgPath(), + RawDescriptor: file_a2a_proto_rawDesc, + NumEnums: 2, + NumMessages: 51, + NumExtensions: 0, + NumServices: 1, + }, + GoTypes: file_a2a_proto_goTypes, + DependencyIndexes: file_a2a_proto_depIdxs, + EnumInfos: file_a2a_proto_enumTypes, + MessageInfos: file_a2a_proto_msgTypes, + }.Build() + File_a2a_proto = out.File + file_a2a_proto_rawDesc = nil + file_a2a_proto_goTypes = nil + file_a2a_proto_depIdxs = nil +} diff --git a/a2aremote/grpcbind/a2apb/a2a.proto b/a2aremote/grpcbind/a2apb/a2a.proto new file mode 100644 index 0000000..2814f0f --- /dev/null +++ b/a2aremote/grpcbind/a2apb/a2a.proto @@ -0,0 +1,812 @@ +// Older protoc compilers don't understand edition yet. +syntax = "proto3"; +package lf.a2a.v1; + +import "google/api/annotations.proto"; +import "google/api/client.proto"; +import "google/api/field_behavior.proto"; +import "google/protobuf/empty.proto"; +import "google/protobuf/struct.proto"; +import "google/protobuf/timestamp.proto"; + +option csharp_namespace = "Lf.A2a.V1"; +option go_package = "google.golang.org/lf/a2a/v1"; +option java_multiple_files = true; +option java_outer_classname = "A2A"; +option java_package = "com.google.lf.a2a.v1"; + +// Provides operations for interacting with agents using the A2A protocol. +service A2AService { + // Sends a message to an agent. + rpc SendMessage(SendMessageRequest) returns (SendMessageResponse) { + option (google.api.http) = { + post: "/message:send" + body: "*" + additional_bindings: { + post: "/{tenant}/message:send" + body: "*" + } + }; + } + // Sends a streaming message to an agent, allowing for real-time interaction and status updates. + // Streaming version of `SendMessage` + rpc SendStreamingMessage(SendMessageRequest) returns (stream StreamResponse) { + option (google.api.http) = { + post: "/message:stream" + body: "*" + additional_bindings: { + post: "/{tenant}/message:stream" + body: "*" + } + }; + } + + // Gets the latest state of a task. + rpc GetTask(GetTaskRequest) returns (Task) { + option (google.api.http) = { + get: "/tasks/{id=*}" + additional_bindings: { + get: "/{tenant}/tasks/{id=*}" + } + }; + option (google.api.method_signature) = "id"; + } + // Lists tasks that match the specified filter. + rpc ListTasks(ListTasksRequest) returns (ListTasksResponse) { + option (google.api.http) = { + get: "/tasks" + additional_bindings: { + get: "/{tenant}/tasks" + } + }; + } + // Cancels a task in progress. + rpc CancelTask(CancelTaskRequest) returns (Task) { + option (google.api.http) = { + post: "/tasks/{id=*}:cancel" + body: "*" + additional_bindings: { + post: "/{tenant}/tasks/{id=*}:cancel" + body: "*" + } + }; + } + // Subscribes to task updates for tasks not in a terminal state. + // Returns `UnsupportedOperationError` if the task is already in a terminal state (completed, failed, canceled, rejected). + rpc SubscribeToTask(SubscribeToTaskRequest) returns (stream StreamResponse) { + option (google.api.http) = { + get: "/tasks/{id=*}:subscribe" + additional_bindings: { + get: "/{tenant}/tasks/{id=*}:subscribe" + } + }; + } + + // (-- api-linter: client-libraries::4232::required-fields=disabled + // api-linter: core::0133::method-signature=disabled + // api-linter: core::0133::request-message-name=disabled + // aip.dev/not-precedent: method_signature preserved for backwards compatibility --) + // Creates a push notification config for a task. + rpc CreateTaskPushNotificationConfig(TaskPushNotificationConfig) returns (TaskPushNotificationConfig) { + option (google.api.http) = { + post: "/tasks/{task_id=*}/pushNotificationConfigs" + body: "*" + additional_bindings: { + post: "/{tenant}/tasks/{task_id=*}/pushNotificationConfigs" + body: "*" + } + }; + option (google.api.method_signature) = "task_id,config"; + } + // Gets a push notification config for a task. + rpc GetTaskPushNotificationConfig(GetTaskPushNotificationConfigRequest) returns (TaskPushNotificationConfig) { + option (google.api.http) = { + get: "/tasks/{task_id=*}/pushNotificationConfigs/{id=*}" + additional_bindings: { + get: "/{tenant}/tasks/{task_id=*}/pushNotificationConfigs/{id=*}" + } + }; + option (google.api.method_signature) = "task_id,id"; + } + // Get a list of push notifications configured for a task. + rpc ListTaskPushNotificationConfigs(ListTaskPushNotificationConfigsRequest) returns (ListTaskPushNotificationConfigsResponse) { + option (google.api.http) = { + get: "/tasks/{task_id=*}/pushNotificationConfigs" + additional_bindings: { + get: "/{tenant}/tasks/{task_id=*}/pushNotificationConfigs" + } + }; + option (google.api.method_signature) = "task_id"; + } + // Gets the extended agent card for the authenticated agent. + rpc GetExtendedAgentCard(GetExtendedAgentCardRequest) returns (AgentCard) { + option (google.api.http) = { + get: "/extendedAgentCard" + additional_bindings: { + get: "/{tenant}/extendedAgentCard" + } + }; + } + // Deletes a push notification config for a task. + rpc DeleteTaskPushNotificationConfig(DeleteTaskPushNotificationConfigRequest) returns (google.protobuf.Empty) { + option (google.api.http) = { + delete: "/tasks/{task_id=*}/pushNotificationConfigs/{id=*}" + additional_bindings: { + delete: "/{tenant}/tasks/{task_id=*}/pushNotificationConfigs/{id=*}" + } + }; + option (google.api.method_signature) = "task_id,id"; + } +} + +// Configuration of a send message request. +message SendMessageConfiguration { + // A list of media types the client is prepared to accept for response parts. + // Agents SHOULD use this to tailor their output. + repeated string accepted_output_modes = 1; + // Configuration for the agent to send push notifications for task updates. + // Task id should be empty when sending this configuration in a `SendMessage` request. + TaskPushNotificationConfig task_push_notification_config = 2; + // The maximum number of most recent messages from the task's history to retrieve in + // the response. An unset value means the client does not impose any limit. A + // value of zero is a request to not include any messages. The server MUST NOT + // return more messages than the provided value, but MAY apply a lower limit. + optional int32 history_length = 3; + // If `true`, the operation returns immediately after creating the task, + // even if processing is still in progress. + // If `false` (default), the operation MUST wait until the task reaches a + // terminal (`COMPLETED`, `FAILED`, `CANCELED`, `REJECTED`) or interrupted + // (`INPUT_REQUIRED`, `AUTH_REQUIRED`) state before returning. + bool return_immediately = 4; +} + +// `Task` is the core unit of action for A2A. It has a current status +// and when results are created for the task they are stored in the +// artifact. If there are multiple turns for a task, these are stored in +// history. +message Task { + // Unique identifier (e.g. UUID) for the task, generated by the server for a + // new task. + string id = 1 [(google.api.field_behavior) = REQUIRED]; + // Unique identifier (e.g. UUID) for the contextual collection of interactions + // (tasks and messages). + string context_id = 2; + // The current status of a `Task`, including `state` and a `message`. + TaskStatus status = 3 [(google.api.field_behavior) = REQUIRED]; + // A set of output artifacts for a `Task`. + repeated Artifact artifacts = 4; + // protolint:disable REPEATED_FIELD_NAMES_PLURALIZED + // The history of interactions from a `Task`. + repeated Message history = 5; + // protolint:enable REPEATED_FIELD_NAMES_PLURALIZED + // A key/value object to store custom metadata about a task. + google.protobuf.Struct metadata = 6; +} + +// Defines the possible lifecycle states of a `Task`. +enum TaskState { + // The task is in an unknown or indeterminate state. + TASK_STATE_UNSPECIFIED = 0; + // Indicates that a task has been successfully submitted and acknowledged. + TASK_STATE_SUBMITTED = 1; + // Indicates that a task is actively being processed by the agent. + TASK_STATE_WORKING = 2; + // Indicates that a task has finished successfully. This is a terminal state. + TASK_STATE_COMPLETED = 3; + // Indicates that a task has finished with an error. This is a terminal state. + TASK_STATE_FAILED = 4; + // Indicates that a task was canceled before completion. This is a terminal state. + TASK_STATE_CANCELED = 5; + // Indicates that the agent requires additional user input to proceed. This is an interrupted state. + TASK_STATE_INPUT_REQUIRED = 6; + // Indicates that the agent has decided to not perform the task. + // This may be done during initial task creation or later once an agent + // has determined it can't or won't proceed. This is a terminal state. + TASK_STATE_REJECTED = 7; + // Indicates that authentication is required to proceed. This is an interrupted state. + TASK_STATE_AUTH_REQUIRED = 8; +} + +// A container for the status of a task +message TaskStatus { + // The current state of this task. + TaskState state = 1 [(google.api.field_behavior) = REQUIRED]; + // A message associated with the status. + Message message = 2; + // ISO 8601 Timestamp when the status was recorded. + // Example: "2023-10-27T10:00:00Z" + google.protobuf.Timestamp timestamp = 3; +} + +// `Part` represents a container for a section of communication content. +// Parts can be purely textual, some sort of file (image, video, etc) or +// a structured data blob (i.e. JSON). +message Part { + oneof content { + // The string content of the `text` part. + string text = 1; + // The `raw` byte content of a file. In JSON serialization, this is encoded as a base64 string. + bytes raw = 2; + // A `url` pointing to the file's content. + string url = 3; + // Arbitrary structured `data` as a JSON value (object, array, string, number, boolean, or null). + google.protobuf.Value data = 4; + } + // Optional. metadata associated with this part. + google.protobuf.Struct metadata = 5; + // An optional `filename` for the file (e.g., "document.pdf"). + string filename = 6; + // The `media_type` (MIME type) of the part content (e.g., "text/plain", "application/json", "image/png"). + // This field is available for all part types. + string media_type = 7; +} + +// Defines the sender of a message in A2A protocol communication. +enum Role { + // The role is unspecified. + ROLE_UNSPECIFIED = 0; + // The message is from the client to the server. + ROLE_USER = 1; + // The message is from the server to the client. + ROLE_AGENT = 2; +} + +// `Message` is one unit of communication between client and server. It can be +// associated with a context and/or a task. For server messages, `context_id` must +// be provided, and `task_id` only if a task was created. For client messages, both +// fields are optional, with the caveat that if both are provided, they have to +// match (the `context_id` has to be the one that is set on the task). If only +// `task_id` is provided, the server will infer `context_id` from it. +message Message { + // The unique identifier (e.g. UUID) of the message. This is created by the message creator. + string message_id = 1 [(google.api.field_behavior) = REQUIRED]; + // Optional. The context id of the message. If set, the message will be associated with the given context. + string context_id = 2; + // Optional. The task id of the message. If set, the message will be associated with the given task. + string task_id = 3; + // Identifies the sender of the message. + Role role = 4 [(google.api.field_behavior) = REQUIRED]; + // Parts is the container of the message content. + repeated Part parts = 5 [(google.api.field_behavior) = REQUIRED]; + // Optional. Any metadata to provide along with the message. + google.protobuf.Struct metadata = 6; + // The URIs of extensions that are present or contributed to this Message. + repeated string extensions = 7; + // A list of task IDs that this message references for additional context. + repeated string reference_task_ids = 8; +} + +// Artifacts represent task outputs. +message Artifact { + // Unique identifier (e.g. UUID) for the artifact. It must be unique within a task. + string artifact_id = 1 [(google.api.field_behavior) = REQUIRED]; + // A human readable name for the artifact. + string name = 2; + // Optional. A human readable description of the artifact. + string description = 3; + // The content of the artifact. Must contain at least one part. + repeated Part parts = 4 [(google.api.field_behavior) = REQUIRED]; + // Optional. Metadata included with the artifact. + google.protobuf.Struct metadata = 5; + // The URIs of extensions that are present or contributed to this Artifact. + repeated string extensions = 6; +} + +// An event sent by the agent to notify the client of a change in a task's status. +message TaskStatusUpdateEvent { + // The ID of the task that has changed. + string task_id = 1 [(google.api.field_behavior) = REQUIRED]; + // The ID of the context that the task belongs to. + string context_id = 2 [(google.api.field_behavior) = REQUIRED]; + // The new status of the task. + TaskStatus status = 3 [(google.api.field_behavior) = REQUIRED]; + // Optional. Metadata associated with the task update. + google.protobuf.Struct metadata = 4; +} + +// A task delta where an artifact has been generated. +message TaskArtifactUpdateEvent { + // The ID of the task for this artifact. + string task_id = 1 [(google.api.field_behavior) = REQUIRED]; + // The ID of the context that this task belongs to. + string context_id = 2 [(google.api.field_behavior) = REQUIRED]; + // The artifact that was generated or updated. + Artifact artifact = 3 [(google.api.field_behavior) = REQUIRED]; + // If true, the content of this artifact should be appended to a previously + // sent artifact with the same ID. + bool append = 4; + // If true, this is the final chunk of the artifact. + bool last_chunk = 5; + // Optional. Metadata associated with the artifact update. + google.protobuf.Struct metadata = 6; +} + +// Defines authentication details, used for push notifications. +message AuthenticationInfo { + // HTTP Authentication Scheme from the [IANA registry](https://www.iana.org/assignments/http-authschemes/). + // Examples: `Bearer`, `Basic`, `Digest`. + // Scheme names are case-insensitive per [RFC 9110 Section 11.1](https://www.rfc-editor.org/rfc/rfc9110#section-11.1). + string scheme = 1 [(google.api.field_behavior) = REQUIRED]; + // Push Notification credentials. Format depends on the scheme (e.g., token for Bearer). + string credentials = 2; +} + +// Declares a combination of a target URL, transport and protocol version for interacting with the agent. +// This allows agents to expose the same functionality over multiple protocol binding mechanisms. +message AgentInterface { + // The URL or address where this interface is available. For HTTP-based transports, must be a valid absolute + // HTTPS URL in production. For gRPC, the address should be in the format "hostname:port". + // Example: "https://api.example.com/a2a/v1", "grpc.example.com:443" + string url = 1 [(google.api.field_behavior) = REQUIRED]; + // The protocol binding supported at this URL. This is an open form string, to be + // easily extended for other protocol bindings. The core ones officially + // supported are `JSONRPC`, `GRPC` and `HTTP+JSON`. + string protocol_binding = 2 [(google.api.field_behavior) = REQUIRED]; + // Optional. An opaque string used for routing requests to a specific agent + // or tenant when multiple agents are served behind a single A2A endpoint. + // When set, clients MUST include this value in the `tenant` field of all + // request messages sent to this interface. The server is responsible for + // interpreting the value and routing requests accordingly; the protocol + // does not define its format or semantics. + string tenant = 3; + // The version of the A2A protocol this interface exposes. + // Use the latest supported minor version per major version. + // Examples: "0.3", "1.0" + string protocol_version = 4 [(google.api.field_behavior) = REQUIRED]; +} + +// A self-describing manifest for an agent. It provides essential +// metadata including the agent's identity, capabilities, skills, supported +// communication methods, and security requirements. +// Next ID: 20 +message AgentCard { + // A human readable name for the agent. + // Example: "Recipe Agent" + string name = 1 [(google.api.field_behavior) = REQUIRED]; + // A human-readable description of the agent, assisting users and other agents + // in understanding its purpose. + // Example: "Agent that helps users with recipes and cooking." + string description = 2 [(google.api.field_behavior) = REQUIRED]; + // Ordered list of supported interfaces. The first entry is preferred. + repeated AgentInterface supported_interfaces = 3 [(google.api.field_behavior) = REQUIRED]; + // The service provider of the agent. + AgentProvider provider = 4; + // The version of the agent. + // Example: "1.0.0" + string version = 5 [(google.api.field_behavior) = REQUIRED]; + // A URL providing additional documentation about the agent. + optional string documentation_url = 6; + // A2A Capability set supported by the agent. + AgentCapabilities capabilities = 7 [(google.api.field_behavior) = REQUIRED]; + // The security scheme details used for authenticating with this agent. + map security_schemes = 8; + // Security requirements for contacting the agent. + repeated SecurityRequirement security_requirements = 9; + // protolint:enable REPEATED_FIELD_NAMES_PLURALIZED + // The set of interaction modes that the agent supports across all skills. + // This can be overridden per skill. Defined as media types. + repeated string default_input_modes = 10 [(google.api.field_behavior) = REQUIRED]; + // The media types supported as outputs from this agent. + repeated string default_output_modes = 11 [(google.api.field_behavior) = REQUIRED]; + // Skills represent the abilities of an agent. + // It is largely a descriptive concept but represents a more focused set of behaviors that the + // agent is likely to succeed at. + repeated AgentSkill skills = 12 [(google.api.field_behavior) = REQUIRED]; + // JSON Web Signatures computed for this `AgentCard`. + repeated AgentCardSignature signatures = 13; + // Optional. A URL to an icon for the agent. + optional string icon_url = 14; +} + +// Represents the service provider of an agent. +message AgentProvider { + // A URL for the agent provider's website or relevant documentation. + // Example: "https://ai.google.dev" + string url = 1 [(google.api.field_behavior) = REQUIRED]; + // The name of the agent provider's organization. + // Example: "Google" + string organization = 2 [(google.api.field_behavior) = REQUIRED]; +} + +// Defines optional capabilities supported by an agent. +message AgentCapabilities { + // Indicates if the agent supports streaming responses. + optional bool streaming = 1; + // Indicates if the agent supports sending push notifications for asynchronous task updates. + optional bool push_notifications = 2; + // A list of protocol extensions supported by the agent. + repeated AgentExtension extensions = 3; + // Indicates if the agent supports providing an extended agent card when authenticated. + optional bool extended_agent_card = 4; +} + +// A declaration of a protocol extension supported by an Agent. +message AgentExtension { + // The unique URI identifying the extension. + string uri = 1; + // A human-readable description of how this agent uses the extension. + string description = 2; + // If true, the client must understand and comply with the extension's requirements. + bool required = 3; + // Optional. Extension-specific configuration parameters. + google.protobuf.Struct params = 4; +} + +// Represents a distinct capability or function that an agent can perform. +message AgentSkill { + // A unique identifier for the agent's skill. + string id = 1 [(google.api.field_behavior) = REQUIRED]; + // A human-readable name for the skill. + string name = 2 [(google.api.field_behavior) = REQUIRED]; + // A detailed description of the skill. + string description = 3 [(google.api.field_behavior) = REQUIRED]; + // A set of keywords describing the skill's capabilities. + repeated string tags = 4 [(google.api.field_behavior) = REQUIRED]; + // Example prompts or scenarios that this skill can handle. + repeated string examples = 5; + // The set of supported input media types for this skill, overriding the agent's defaults. + repeated string input_modes = 6; + // The set of supported output media types for this skill, overriding the agent's defaults. + repeated string output_modes = 7; + // Security schemes necessary for this skill. + repeated SecurityRequirement security_requirements = 8; +} + +// AgentCardSignature represents a JWS signature of an AgentCard. +// This follows the JSON format of an RFC 7515 JSON Web Signature (JWS). +message AgentCardSignature { + // (-- api-linter: core::0140::reserved-words=disabled + // aip.dev/not-precedent: Backwards compatibility --) + // Required. The protected JWS header for the signature. This is always a + // base64url-encoded JSON object. + string protected = 1 [(google.api.field_behavior) = REQUIRED]; + // Required. The computed signature, base64url-encoded. + string signature = 2 [(google.api.field_behavior) = REQUIRED]; + // The unprotected JWS header values. + google.protobuf.Struct header = 3; +} + +// A container associating a push notification configuration with a specific task. +message TaskPushNotificationConfig { + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + string tenant = 1; + // The push notification configuration details. + // A unique identifier (e.g. UUID) for this push notification configuration. + string id = 2; + // The ID of the task this configuration is associated with. + string task_id = 3; + // The URL where the notification should be sent. + string url = 4 [(google.api.field_behavior) = REQUIRED]; + // A token unique for this task or session. + string token = 5; + // Authentication information required to send the notification. + AuthenticationInfo authentication = 6; +} + +// protolint:disable REPEATED_FIELD_NAMES_PLURALIZED +// A list of strings. +message StringList { + // The individual string values. + repeated string list = 1; +} +// protolint:enable REPEATED_FIELD_NAMES_PLURALIZED + +// Defines the security requirements for an agent. +message SecurityRequirement { + // A map of security schemes to the required scopes. + map schemes = 1; +} + +// Defines a security scheme that can be used to secure an agent's endpoints. +// This is a discriminated union type based on the OpenAPI 3.2 Security Scheme Object. +// See: https://spec.openapis.org/oas/v3.2.0.html#security-scheme-object +message SecurityScheme { + oneof scheme { + // API key-based authentication. + APIKeySecurityScheme api_key_security_scheme = 1; + // HTTP authentication (Basic, Bearer, etc.). + HTTPAuthSecurityScheme http_auth_security_scheme = 2; + // OAuth 2.0 authentication. + OAuth2SecurityScheme oauth2_security_scheme = 3; + // OpenID Connect authentication. + OpenIdConnectSecurityScheme open_id_connect_security_scheme = 4; + // Mutual TLS authentication. + MutualTlsSecurityScheme mtls_security_scheme = 5; + } +} + +// Defines a security scheme using an API key. +message APIKeySecurityScheme { + // An optional description for the security scheme. + string description = 1; + // The location of the API key. Valid values are "query", "header", or "cookie". + string location = 2 [(google.api.field_behavior) = REQUIRED]; + // The name of the header, query, or cookie parameter to be used. + string name = 3 [(google.api.field_behavior) = REQUIRED]; +} + +// Defines a security scheme using HTTP authentication. +message HTTPAuthSecurityScheme { + // An optional description for the security scheme. + string description = 1; + // The name of the HTTP Authentication scheme to be used in the Authorization header, + // as defined in RFC7235 (e.g., "Bearer"). + // This value should be registered in the IANA Authentication Scheme registry. + string scheme = 2 [(google.api.field_behavior) = REQUIRED]; + // A hint to the client to identify how the bearer token is formatted (e.g., "JWT"). + // Primarily for documentation purposes. + string bearer_format = 3; +} + +// Defines a security scheme using OAuth 2.0. +message OAuth2SecurityScheme { + // An optional description for the security scheme. + string description = 1; + // An object containing configuration information for the supported OAuth 2.0 flows. + OAuthFlows flows = 2 [(google.api.field_behavior) = REQUIRED]; + // URL to the OAuth2 authorization server metadata [RFC 8414](https://datatracker.ietf.org/doc/html/rfc8414). + // TLS is required. + string oauth2_metadata_url = 3; +} + +// Defines a security scheme using OpenID Connect. +message OpenIdConnectSecurityScheme { + // An optional description for the security scheme. + string description = 1; + // The [OpenID Connect Discovery URL](https://openid.net/specs/openid-connect-discovery-1_0.html) for the OIDC provider's metadata. + string open_id_connect_url = 2 [(google.api.field_behavior) = REQUIRED]; +} + +// Defines a security scheme using mTLS authentication. +message MutualTlsSecurityScheme { + // An optional description for the security scheme. + string description = 1; +} + +// Defines the configuration for the supported OAuth 2.0 flows. +message OAuthFlows { + oneof flow { + // Configuration for the OAuth Authorization Code flow. + AuthorizationCodeOAuthFlow authorization_code = 1; + // Configuration for the OAuth Client Credentials flow. + ClientCredentialsOAuthFlow client_credentials = 2; + // Deprecated: Use Authorization Code + PKCE instead. + ImplicitOAuthFlow implicit = 3 [deprecated = true]; + // Deprecated: Use Authorization Code + PKCE or Device Code. + PasswordOAuthFlow password = 4 [deprecated = true]; + // Configuration for the OAuth Device Code flow. + DeviceCodeOAuthFlow device_code = 5; + } +} + +// Defines configuration details for the OAuth 2.0 Authorization Code flow. +message AuthorizationCodeOAuthFlow { + // The authorization URL to be used for this flow. + string authorization_url = 1 [(google.api.field_behavior) = REQUIRED]; + // The token URL to be used for this flow. + string token_url = 2 [(google.api.field_behavior) = REQUIRED]; + // The URL to be used for obtaining refresh tokens. + string refresh_url = 3; + // The available scopes for the OAuth2 security scheme. + map scopes = 4 [(google.api.field_behavior) = REQUIRED]; + // Indicates if PKCE (RFC 7636) is required for this flow. + // PKCE should always be used for public clients and is recommended for all clients. + bool pkce_required = 5; +} + +// Defines configuration details for the OAuth 2.0 Client Credentials flow. +message ClientCredentialsOAuthFlow { + // The token URL to be used for this flow. + string token_url = 1 [(google.api.field_behavior) = REQUIRED]; + // The URL to be used for obtaining refresh tokens. + string refresh_url = 2; + // The available scopes for the OAuth2 security scheme. + map scopes = 3 [(google.api.field_behavior) = REQUIRED]; +} + +// Deprecated: Use Authorization Code + PKCE instead. +message ImplicitOAuthFlow { + // The authorization URL to be used for this flow. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS + string authorization_url = 1; + // The URL to be used for obtaining refresh tokens. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS. + string refresh_url = 2; + // The available scopes for the OAuth2 security scheme. A map between the + // scope name and a short description for it. The map MAY be empty. + map scopes = 3; +} + +// Deprecated: Use Authorization Code + PKCE or Device Code. +message PasswordOAuthFlow { + // The token URL to be used for this flow. This MUST be in the form of a URL. + // The OAuth2 standard requires the use of TLS. + string token_url = 1; + // The URL to be used for obtaining refresh tokens. This MUST be in the + // form of a URL. The OAuth2 standard requires the use of TLS. + string refresh_url = 2; + // The available scopes for the OAuth2 security scheme. A map between the + // scope name and a short description for it. The map MAY be empty. + map scopes = 3; +} + +// Defines configuration details for the OAuth 2.0 Device Code flow (RFC 8628). +// This flow is designed for input-constrained devices such as IoT devices, +// and CLI tools where the user authenticates on a separate device. +message DeviceCodeOAuthFlow { + // The device authorization endpoint URL. + string device_authorization_url = 1 [(google.api.field_behavior) = REQUIRED]; + // The token URL to be used for this flow. + string token_url = 2 [(google.api.field_behavior) = REQUIRED]; + // The URL to be used for obtaining refresh tokens. + string refresh_url = 3; + // The available scopes for the OAuth2 security scheme. + map scopes = 4 [(google.api.field_behavior) = REQUIRED]; +} + +// Represents a request for the `SendMessage` method. +message SendMessageRequest { + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + string tenant = 1; + // The message to send to the agent. + Message message = 2 [(google.api.field_behavior) = REQUIRED]; + // Configuration for the send request. + SendMessageConfiguration configuration = 3; + // A flexible key-value map for passing additional context or parameters. + google.protobuf.Struct metadata = 4; +} + +// Represents a request for the `GetTask` method. +message GetTaskRequest { + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + string tenant = 1; + // The resource ID of the task to retrieve. + string id = 2 [(google.api.field_behavior) = REQUIRED]; + // The maximum number of most recent messages from the task's history to retrieve. An + // unset value means the client does not impose any limit. A value of zero is + // a request to not include any messages. The server MUST NOT return more + // messages than the provided value, but MAY apply a lower limit. + optional int32 history_length = 3; +} + +// Parameters for listing tasks with optional filtering criteria. +message ListTasksRequest { + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + string tenant = 1; + // Filter tasks by context ID to get tasks from a specific conversation or session. + string context_id = 2; + // Filter tasks by their current status state. + TaskState status = 3; + // The maximum number of tasks to return. The service may return fewer than this value. + // If unspecified, at most 50 tasks will be returned. + // The minimum value is 1. + // The maximum value is 100. + optional int32 page_size = 4; + // A page token, received from a previous `ListTasks` call. + // `ListTasksResponse.next_page_token`. + // Provide this to retrieve the subsequent page. + string page_token = 5; + // The maximum number of messages to include in each task's history. + optional int32 history_length = 6; + // Filter tasks which have a status updated after the provided timestamp in ISO 8601 format (e.g., "2023-10-27T10:00:00Z"). + // Only tasks with a status timestamp time greater than or equal to this value will be returned. + google.protobuf.Timestamp status_timestamp_after = 7; + // Whether to include artifacts in the returned tasks. + // Defaults to false to reduce payload size. + optional bool include_artifacts = 8; +} + +// Result object for `ListTasks` method containing an array of tasks and pagination information. +message ListTasksResponse { + // Array of tasks matching the specified criteria. + repeated Task tasks = 1 [(google.api.field_behavior) = REQUIRED]; + // A token to retrieve the next page of results, or empty if there are no more results in the list. + string next_page_token = 2 [(google.api.field_behavior) = REQUIRED]; + // The page size used for this response. + int32 page_size = 3 [(google.api.field_behavior) = REQUIRED]; + // Total number of tasks available (before pagination). + int32 total_size = 4 [(google.api.field_behavior) = REQUIRED]; +} + +// Represents a request for the `CancelTask` method. +message CancelTaskRequest { + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + string tenant = 1; + // The resource ID of the task to cancel. + string id = 2 [(google.api.field_behavior) = REQUIRED]; + // A flexible key-value map for passing additional context or parameters. + google.protobuf.Struct metadata = 3; +} + +// Represents a request for the `GetTaskPushNotificationConfig` method. +message GetTaskPushNotificationConfigRequest { + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + string tenant = 1; + // The parent task resource ID. + string task_id = 2 [(google.api.field_behavior) = REQUIRED]; + // The resource ID of the configuration to retrieve. + string id = 3 [(google.api.field_behavior) = REQUIRED]; +} + +// Represents a request for the `DeleteTaskPushNotificationConfig` method. +message DeleteTaskPushNotificationConfigRequest { + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + string tenant = 1; + // The parent task resource ID. + string task_id = 2 [(google.api.field_behavior) = REQUIRED]; + // The resource ID of the configuration to delete. + string id = 3 [(google.api.field_behavior) = REQUIRED]; +} + +// Represents a request for the `SubscribeToTask` method. +message SubscribeToTaskRequest { + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + string tenant = 1; + // The resource ID of the task to subscribe to. + string id = 2 [(google.api.field_behavior) = REQUIRED]; +} + +// Represents a request for the `ListTaskPushNotificationConfigs` method. +message ListTaskPushNotificationConfigsRequest { + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + string tenant = 4; + // The parent task resource ID. + string task_id = 1 [(google.api.field_behavior) = REQUIRED]; + + // The maximum number of configurations to return. + int32 page_size = 2; + + // A page token received from a previous `ListTaskPushNotificationConfigsRequest` call. + string page_token = 3; +} + +// Represents a request for the `GetExtendedAgentCard` method. +message GetExtendedAgentCardRequest { + // Optional. Opaque routing identifier. Must match the `tenant` value from + // the selected `AgentInterface` in the Agent Card when that field is set. + string tenant = 1; +} + +// Represents the response for the `SendMessage` method. +message SendMessageResponse { + // The payload of the response. + oneof payload { + // The task created or updated by the message. + Task task = 1; + // A message from the agent. + Message message = 2; + } +} + +// A wrapper object used in streaming operations to encapsulate different types of response data. +message StreamResponse { + // The payload of the stream response. + oneof payload { + // A Task object containing the current state of the task. + Task task = 1; + // A Message object containing a message from the agent. + Message message = 2; + // An event indicating a task status update. + TaskStatusUpdateEvent status_update = 3; + // An event indicating a task artifact update. + TaskArtifactUpdateEvent artifact_update = 4; + } +} + +// Represents a successful response for the `ListTaskPushNotificationConfigs` +// method. +message ListTaskPushNotificationConfigsResponse { + // The list of push notification configurations. + repeated TaskPushNotificationConfig configs = 1; + // A token to retrieve the next page of results, or empty if there are no more results in the list. + string next_page_token = 2; +} diff --git a/a2aremote/grpcbind/a2apb/a2a_grpc.pb.go b/a2aremote/grpcbind/a2apb/a2a_grpc.pb.go new file mode 100644 index 0000000..95a4bd3 --- /dev/null +++ b/a2aremote/grpcbind/a2apb/a2a_grpc.pb.go @@ -0,0 +1,559 @@ +// Code generated by protoc-gen-go-grpc. DO NOT EDIT. +// versions: +// - protoc-gen-go-grpc v1.2.0 +// - protoc v7.35.0 +// source: a2a.proto + +package a2apb + +import ( + context "context" + grpc "google.golang.org/grpc" + codes "google.golang.org/grpc/codes" + status "google.golang.org/grpc/status" + emptypb "google.golang.org/protobuf/types/known/emptypb" +) + +// This is a compile-time assertion to ensure that this generated file +// is compatible with the grpc package it is being compiled against. +// Requires gRPC-Go v1.32.0 or later. +const _ = grpc.SupportPackageIsVersion7 + +// A2AServiceClient is the client API for A2AService service. +// +// For semantics around ctx use and closing/ending streaming RPCs, please refer to https://pkg.go.dev/google.golang.org/grpc/?tab=doc#ClientConn.NewStream. +type A2AServiceClient interface { + // Sends a message to an agent. + SendMessage(ctx context.Context, in *SendMessageRequest, opts ...grpc.CallOption) (*SendMessageResponse, error) + // Sends a streaming message to an agent, allowing for real-time interaction and status updates. + // Streaming version of `SendMessage` + SendStreamingMessage(ctx context.Context, in *SendMessageRequest, opts ...grpc.CallOption) (A2AService_SendStreamingMessageClient, error) + // Gets the latest state of a task. + GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*Task, error) + // Lists tasks that match the specified filter. + ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*ListTasksResponse, error) + // Cancels a task in progress. + CancelTask(ctx context.Context, in *CancelTaskRequest, opts ...grpc.CallOption) (*Task, error) + // Subscribes to task updates for tasks not in a terminal state. + // Returns `UnsupportedOperationError` if the task is already in a terminal state (completed, failed, canceled, rejected). + SubscribeToTask(ctx context.Context, in *SubscribeToTaskRequest, opts ...grpc.CallOption) (A2AService_SubscribeToTaskClient, error) + // (-- api-linter: client-libraries::4232::required-fields=disabled + // + // api-linter: core::0133::method-signature=disabled + // api-linter: core::0133::request-message-name=disabled + // aip.dev/not-precedent: method_signature preserved for backwards compatibility --) + // + // Creates a push notification config for a task. + CreateTaskPushNotificationConfig(ctx context.Context, in *TaskPushNotificationConfig, opts ...grpc.CallOption) (*TaskPushNotificationConfig, error) + // Gets a push notification config for a task. + GetTaskPushNotificationConfig(ctx context.Context, in *GetTaskPushNotificationConfigRequest, opts ...grpc.CallOption) (*TaskPushNotificationConfig, error) + // Get a list of push notifications configured for a task. + ListTaskPushNotificationConfigs(ctx context.Context, in *ListTaskPushNotificationConfigsRequest, opts ...grpc.CallOption) (*ListTaskPushNotificationConfigsResponse, error) + // Gets the extended agent card for the authenticated agent. + GetExtendedAgentCard(ctx context.Context, in *GetExtendedAgentCardRequest, opts ...grpc.CallOption) (*AgentCard, error) + // Deletes a push notification config for a task. + DeleteTaskPushNotificationConfig(ctx context.Context, in *DeleteTaskPushNotificationConfigRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) +} + +type a2AServiceClient struct { + cc grpc.ClientConnInterface +} + +func NewA2AServiceClient(cc grpc.ClientConnInterface) A2AServiceClient { + return &a2AServiceClient{cc} +} + +func (c *a2AServiceClient) SendMessage(ctx context.Context, in *SendMessageRequest, opts ...grpc.CallOption) (*SendMessageResponse, error) { + out := new(SendMessageResponse) + err := c.cc.Invoke(ctx, "/lf.a2a.v1.A2AService/SendMessage", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *a2AServiceClient) SendStreamingMessage(ctx context.Context, in *SendMessageRequest, opts ...grpc.CallOption) (A2AService_SendStreamingMessageClient, error) { + stream, err := c.cc.NewStream(ctx, &A2AService_ServiceDesc.Streams[0], "/lf.a2a.v1.A2AService/SendStreamingMessage", opts...) + if err != nil { + return nil, err + } + x := &a2AServiceSendStreamingMessageClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type A2AService_SendStreamingMessageClient interface { + Recv() (*StreamResponse, error) + grpc.ClientStream +} + +type a2AServiceSendStreamingMessageClient struct { + grpc.ClientStream +} + +func (x *a2AServiceSendStreamingMessageClient) Recv() (*StreamResponse, error) { + m := new(StreamResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +func (c *a2AServiceClient) GetTask(ctx context.Context, in *GetTaskRequest, opts ...grpc.CallOption) (*Task, error) { + out := new(Task) + err := c.cc.Invoke(ctx, "/lf.a2a.v1.A2AService/GetTask", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *a2AServiceClient) ListTasks(ctx context.Context, in *ListTasksRequest, opts ...grpc.CallOption) (*ListTasksResponse, error) { + out := new(ListTasksResponse) + err := c.cc.Invoke(ctx, "/lf.a2a.v1.A2AService/ListTasks", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *a2AServiceClient) CancelTask(ctx context.Context, in *CancelTaskRequest, opts ...grpc.CallOption) (*Task, error) { + out := new(Task) + err := c.cc.Invoke(ctx, "/lf.a2a.v1.A2AService/CancelTask", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *a2AServiceClient) SubscribeToTask(ctx context.Context, in *SubscribeToTaskRequest, opts ...grpc.CallOption) (A2AService_SubscribeToTaskClient, error) { + stream, err := c.cc.NewStream(ctx, &A2AService_ServiceDesc.Streams[1], "/lf.a2a.v1.A2AService/SubscribeToTask", opts...) + if err != nil { + return nil, err + } + x := &a2AServiceSubscribeToTaskClient{stream} + if err := x.ClientStream.SendMsg(in); err != nil { + return nil, err + } + if err := x.ClientStream.CloseSend(); err != nil { + return nil, err + } + return x, nil +} + +type A2AService_SubscribeToTaskClient interface { + Recv() (*StreamResponse, error) + grpc.ClientStream +} + +type a2AServiceSubscribeToTaskClient struct { + grpc.ClientStream +} + +func (x *a2AServiceSubscribeToTaskClient) Recv() (*StreamResponse, error) { + m := new(StreamResponse) + if err := x.ClientStream.RecvMsg(m); err != nil { + return nil, err + } + return m, nil +} + +func (c *a2AServiceClient) CreateTaskPushNotificationConfig(ctx context.Context, in *TaskPushNotificationConfig, opts ...grpc.CallOption) (*TaskPushNotificationConfig, error) { + out := new(TaskPushNotificationConfig) + err := c.cc.Invoke(ctx, "/lf.a2a.v1.A2AService/CreateTaskPushNotificationConfig", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *a2AServiceClient) GetTaskPushNotificationConfig(ctx context.Context, in *GetTaskPushNotificationConfigRequest, opts ...grpc.CallOption) (*TaskPushNotificationConfig, error) { + out := new(TaskPushNotificationConfig) + err := c.cc.Invoke(ctx, "/lf.a2a.v1.A2AService/GetTaskPushNotificationConfig", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *a2AServiceClient) ListTaskPushNotificationConfigs(ctx context.Context, in *ListTaskPushNotificationConfigsRequest, opts ...grpc.CallOption) (*ListTaskPushNotificationConfigsResponse, error) { + out := new(ListTaskPushNotificationConfigsResponse) + err := c.cc.Invoke(ctx, "/lf.a2a.v1.A2AService/ListTaskPushNotificationConfigs", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *a2AServiceClient) GetExtendedAgentCard(ctx context.Context, in *GetExtendedAgentCardRequest, opts ...grpc.CallOption) (*AgentCard, error) { + out := new(AgentCard) + err := c.cc.Invoke(ctx, "/lf.a2a.v1.A2AService/GetExtendedAgentCard", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +func (c *a2AServiceClient) DeleteTaskPushNotificationConfig(ctx context.Context, in *DeleteTaskPushNotificationConfigRequest, opts ...grpc.CallOption) (*emptypb.Empty, error) { + out := new(emptypb.Empty) + err := c.cc.Invoke(ctx, "/lf.a2a.v1.A2AService/DeleteTaskPushNotificationConfig", in, out, opts...) + if err != nil { + return nil, err + } + return out, nil +} + +// A2AServiceServer is the server API for A2AService service. +// All implementations must embed UnimplementedA2AServiceServer +// for forward compatibility +type A2AServiceServer interface { + // Sends a message to an agent. + SendMessage(context.Context, *SendMessageRequest) (*SendMessageResponse, error) + // Sends a streaming message to an agent, allowing for real-time interaction and status updates. + // Streaming version of `SendMessage` + SendStreamingMessage(*SendMessageRequest, A2AService_SendStreamingMessageServer) error + // Gets the latest state of a task. + GetTask(context.Context, *GetTaskRequest) (*Task, error) + // Lists tasks that match the specified filter. + ListTasks(context.Context, *ListTasksRequest) (*ListTasksResponse, error) + // Cancels a task in progress. + CancelTask(context.Context, *CancelTaskRequest) (*Task, error) + // Subscribes to task updates for tasks not in a terminal state. + // Returns `UnsupportedOperationError` if the task is already in a terminal state (completed, failed, canceled, rejected). + SubscribeToTask(*SubscribeToTaskRequest, A2AService_SubscribeToTaskServer) error + // (-- api-linter: client-libraries::4232::required-fields=disabled + // + // api-linter: core::0133::method-signature=disabled + // api-linter: core::0133::request-message-name=disabled + // aip.dev/not-precedent: method_signature preserved for backwards compatibility --) + // + // Creates a push notification config for a task. + CreateTaskPushNotificationConfig(context.Context, *TaskPushNotificationConfig) (*TaskPushNotificationConfig, error) + // Gets a push notification config for a task. + GetTaskPushNotificationConfig(context.Context, *GetTaskPushNotificationConfigRequest) (*TaskPushNotificationConfig, error) + // Get a list of push notifications configured for a task. + ListTaskPushNotificationConfigs(context.Context, *ListTaskPushNotificationConfigsRequest) (*ListTaskPushNotificationConfigsResponse, error) + // Gets the extended agent card for the authenticated agent. + GetExtendedAgentCard(context.Context, *GetExtendedAgentCardRequest) (*AgentCard, error) + // Deletes a push notification config for a task. + DeleteTaskPushNotificationConfig(context.Context, *DeleteTaskPushNotificationConfigRequest) (*emptypb.Empty, error) + mustEmbedUnimplementedA2AServiceServer() +} + +// UnimplementedA2AServiceServer must be embedded to have forward compatible implementations. +type UnimplementedA2AServiceServer struct { +} + +func (UnimplementedA2AServiceServer) SendMessage(context.Context, *SendMessageRequest) (*SendMessageResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method SendMessage not implemented") +} +func (UnimplementedA2AServiceServer) SendStreamingMessage(*SendMessageRequest, A2AService_SendStreamingMessageServer) error { + return status.Errorf(codes.Unimplemented, "method SendStreamingMessage not implemented") +} +func (UnimplementedA2AServiceServer) GetTask(context.Context, *GetTaskRequest) (*Task, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetTask not implemented") +} +func (UnimplementedA2AServiceServer) ListTasks(context.Context, *ListTasksRequest) (*ListTasksResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListTasks not implemented") +} +func (UnimplementedA2AServiceServer) CancelTask(context.Context, *CancelTaskRequest) (*Task, error) { + return nil, status.Errorf(codes.Unimplemented, "method CancelTask not implemented") +} +func (UnimplementedA2AServiceServer) SubscribeToTask(*SubscribeToTaskRequest, A2AService_SubscribeToTaskServer) error { + return status.Errorf(codes.Unimplemented, "method SubscribeToTask not implemented") +} +func (UnimplementedA2AServiceServer) CreateTaskPushNotificationConfig(context.Context, *TaskPushNotificationConfig) (*TaskPushNotificationConfig, error) { + return nil, status.Errorf(codes.Unimplemented, "method CreateTaskPushNotificationConfig not implemented") +} +func (UnimplementedA2AServiceServer) GetTaskPushNotificationConfig(context.Context, *GetTaskPushNotificationConfigRequest) (*TaskPushNotificationConfig, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetTaskPushNotificationConfig not implemented") +} +func (UnimplementedA2AServiceServer) ListTaskPushNotificationConfigs(context.Context, *ListTaskPushNotificationConfigsRequest) (*ListTaskPushNotificationConfigsResponse, error) { + return nil, status.Errorf(codes.Unimplemented, "method ListTaskPushNotificationConfigs not implemented") +} +func (UnimplementedA2AServiceServer) GetExtendedAgentCard(context.Context, *GetExtendedAgentCardRequest) (*AgentCard, error) { + return nil, status.Errorf(codes.Unimplemented, "method GetExtendedAgentCard not implemented") +} +func (UnimplementedA2AServiceServer) DeleteTaskPushNotificationConfig(context.Context, *DeleteTaskPushNotificationConfigRequest) (*emptypb.Empty, error) { + return nil, status.Errorf(codes.Unimplemented, "method DeleteTaskPushNotificationConfig not implemented") +} +func (UnimplementedA2AServiceServer) mustEmbedUnimplementedA2AServiceServer() {} + +// UnsafeA2AServiceServer may be embedded to opt out of forward compatibility for this service. +// Use of this interface is not recommended, as added methods to A2AServiceServer will +// result in compilation errors. +type UnsafeA2AServiceServer interface { + mustEmbedUnimplementedA2AServiceServer() +} + +func RegisterA2AServiceServer(s grpc.ServiceRegistrar, srv A2AServiceServer) { + s.RegisterService(&A2AService_ServiceDesc, srv) +} + +func _A2AService_SendMessage_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(SendMessageRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(A2AServiceServer).SendMessage(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lf.a2a.v1.A2AService/SendMessage", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(A2AServiceServer).SendMessage(ctx, req.(*SendMessageRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _A2AService_SendStreamingMessage_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SendMessageRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(A2AServiceServer).SendStreamingMessage(m, &a2AServiceSendStreamingMessageServer{stream}) +} + +type A2AService_SendStreamingMessageServer interface { + Send(*StreamResponse) error + grpc.ServerStream +} + +type a2AServiceSendStreamingMessageServer struct { + grpc.ServerStream +} + +func (x *a2AServiceSendStreamingMessageServer) Send(m *StreamResponse) error { + return x.ServerStream.SendMsg(m) +} + +func _A2AService_GetTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTaskRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(A2AServiceServer).GetTask(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lf.a2a.v1.A2AService/GetTask", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(A2AServiceServer).GetTask(ctx, req.(*GetTaskRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _A2AService_ListTasks_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTasksRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(A2AServiceServer).ListTasks(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lf.a2a.v1.A2AService/ListTasks", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(A2AServiceServer).ListTasks(ctx, req.(*ListTasksRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _A2AService_CancelTask_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(CancelTaskRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(A2AServiceServer).CancelTask(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lf.a2a.v1.A2AService/CancelTask", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(A2AServiceServer).CancelTask(ctx, req.(*CancelTaskRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _A2AService_SubscribeToTask_Handler(srv interface{}, stream grpc.ServerStream) error { + m := new(SubscribeToTaskRequest) + if err := stream.RecvMsg(m); err != nil { + return err + } + return srv.(A2AServiceServer).SubscribeToTask(m, &a2AServiceSubscribeToTaskServer{stream}) +} + +type A2AService_SubscribeToTaskServer interface { + Send(*StreamResponse) error + grpc.ServerStream +} + +type a2AServiceSubscribeToTaskServer struct { + grpc.ServerStream +} + +func (x *a2AServiceSubscribeToTaskServer) Send(m *StreamResponse) error { + return x.ServerStream.SendMsg(m) +} + +func _A2AService_CreateTaskPushNotificationConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(TaskPushNotificationConfig) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(A2AServiceServer).CreateTaskPushNotificationConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lf.a2a.v1.A2AService/CreateTaskPushNotificationConfig", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(A2AServiceServer).CreateTaskPushNotificationConfig(ctx, req.(*TaskPushNotificationConfig)) + } + return interceptor(ctx, in, info, handler) +} + +func _A2AService_GetTaskPushNotificationConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetTaskPushNotificationConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(A2AServiceServer).GetTaskPushNotificationConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lf.a2a.v1.A2AService/GetTaskPushNotificationConfig", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(A2AServiceServer).GetTaskPushNotificationConfig(ctx, req.(*GetTaskPushNotificationConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _A2AService_ListTaskPushNotificationConfigs_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(ListTaskPushNotificationConfigsRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(A2AServiceServer).ListTaskPushNotificationConfigs(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lf.a2a.v1.A2AService/ListTaskPushNotificationConfigs", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(A2AServiceServer).ListTaskPushNotificationConfigs(ctx, req.(*ListTaskPushNotificationConfigsRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _A2AService_GetExtendedAgentCard_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(GetExtendedAgentCardRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(A2AServiceServer).GetExtendedAgentCard(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lf.a2a.v1.A2AService/GetExtendedAgentCard", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(A2AServiceServer).GetExtendedAgentCard(ctx, req.(*GetExtendedAgentCardRequest)) + } + return interceptor(ctx, in, info, handler) +} + +func _A2AService_DeleteTaskPushNotificationConfig_Handler(srv interface{}, ctx context.Context, dec func(interface{}) error, interceptor grpc.UnaryServerInterceptor) (interface{}, error) { + in := new(DeleteTaskPushNotificationConfigRequest) + if err := dec(in); err != nil { + return nil, err + } + if interceptor == nil { + return srv.(A2AServiceServer).DeleteTaskPushNotificationConfig(ctx, in) + } + info := &grpc.UnaryServerInfo{ + Server: srv, + FullMethod: "/lf.a2a.v1.A2AService/DeleteTaskPushNotificationConfig", + } + handler := func(ctx context.Context, req interface{}) (interface{}, error) { + return srv.(A2AServiceServer).DeleteTaskPushNotificationConfig(ctx, req.(*DeleteTaskPushNotificationConfigRequest)) + } + return interceptor(ctx, in, info, handler) +} + +// A2AService_ServiceDesc is the grpc.ServiceDesc for A2AService service. +// It's only intended for direct use with grpc.RegisterService, +// and not to be introspected or modified (even as a copy) +var A2AService_ServiceDesc = grpc.ServiceDesc{ + ServiceName: "lf.a2a.v1.A2AService", + HandlerType: (*A2AServiceServer)(nil), + Methods: []grpc.MethodDesc{ + { + MethodName: "SendMessage", + Handler: _A2AService_SendMessage_Handler, + }, + { + MethodName: "GetTask", + Handler: _A2AService_GetTask_Handler, + }, + { + MethodName: "ListTasks", + Handler: _A2AService_ListTasks_Handler, + }, + { + MethodName: "CancelTask", + Handler: _A2AService_CancelTask_Handler, + }, + { + MethodName: "CreateTaskPushNotificationConfig", + Handler: _A2AService_CreateTaskPushNotificationConfig_Handler, + }, + { + MethodName: "GetTaskPushNotificationConfig", + Handler: _A2AService_GetTaskPushNotificationConfig_Handler, + }, + { + MethodName: "ListTaskPushNotificationConfigs", + Handler: _A2AService_ListTaskPushNotificationConfigs_Handler, + }, + { + MethodName: "GetExtendedAgentCard", + Handler: _A2AService_GetExtendedAgentCard_Handler, + }, + { + MethodName: "DeleteTaskPushNotificationConfig", + Handler: _A2AService_DeleteTaskPushNotificationConfig_Handler, + }, + }, + Streams: []grpc.StreamDesc{ + { + StreamName: "SendStreamingMessage", + Handler: _A2AService_SendStreamingMessage_Handler, + ServerStreams: true, + }, + { + StreamName: "SubscribeToTask", + Handler: _A2AService_SubscribeToTask_Handler, + ServerStreams: true, + }, + }, + Metadata: "a2a.proto", +} diff --git a/a2aremote/grpcbind/a2apb/generate.sh b/a2aremote/grpcbind/a2apb/generate.sh new file mode 100755 index 0000000..aaf147a --- /dev/null +++ b/a2aremote/grpcbind/a2apb/generate.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash +# Regenerates the A2A protobuf bindings from the normative proto. +# +# a2a.proto is vendored verbatim from github.com/a2aproject/A2A at +# specification/a2a.proto. It is the canonical data model the three +# protocol bindings are derived from, so hand-editing either it or the +# generated files means cortex stops speaking the protocol it claims to. +# +# The googleapis imports are fetched rather than vendored, because they +# are large, stable, and not ours. +set -euo pipefail + +cd "$(dirname "$0")" +work="$(mktemp -d)" +trap 'rm -rf "$work"' EXIT + +cp a2a.proto "$work/" +mkdir -p "$work/google/api" +for f in annotations http client field_behavior launch_stage; do + gh api "repos/googleapis/googleapis/contents/google/api/$f.proto" --jq '.content' \ + | base64 -d > "$work/google/api/$f.proto" +done + +protoc -I"$work" \ + --go_out="$work" --go_opt=Ma2a.proto=github.com/xraph/cortex/a2aremote/grpcbind/a2apb \ + --go-grpc_out="$work" --go-grpc_opt=Ma2a.proto=github.com/xraph/cortex/a2aremote/grpcbind/a2apb \ + "$work/a2a.proto" + +cp "$work"/github.com/xraph/cortex/a2aremote/grpcbind/a2apb/*.pb.go . + +# protoc-gen-go names the package from the proto's own package (v1), +# which is not the directory it lands in. Renaming it is the one edit +# these generated files get. +sed -i '' 's/^package v1$/package a2apb/' *.pb.go +echo "regenerated $(ls *.pb.go | tr '\n' ' ')" diff --git a/a2aremote/grpcbind/go.mod b/a2aremote/grpcbind/go.mod new file mode 100644 index 0000000..b3a2eda --- /dev/null +++ b/a2aremote/grpcbind/go.mod @@ -0,0 +1,28 @@ +module github.com/xraph/cortex/a2aremote/grpcbind + +go 1.26.0 + +replace github.com/xraph/cortex => ../../ + +replace github.com/xraph/cortex/a2aremote => ../ + +require ( + github.com/xraph/cortex v0.0.0-00010101000000-000000000000 + github.com/xraph/cortex/a2aremote v0.0.0-00010101000000-000000000000 + google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 + google.golang.org/grpc v1.71.0 + google.golang.org/protobuf v1.36.6 +) + +require ( + github.com/gofrs/uuid/v5 v5.3.2 // indirect + github.com/google/go-cmp v0.7.0 // indirect + github.com/xraph/go-utils v1.1.8 // indirect + go.jetify.com/typeid/v2 v2.0.0-alpha.3 // indirect + go.uber.org/multierr v1.11.0 // indirect + go.uber.org/zap v1.28.0 // indirect + golang.org/x/net v0.57.0 // indirect + golang.org/x/sys v0.47.0 // indirect + golang.org/x/text v0.41.0 // indirect + google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f // indirect +) diff --git a/a2aremote/grpcbind/go.sum b/a2aremote/grpcbind/go.sum new file mode 100644 index 0000000..996e27a --- /dev/null +++ b/a2aremote/grpcbind/go.sum @@ -0,0 +1,84 @@ +github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs= +github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc h1:U9qPSI2PIWSS1VwoXQT9A3Wy9MM3WgvqSxFWenqJduM= +github.com/davecgh/go-spew v1.1.2-0.20180830191138-d8f796af33cc/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY= +github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto= +github.com/go-logr/logr v1.4.4 h1:tG4xh9yMsRCAiodLVTxyrkzSZ9+o0L1Kg/+cPVcbP/8= +github.com/go-logr/logr v1.4.4/go.mod h1:9T104GzyrTigFIr8wt5mBrctHMim0Nb2HLGrmQ40KvY= +github.com/go-logr/stdr v1.2.2 h1:hSWxHoqTgW2S2qGc0LTAI563KZ5YKYRhT3MFKZMbjag= +github.com/go-logr/stdr v1.2.2/go.mod h1:mMo/vtBO5dYbehREoey6XUKy/eSumjCCveDpRre4VKE= +github.com/gofrs/uuid/v5 v5.3.2 h1:2jfO8j3XgSwlz/wHqemAEugfnTlikAYHhnqQ8Xh4fE0= +github.com/gofrs/uuid/v5 v5.3.2/go.mod h1:CDOjlDMVAtN56jqyRUZh58JT31Tiw7/oQyEXZV+9bD8= +github.com/golang/protobuf v1.5.4 h1:i7eJL8qZTpSEXOPTxNKhASYpMn+8e5Q6AdndVa1dWek= +github.com/golang/protobuf v1.5.4/go.mod h1:lnTiLA8Wa4RWRcIUkrtSVa5nRhsEGBg48fD6rSs7xps= +github.com/google/go-cmp v0.7.0 h1:wk8382ETsv4JYUZwIsn6YpYiWiBsYLSJiTsyBybVuN8= +github.com/google/go-cmp v0.7.0/go.mod h1:pXiqmnSA92OHEEa9HXL2W4E7lf9JzCmGVUdgjX3N/iU= +github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0= +github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo= +github.com/mattn/go-isatty v0.0.24 h1:tGZZoVgT/KiqK1c8ocVLeDS8BSWMRd47J3Lbz7vsReI= +github.com/mattn/go-isatty v0.0.24/go.mod h1:nMCL3Zebbrt45jsMDgnfIwz6ydEQApk5oEI3HqDio6A= +github.com/ncruces/go-strftime v1.0.0 h1:HMFp8mLCTPp341M/ZnA4qaf7ZlsbTc+miZjCLOFAw7w= +github.com/ncruces/go-strftime v1.0.0/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2 h1:Jamvg5psRIccs7FGNTlIRMkT8wgtp5eCXdBlqhYGL6U= +github.com/pmezard/go-difflib v1.0.1-0.20181226105442-5d4384ee4fb2/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE= +github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +github.com/xraph/go-utils v1.1.8 h1:O8+Vie/u/ntn2cEbvh47jJLzQ6S7qwxhYwgRm2SL1sw= +github.com/xraph/go-utils v1.1.8/go.mod h1:Mckdi+nR0bI4bUESKSYajJq4tNSPsvZiuLRYJ0+qDQw= +github.com/xraph/grove v1.6.2 h1:O/3UyHTKQQ57CyZiLkDQi5T7xyzMSBz320VlK3C04Vo= +github.com/xraph/grove v1.6.2/go.mod h1:bgjHNhnmyfEyzbdpcppRt+Zf24nNcbGKlo450Mi4giI= +github.com/xraph/grove/drivers/sqlitedriver v1.6.2 h1:+s2mbOPufStYaK1bHHnWRBeX0ENeisvJ7ZU3Ip46wVA= +github.com/xraph/grove/drivers/sqlitedriver v1.6.2/go.mod h1:xzHewWROOPVn0Luu8/sWEAhSgmDnw3xmNrRw0xcefnM= +go.jetify.com/typeid/v2 v2.0.0-alpha.3 h1:T6RPx6bNl10lp0JN2Xz/XcgLZWSlVmL58Xqy9cgTCcc= +go.jetify.com/typeid/v2 v2.0.0-alpha.3/go.mod h1:zfD1ZDHDJNgXZANsO9jDOD81XRRQ0zAOnDBEHmIV/Gw= +go.opentelemetry.io/auto/sdk v1.2.1 h1:jXsnJ4Lmnqd11kwkBV2LgLoFMZKizbCi5fNZ/ipaZ64= +go.opentelemetry.io/auto/sdk v1.2.1/go.mod h1:KRTj+aOaElaLi+wW1kO/DZRXwkF4C5xPbEe3ZiIhN7Y= +go.opentelemetry.io/otel v1.44.0 h1:JjwHmHpA4iZ3wBxluu2fbbE7j4kqlE8jXyAyPXH7HqU= +go.opentelemetry.io/otel v1.44.0/go.mod h1:BMgjTHL9WPRlRjL2oZCBTL4whCGtXch2H4BhOPIAyYc= +go.opentelemetry.io/otel/metric v1.44.0 h1:1w0gILTcHdr3YI+ixLyjemwrVnsMURbTZFrSYCdDdmc= +go.opentelemetry.io/otel/metric v1.44.0/go.mod h1:8O7hanEPBNgEMmybD3s2VBKcgWOCsA6tzHBPODAiquo= +go.opentelemetry.io/otel/sdk v1.34.0 h1:95zS4k/2GOy069d321O8jWgYsW3MzVV+KuSPKp7Wr1A= +go.opentelemetry.io/otel/sdk v1.34.0/go.mod h1:0e/pNiaMAqaykJGKbi+tSjWfNNHMTxoC9qANsCzbyxU= +go.opentelemetry.io/otel/sdk/metric v1.34.0 h1:5CeK9ujjbFVL5c1PhLuStg1wxA7vQv7ce1EK0Gyvahk= +go.opentelemetry.io/otel/sdk/metric v1.34.0/go.mod h1:jQ/r8Ze28zRKoNRdkjCZxfs6YvBTG1+YIqyFVFYec5w= +go.opentelemetry.io/otel/trace v1.44.0 h1:jxF5CsGYCe74MCRx2X4g7WsY/VBKRqqpNvXlX/6gtIk= +go.opentelemetry.io/otel/trace v1.44.0/go.mod h1:oLl1jrMQAVo6v3GAggN+1VH9VIz9iUSvW53sW1Q8PIE= +go.uber.org/goleak v1.3.0 h1:2K3zAYmnTNqV73imy9J1T3WC+gmCePx2hEGkimedGto= +go.uber.org/goleak v1.3.0/go.mod h1:CoHD4mav9JJNrW/WLlf7HGZPjdw8EucARQHekz1X6bE= +go.uber.org/multierr v1.11.0 h1:blXXJkSxSSfBVBlC76pxqeO+LN3aDfLQo+309xJstO0= +go.uber.org/multierr v1.11.0/go.mod h1:20+QtiLqy0Nd6FdQB9TLXag12DsQkrbs3htMFfDN80Y= +go.uber.org/zap v1.28.0 h1:IZzaP1Fv73/T/pBMLk4VutPl36uNC+OSUh3JLG3FIjo= +go.uber.org/zap v1.28.0/go.mod h1:rDLpOi171uODNm/mxFcuYWxDsqWSAVkFdX4XojSKg/Q= +go.yaml.in/yaml/v3 v3.0.4 h1:tfq32ie2Jv2UxXFdLJdh3jXuOzWiL1fo0bu/FbuKpbc= +go.yaml.in/yaml/v3 v3.0.4/go.mod h1:DhzuOOF2ATzADvBadXxruRBLzYTpT36CKvDb3+aBEFg= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743 h1:ex206bKw+v3K0dm3andkrIF+ijyQKJG1pLgwQ2PYdQM= +golang.org/x/exp v0.0.0-20260727155853-b88d891fe743/go.mod h1:EdfpwwqSu+0Li0mzskwHU6FWDV3t9Q+RZDo3QMUtL3Q= +golang.org/x/net v0.57.0 h1:K5+3DljvIuDG9/Jv9rvyMywYNFCQ9RSUY6OOTTkT+tE= +golang.org/x/net v0.57.0/go.mod h1:KpXc8iv+r3XplLAG/f7Jsf9RPszJzdR0f58q9vGOuEU= +golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs= +golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/text v0.41.0 h1:vz/seA0lnX87Othu2f/0L24RcgrXD9/YFTSuGjj3rH8= +golang.org/x/text v0.41.0/go.mod h1:jvf1O8ajNzZqhSrQBPbutR/EB83Cc0CFrezNQIwbb5M= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422 h1:GVIKPyP/kLIyVOgOnTwFOrvQaQUzOzGMCxgFUOEmm24= +google.golang.org/genproto/googleapis/api v0.0.0-20250106144421-5f5ef82da422/go.mod h1:b6h1vNKhxaSoEI+5jc3PJUCustfli/mRab7295pY7rw= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f h1:OxYkA3wjPsZyBylwymxSHa7ViiW1Sml4ToBrncvFehI= +google.golang.org/genproto/googleapis/rpc v0.0.0-20250115164207-1a7da9e5054f/go.mod h1:+2Yz8+CLJbIfL9z73EW45avw8Lmge3xVElCP9zEKi50= +google.golang.org/grpc v1.71.0 h1:kF77BGdPTQ4/JZWMlb9VpJ5pa25aqvVqogsxNHHdeBg= +google.golang.org/grpc v1.71.0/go.mod h1:H0GRtasmQOh9LkFoCPDu3ZrwUtD1YGE+b2vYBYd/8Ec= +google.golang.org/protobuf v1.36.6 h1:z1NpPI8ku2WgiWnf+t9wTPsn6eP1L7ksHUlkfLvd9xY= +google.golang.org/protobuf v1.36.6/go.mod h1:jduwjTPXsFjZGTmRluh+L6NjiWu7pchiJ2/5YcXBHnY= +gopkg.in/yaml.v2 v2.4.0 h1:D8xgwECY7CYvx+Y2n4sBz93Jn9JRvxdiyyo8CTfuKaY= +gopkg.in/yaml.v2 v2.4.0/go.mod h1:RDklbk79AGWmwhnvt/jBztapEOGDOx6ZbXqjP6csGnQ= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= +modernc.org/libc v1.68.0 h1:PJ5ikFOV5pwpW+VqCK1hKJuEWsonkIJhhIXyuF/91pQ= +modernc.org/libc v1.68.0/go.mod h1:NnKCYeoYgsEqnY3PgvNgAeaJnso968ygU8Z0DxjoEc0= +modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU= +modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg= +modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI= +modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw= +modernc.org/sqlite v1.46.1 h1:eFJ2ShBLIEnUWlLy12raN0Z1plqmFX9Qe3rjQTKt6sU= +modernc.org/sqlite v1.46.1/go.mod h1:CzbrU2lSB1DKUusvwGz7rqEKIq+NUd8GWuBBZDs9/nA= diff --git a/a2aremote/grpcbind/server.go b/a2aremote/grpcbind/server.go new file mode 100644 index 0000000..4c57b38 --- /dev/null +++ b/a2aremote/grpcbind/server.go @@ -0,0 +1,266 @@ +// Package grpcbind serves the A2A gRPC binding over the same service the +// other two bindings use. +// +// It is a module of its own rather than a package inside a2aremote, and +// the reason is dependency weight: gRPC and protobuf are a large graph, +// and a host serving JSON-RPC or HTTP+JSON has no business inheriting +// them. Importing this module is the opt-in. +// +// The generated types in a2apb come from the normative a2a.proto, +// vendored verbatim. Everything here is translation between those types +// and the ones a2aremote already defines; not one decision about what an +// operation means lives on this side of the boundary. +package grpcbind + +import ( + "context" + "errors" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/peer" + "google.golang.org/grpc/status" + "google.golang.org/protobuf/types/known/timestamppb" + + "github.com/xraph/cortex/a2aremote" + "github.com/xraph/cortex/a2aremote/grpcbind/a2apb" +) + +// Server adapts a2aremote.Service to the generated gRPC service. +type Server struct { + a2apb.UnimplementedA2AServiceServer + svc *a2aremote.Service +} + +// NewServer wraps a service for gRPC. +func NewServer(svc *a2aremote.Service) *Server { return &Server{svc: svc} } + +// Register adds the service to a gRPC server. +func Register(s grpc.ServiceRegistrar, svc *a2aremote.Service) { + a2apb.RegisterA2AServiceServer(s, NewServer(svc)) +} + +// SendMessage carries an inbound message to the agent named by tenant. +func (s *Server) SendMessage(ctx context.Context, req *a2apb.SendMessageRequest) (*a2apb.SendMessageResponse, error) { + result, err := s.svc.SendMessage(ctx, credentialsOf(ctx), a2aremote.SendMessageRequest{ + Tenant: req.GetTenant(), + Message: messageFromProto(req.GetMessage()), + }) + if err != nil { + return nil, statusOf(err) + } + + out := &a2apb.SendMessageResponse{} + switch { + case result.Task != nil: + out.Payload = &a2apb.SendMessageResponse_Task{Task: taskToProto(result.Task)} + case result.Message != nil: + out.Payload = &a2apb.SendMessageResponse_Message{Message: messageToProto(result.Message)} + } + return out, nil +} + +// GetTask projects a run as a task. +func (s *Server) GetTask(ctx context.Context, req *a2apb.GetTaskRequest) (*a2apb.Task, error) { + task, err := s.svc.GetTask(ctx, credentialsOf(ctx), a2aremote.GetTaskRequest{ + Tenant: req.GetTenant(), ID: req.GetId(), + }) + if err != nil { + return nil, statusOf(err) + } + return taskToProto(task), nil +} + +// ListTasks projects this scope's runs as tasks. +func (s *Server) ListTasks(ctx context.Context, req *a2apb.ListTasksRequest) (*a2apb.ListTasksResponse, error) { + result, err := s.svc.ListTasks(ctx, credentialsOf(ctx), a2aremote.ListTasksRequest{ + Tenant: req.GetTenant(), PageSize: int(req.GetPageSize()), + }) + if err != nil { + return nil, statusOf(err) + } + out := &a2apb.ListTasksResponse{Tasks: make([]*a2apb.Task, 0, len(result.Tasks))} + for i := range result.Tasks { + out.Tasks = append(out.Tasks, taskToProto(&result.Tasks[i])) + } + return out, nil +} + +// CancelTask stops a running task. +func (s *Server) CancelTask(ctx context.Context, req *a2apb.CancelTaskRequest) (*a2apb.Task, error) { + task, err := s.svc.CancelTask(ctx, credentialsOf(ctx), a2aremote.CancelTaskRequest{ + Tenant: req.GetTenant(), ID: req.GetId(), + }) + if err != nil { + return nil, statusOf(err) + } + return taskToProto(task), nil +} + +// credentialsOf lifts gRPC metadata into the transport-neutral shape a +// resolver takes, so one resolver serves all three bindings. +func credentialsOf(ctx context.Context) a2aremote.Credentials { + cred := a2aremote.Credentials{} + if md, ok := metadata.FromIncomingContext(ctx); ok { + cred.Headers = md + } + if p, ok := peer.FromContext(ctx); ok { + if p.Addr != nil { + cred.RemoteAddr = p.Addr.String() + } + if tlsInfo, ok := p.AuthInfo.(interface{ GetSecurityValue() any }); ok { + _ = tlsInfo // TLS details arrive through AuthInfo; nothing needs them yet. + } + } + return cred +} + +// statusOf maps a protocol error onto a gRPC status, using the code +// mapping the specification's own error table gives. +func statusOf(err error) error { + var perr *a2aremote.Error + if !errors.As(err, &perr) { + return status.Error(codes.Internal, "the request could not be completed") + } + return status.Error(grpcCodeFor(perr), perr.Message) +} + +func grpcCodeFor(err *a2aremote.Error) codes.Code { + switch err.Code { + case a2aremote.CodeTaskNotFound, a2aremote.CodeMethodNotFound: + return codes.NotFound + case a2aremote.CodeTaskNotCancelable, a2aremote.CodePushNotificationNotSupported, + a2aremote.CodeUnsupportedOperation, a2aremote.CodeExtendedCardNotConfigured, + a2aremote.CodeExtensionSupportRequired, a2aremote.CodeVersionNotSupported: + return codes.FailedPrecondition + case a2aremote.CodeContentTypeNotSupported, a2aremote.CodeInvalidParams, a2aremote.CodeParse: + return codes.InvalidArgument + case a2aremote.CodeInvalidRequest: + // The refusal that is about the caller rather than the request. + if err.Message == a2aremote.ErrUnauthenticated().Message { + return codes.Unauthenticated + } + return codes.InvalidArgument + default: + return codes.Internal + } +} + +func messageFromProto(m *a2apb.Message) a2aremote.Message { + if m == nil { + return a2aremote.Message{} + } + out := a2aremote.Message{ + MessageID: m.GetMessageId(), + ContextID: m.GetContextId(), + TaskID: m.GetTaskId(), + Role: roleFromProto(m.GetRole()), + Extensions: m.GetExtensions(), + ReferenceTaskIDs: m.GetReferenceTaskIds(), + } + if md := m.GetMetadata(); md != nil { + out.Metadata = md.AsMap() + } + for _, p := range m.GetParts() { + out.Parts = append(out.Parts, partFromProto(p)) + } + return out +} + +func partFromProto(p *a2apb.Part) a2aremote.Part { + switch content := p.GetContent().(type) { + case *a2apb.Part_Text: + return a2aremote.Part{Text: content.Text} + case *a2apb.Part_Raw: + return a2aremote.Part{File: &a2aremote.FilePart{Raw: content.Raw}} + case *a2apb.Part_Url: + return a2aremote.Part{File: &a2aremote.FilePart{URL: content.Url}} + case *a2apb.Part_Data: + // A data part is refused by the mapping layer anyway, so it is + // carried across as an empty one: the refusal names the shape, + // not its contents. + return a2aremote.Part{Data: &a2aremote.DataPart{}} + default: + return a2aremote.Part{} + } +} + +func messageToProto(m *a2aremote.Message) *a2apb.Message { + if m == nil { + return nil + } + out := &a2apb.Message{ + MessageId: m.MessageID, + ContextId: m.ContextID, + TaskId: m.TaskID, + Role: roleToProto(m.Role), + Extensions: m.Extensions, + ReferenceTaskIds: m.ReferenceTaskIDs, + } + for _, p := range m.Parts { + out.Parts = append(out.Parts, &a2apb.Part{Content: &a2apb.Part_Text{Text: p.Text}}) + } + return out +} + +func taskToProto(t *a2aremote.Task) *a2apb.Task { + if t == nil { + return nil + } + out := &a2apb.Task{ + Id: t.ID, + ContextId: t.ContextID, + Status: &a2apb.TaskStatus{ + State: taskStateToProto(t.Status.State), + Message: messageToProto(t.Status.Message), + Timestamp: timestamppb.Now(), + }, + } + for i := range t.Artifacts { + a := &t.Artifacts[i] + pa := &a2apb.Artifact{ArtifactId: a.ArtifactID, Name: a.Name, Description: a.Description} + for _, p := range a.Parts { + pa.Parts = append(pa.Parts, &a2apb.Part{Content: &a2apb.Part_Text{Text: p.Text}}) + } + out.Artifacts = append(out.Artifacts, pa) + } + return out +} + +func taskStateToProto(s a2aremote.TaskState) a2apb.TaskState { + switch s { + case a2aremote.TaskStateSubmitted: + return a2apb.TaskState_TASK_STATE_SUBMITTED + case a2aremote.TaskStateWorking: + return a2apb.TaskState_TASK_STATE_WORKING + case a2aremote.TaskStateCompleted: + return a2apb.TaskState_TASK_STATE_COMPLETED + case a2aremote.TaskStateFailed: + return a2apb.TaskState_TASK_STATE_FAILED + case a2aremote.TaskStateCanceled: + return a2apb.TaskState_TASK_STATE_CANCELED + case a2aremote.TaskStateRejected: + return a2apb.TaskState_TASK_STATE_REJECTED + case a2aremote.TaskStateInputRequired: + return a2apb.TaskState_TASK_STATE_INPUT_REQUIRED + case a2aremote.TaskStateAuthRequired: + return a2apb.TaskState_TASK_STATE_AUTH_REQUIRED + default: + return a2apb.TaskState_TASK_STATE_UNSPECIFIED + } +} + +func roleFromProto(r a2apb.Role) a2aremote.Role { + if r == a2apb.Role_ROLE_AGENT { + return a2aremote.RoleAgent + } + return a2aremote.RoleUser +} + +func roleToProto(r a2aremote.Role) a2apb.Role { + if r == a2aremote.RoleAgent { + return a2apb.Role_ROLE_AGENT + } + return a2apb.Role_ROLE_USER +} diff --git a/a2aremote/grpcbind/server_test.go b/a2aremote/grpcbind/server_test.go new file mode 100644 index 0000000..ffadf19 --- /dev/null +++ b/a2aremote/grpcbind/server_test.go @@ -0,0 +1,207 @@ +package grpcbind_test + +import ( + "context" + "errors" + "net" + "testing" + + "google.golang.org/grpc" + "google.golang.org/grpc/codes" + "google.golang.org/grpc/credentials/insecure" + "google.golang.org/grpc/metadata" + "google.golang.org/grpc/status" + "google.golang.org/grpc/test/bufconn" + + "github.com/xraph/cortex" + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/a2aremote" + "github.com/xraph/cortex/a2aremote/grpcbind" + "github.com/xraph/cortex/a2aremote/grpcbind/a2apb" + "github.com/xraph/cortex/agent" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" + "github.com/xraph/cortex/skill" +) + +// gateway is the smallest thing that satisfies the seam: enough to prove +// the binding translates, not enough to test cortex itself. +type gateway struct { + sent []a2a.SendParams + runs map[string]*run.Run + agents map[string]bool +} + +func newGateway() *gateway { + return &gateway{runs: map[string]*run.Run{}, agents: map[string]bool{"worker": true}} +} + +func (g *gateway) SendMessage(_ context.Context, p a2a.SendParams) (*a2a.SendResult, error) { + g.sent = append(g.sent, p) + return &a2a.SendResult{ + MessageID: id.NewMessageID(), + ConversationID: id.NewConversationID(), + Deliveries: []a2a.DeliveryOutcome{{Receiver: p.Receivers[0], DeliveryID: id.NewDeliveryID()}}, + }, nil +} + +func (g *gateway) GetRun(_ context.Context, runID id.AgentRunID) (*run.Run, error) { + r, ok := g.runs[runID.String()] + if !ok { + return nil, errors.New("not found") + } + return r, nil +} + +func (g *gateway) GetDelivery(context.Context, id.DeliveryID) (*a2a.Delivery, error) { + return nil, errors.New("not found") +} + +func (g *gateway) ListRuns(context.Context, *run.ListFilter) ([]*run.Run, error) { + out := make([]*run.Run, 0, len(g.runs)) + for _, r := range g.runs { + out = append(out, r) + } + return out, nil +} + +func (g *gateway) CancelRun(context.Context, id.AgentRunID) error { return nil } + +func (g *gateway) GetAgentByName(_ context.Context, name string) (*agent.Config, error) { + if !g.agents[name] { + return nil, errors.New("no such agent") + } + return &agent.Config{ID: id.NewAgentID(), Name: name}, nil +} + +func (g *gateway) GetSkillByName(context.Context, string) (*skill.Skill, error) { + return nil, errors.New("no such skill") +} + +func dial(t *testing.T, gw a2aremote.Gateway, res a2aremote.PeerResolver) a2apb.A2AServiceClient { + t.Helper() + lis := bufconn.Listen(1 << 20) + srv := grpc.NewServer() + grpcbind.Register(srv, a2aremote.NewService(gw, res, a2aremote.Options{})) + + go func() { _ = srv.Serve(lis) }() + t.Cleanup(srv.Stop) + + conn, err := grpc.NewClient("passthrough://bufnet", + grpc.WithContextDialer(func(ctx context.Context, _ string) (net.Conn, error) { + return lis.DialContext(ctx) + }), + grpc.WithTransportCredentials(insecure.NewCredentials()), + ) + if err != nil { + t.Fatalf("dial: %v", err) + } + t.Cleanup(func() { _ = conn.Close() }) + return a2apb.NewA2AServiceClient(conn) +} + +func okResolver() a2aremote.PeerResolver { + return a2aremote.ResolverFunc(func(_ context.Context, cred a2aremote.Credentials) (a2aremote.Peer, error) { + if cred.Header("authorization") != "Bearer ok" { + return a2aremote.Peer{}, errors.New("who are you") + } + return a2aremote.Peer{ + Node: "peer.example", + Scope: cortex.Scope{Levels: []cortex.Level{{Key: "tenant", Value: "acme"}}}, + }, nil + }) +} + +func authed(ctx context.Context) context.Context { + return metadata.AppendToOutgoingContext(ctx, "authorization", "Bearer ok") +} + +func TestGRPCSendMessage(t *testing.T) { + gw := newGateway() + client := dial(t, gw, okResolver()) + + res, err := client.SendMessage(authed(context.Background()), &a2apb.SendMessageRequest{ + Tenant: "worker", + Message: &a2apb.Message{ + MessageId: "m1", Role: a2apb.Role_ROLE_USER, + Parts: []*a2apb.Part{{Content: &a2apb.Part_Text{Text: "hello over grpc"}}}, + }, + }) + if err != nil { + t.Fatalf("SendMessage: %v", err) + } + if res.GetTask() == nil { + t.Fatalf("a request must come back as a task: %+v", res) + } + if len(gw.sent) != 1 || gw.sent[0].Content != "hello over grpc" { + t.Fatalf("the gateway saw %+v", gw.sent) + } + // The sender is namespaced by the peer's node here exactly as it is + // on the other two bindings, because the rule lives in the service. + if gw.sent[0].Sender.Node != "peer.example" { + t.Fatalf("sender = %+v, want it namespaced by the peer", gw.sent[0].Sender) + } +} + +func TestGRPCGetTask(t *testing.T) { + gw := newGateway() + runID := id.NewAgentRunID() + gw.runs[runID.String()] = &run.Run{ID: runID, State: run.StateCompleted, Output: "done"} + client := dial(t, gw, okResolver()) + + task, err := client.GetTask(authed(context.Background()), &a2apb.GetTaskRequest{ + Tenant: "worker", Id: runID.String(), + }) + if err != nil { + t.Fatalf("GetTask: %v", err) + } + if task.GetStatus().GetState() != a2apb.TaskState_TASK_STATE_COMPLETED { + t.Fatalf("state = %s", task.GetStatus().GetState()) + } + if len(task.GetArtifacts()) != 1 || task.GetArtifacts()[0].GetParts()[0].GetText() != "done" { + t.Fatalf("artifacts = %+v", task.GetArtifacts()) + } +} + +// The status codes are the specification's own mapping, so a gRPC client +// can tell "not there" from "not allowed" without reading strings. +func TestGRPCErrorsCarryTheRightCode(t *testing.T) { + gw := newGateway() + client := dial(t, gw, okResolver()) + ctx := authed(context.Background()) + + if _, err := client.GetTask(ctx, &a2apb.GetTaskRequest{Tenant: "worker", Id: "arun_notreal"}); status.Code(err) != codes.NotFound { + t.Errorf("missing task: code = %s, want NotFound", status.Code(err)) + } + if _, err := client.SendMessage(ctx, &a2apb.SendMessageRequest{ + Tenant: "nobody", + Message: &a2apb.Message{MessageId: "m", Role: a2apb.Role_ROLE_USER, Parts: []*a2apb.Part{{Content: &a2apb.Part_Text{Text: "x"}}}}, + }); status.Code(err) != codes.NotFound { + t.Errorf("unknown tenant: code = %s, want NotFound", status.Code(err)) + } +} + +func TestGRPCUnauthenticated(t *testing.T) { + client := dial(t, newGateway(), okResolver()) + + // No credentials in the metadata at all. + _, err := client.ListTasks(context.Background(), &a2apb.ListTasksRequest{Tenant: "worker"}) + if status.Code(err) != codes.Unauthenticated { + t.Fatalf("code = %s, want Unauthenticated", status.Code(err)) + } +} + +func TestGRPCStreamingIsRefusedRatherThanHanging(t *testing.T) { + client := dial(t, newGateway(), okResolver()) + + stream, err := client.SendStreamingMessage(authed(context.Background()), &a2apb.SendMessageRequest{ + Tenant: "worker", + Message: &a2apb.Message{MessageId: "m", Role: a2apb.Role_ROLE_USER}, + }) + if err == nil { + _, err = stream.Recv() + } + if status.Code(err) != codes.Unimplemented { + t.Fatalf("code = %s, want Unimplemented for something the card says is not there", status.Code(err)) + } +} From 8c3d0360be5b7a9b7e9ee9c3e1544ad50999212c Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 21:34:29 -0500 Subject: [PATCH 45/50] feat(a2aremote): stream on all three bindings Task-level streaming, which is what A2A's streaming is: the subscriber gets the task, then each transition, and the last one carries the output with final set so a client knows to stop reading. Server-sent events on the two HTTP bindings, native server streaming on gRPC, and one implementation behind all three. Off unless a host asks for it, and the card says so either way. A card that offered a stream nobody served would be a promise the server could not keep. --- CHANGELOG.md | 20 ++ a2aremote/card.go | 5 +- a2aremote/grpcbind/server.go | 69 +++++ a2aremote/grpcbind/server_test.go | 88 +++++- a2aremote/jsonrpc.go | 79 ++++- a2aremote/rest.go | 30 +- a2aremote/service.go | 8 + a2aremote/sse.go | 71 +++++ a2aremote/stream.go | 146 ++++++++++ a2aremote/stream_test.go | 270 ++++++++++++++++++ .../docs/execution/remote-messaging.mdx | 74 ++++- 11 files changed, 841 insertions(+), 19 deletions(-) create mode 100644 a2aremote/sse.go create mode 100644 a2aremote/stream.go create mode 100644 a2aremote/stream_test.go diff --git a/CHANGELOG.md b/CHANGELOG.md index 8f59ca3..462984b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -156,6 +156,26 @@ reply; it is now `{"replies": [...], "complete": bool}`, with one entry for a single-recipient ask. Shipping two shapes, one per recipient count, would have cost every prompt forever; a list costs one sentence. +### Added later in this release: the other two bindings, and streaming + +Cortex now serves all three A2A bindings over one service. HTTP+JSON is +`svc.RESTHandler()` at the protocol's own colon-verb paths. gRPC is its +own module, `a2aremote/grpcbind`, so a host serving JSON-RPC does not +inherit grpc-go and protobuf; its types are generated from the normative +a2a.proto, vendored with the script that regenerates them. + +Whatever a rule says about scope or sender namespacing holds on all +three, because the rule lives in the service and the bindings only +translate. A test asserts the bindings agree on identical input, since +that is the property the shared service exists to provide. + +Streaming is there too, off unless you ask for it: +`SendStreamingMessage` and `SubscribeToTask`, over server-sent events on +the HTTP bindings and native server streaming on gRPC. It is task-level +streaming rather than tokens, which is what A2A's streaming is: the +subscriber gets the task, then each transition, and the last one carries +the output with `final` set. + ### Fixed - **A refusal ended a round it should not have.** `refuse` and diff --git a/a2aremote/card.go b/a2aremote/card.go index 594e113..41b2f7e 100644 --- a/a2aremote/card.go +++ b/a2aremote/card.go @@ -96,6 +96,9 @@ type CardOptions struct { Version string Provider AgentProvider DocumentationURL string + // Streaming says the card should advertise streaming, which it does + // only when the service actually serves it. + Streaming bool // Bindings names which bindings the card advertises. Empty means // JSON-RPC only, which is what this module serves on its own. // @@ -147,7 +150,7 @@ func BuildCard(a *agent.Config, skills []*skill.Skill, opts CardOptions) AgentCa // Everything cortex does not implement is declared false // rather than left out, so a peer reads a refusal here // instead of discovering it mid-conversation. - Streaming: false, + Streaming: opts.Streaming, PushNotifications: false, ExtendedAgentCard: false, Extensions: []AgentExtension{{ diff --git a/a2aremote/grpcbind/server.go b/a2aremote/grpcbind/server.go index 4c57b38..39d7eaa 100644 --- a/a2aremote/grpcbind/server.go +++ b/a2aremote/grpcbind/server.go @@ -264,3 +264,72 @@ func roleToProto(r a2aremote.Role) a2apb.Role { } return a2apb.Role_ROLE_USER } + +// SendStreamingMessage sends a message and streams the task's progress. +func (s *Server) SendStreamingMessage(req *a2apb.SendMessageRequest, stream a2apb.A2AService_SendStreamingMessageServer) error { + return s.svc.StreamMessage(stream.Context(), credentialsOf(stream.Context()), a2aremote.SendMessageRequest{ + Tenant: req.GetTenant(), + Message: messageFromProto(req.GetMessage()), + }, emitTo(stream)) +} + +// SubscribeToTask streams the progress of a task already running. +func (s *Server) SubscribeToTask(req *a2apb.SubscribeToTaskRequest, stream a2apb.A2AService_SubscribeToTaskServer) error { + return s.svc.SubscribeTask(stream.Context(), credentialsOf(stream.Context()), + req.GetTenant(), req.GetId(), emitTo(stream)) +} + +// streamSender is the half of a generated server stream this package +// uses. Naming it lets both streaming methods share one adapter rather +// than repeating the translation twice. +type streamSender interface { + Send(*a2apb.StreamResponse) error +} + +// emitTo adapts a generated stream to the service's emitter. +// +// A send failure ends the stream by returning, which is how a gRPC +// server learns its client hung up. +func emitTo(stream streamSender) a2aremote.Emit { + return func(ev a2aremote.StreamEvent) error { + return stream.Send(streamResponseOf(ev)) + } +} + +func streamResponseOf(ev a2aremote.StreamEvent) *a2apb.StreamResponse { + switch { + case ev.Task != nil: + return &a2apb.StreamResponse{Payload: &a2apb.StreamResponse_Task{Task: taskToProto(ev.Task)}} + case ev.Message != nil: + return &a2apb.StreamResponse{Payload: &a2apb.StreamResponse_Message{Message: messageToProto(ev.Message)}} + case ev.StatusUpdate != nil: + return &a2apb.StreamResponse{Payload: &a2apb.StreamResponse_StatusUpdate{ + StatusUpdate: &a2apb.TaskStatusUpdateEvent{ + TaskId: ev.StatusUpdate.TaskID, + ContextId: ev.StatusUpdate.ContextID, + Status: &a2apb.TaskStatus{ + State: taskStateToProto(ev.StatusUpdate.Status.State), + Message: messageToProto(ev.StatusUpdate.Status.Message), + Timestamp: timestamppb.Now(), + }, + }, + }} + case ev.ArtifactUpdate != nil: + artifact := &a2apb.Artifact{ + ArtifactId: ev.ArtifactUpdate.Artifact.ArtifactID, + Name: ev.ArtifactUpdate.Artifact.Name, + } + for _, p := range ev.ArtifactUpdate.Artifact.Parts { + artifact.Parts = append(artifact.Parts, &a2apb.Part{Content: &a2apb.Part_Text{Text: p.Text}}) + } + return &a2apb.StreamResponse{Payload: &a2apb.StreamResponse_ArtifactUpdate{ + ArtifactUpdate: &a2apb.TaskArtifactUpdateEvent{ + TaskId: ev.ArtifactUpdate.TaskID, + ContextId: ev.ArtifactUpdate.ContextID, + Artifact: artifact, + }, + }} + default: + return &a2apb.StreamResponse{} + } +} diff --git a/a2aremote/grpcbind/server_test.go b/a2aremote/grpcbind/server_test.go index ffadf19..1fb6aac 100644 --- a/a2aremote/grpcbind/server_test.go +++ b/a2aremote/grpcbind/server_test.go @@ -5,6 +5,7 @@ import ( "errors" "net" "testing" + "time" "google.golang.org/grpc" "google.golang.org/grpc/codes" @@ -79,10 +80,15 @@ func (g *gateway) GetSkillByName(context.Context, string) (*skill.Skill, error) } func dial(t *testing.T, gw a2aremote.Gateway, res a2aremote.PeerResolver) a2apb.A2AServiceClient { + t.Helper() + return dialWith(t, gw, res, a2aremote.Options{}) +} + +func dialWith(t *testing.T, gw a2aremote.Gateway, res a2aremote.PeerResolver, opts a2aremote.Options) a2apb.A2AServiceClient { t.Helper() lis := bufconn.Listen(1 << 20) srv := grpc.NewServer() - grpcbind.Register(srv, a2aremote.NewService(gw, res, a2aremote.Options{})) + grpcbind.Register(srv, a2aremote.NewService(gw, res, opts)) go func() { _ = srv.Serve(lis) }() t.Cleanup(srv.Stop) @@ -191,7 +197,7 @@ func TestGRPCUnauthenticated(t *testing.T) { } } -func TestGRPCStreamingIsRefusedRatherThanHanging(t *testing.T) { +func TestGRPCStreamingIsRefusedWhenItIsOff(t *testing.T) { client := dial(t, newGateway(), okResolver()) stream, err := client.SendStreamingMessage(authed(context.Background()), &a2apb.SendMessageRequest{ @@ -201,7 +207,81 @@ func TestGRPCStreamingIsRefusedRatherThanHanging(t *testing.T) { if err == nil { _, err = stream.Recv() } - if status.Code(err) != codes.Unimplemented { - t.Fatalf("code = %s, want Unimplemented for something the card says is not there", status.Code(err)) + if status.Code(err) == codes.OK { + t.Fatalf("streaming answered while it is switched off") + } +} + +// movingGateway walks a run through its states so a subscriber sees +// transitions rather than one snapshot. +type movingGateway struct { + *gateway + states []run.State + runID id.AgentRunID +} + +func (g *movingGateway) GetRun(_ context.Context, runID id.AgentRunID) (*run.Run, error) { + if runID != g.runID { + return nil, errors.New("not found") + } + state := g.states[0] + if len(g.states) > 1 { + g.states = g.states[1:] + } + r := &run.Run{ID: g.runID, State: state} + if state == run.StateCompleted { + r.Output = "done over grpc" + } + return r, nil +} + +func (g *movingGateway) GetDelivery(_ context.Context, deliveryID id.DeliveryID) (*a2a.Delivery, error) { + return &a2a.Delivery{ID: deliveryID, State: a2a.DeliveryDelivered, RunID: g.runID}, nil +} + +// gRPC streams the same events the other bindings do, because the events +// come from the service rather than from any binding. +func TestGRPCSendStreamingMessage(t *testing.T) { + base := newGateway() + gw := &movingGateway{gateway: base, states: []run.State{run.StateRunning, run.StateCompleted}, runID: id.NewAgentRunID()} + client := dialWith(t, gw, okResolver(), a2aremote.Options{Streaming: true, StreamPoll: time.Millisecond}) + + stream, err := client.SendStreamingMessage(authed(context.Background()), &a2apb.SendMessageRequest{ + Tenant: "worker", + Message: &a2apb.Message{ + MessageId: "m1", Role: a2apb.Role_ROLE_USER, + Parts: []*a2apb.Part{{Content: &a2apb.Part_Text{Text: "stream this"}}}, + }, + }) + if err != nil { + t.Fatalf("SendStreamingMessage: %v", err) + } + + var sawTask, sawFinal, sawArtifact bool + for { + resp, recvErr := stream.Recv() + if recvErr != nil { + break + } + switch { + case resp.GetTask() != nil: + sawTask = true + case resp.GetStatusUpdate() != nil: + if resp.GetStatusUpdate().GetStatus().GetState() == a2apb.TaskState_TASK_STATE_COMPLETED { + sawFinal = true + } + case resp.GetArtifactUpdate() != nil: + sawArtifact = true + } + } + + if !sawTask { + t.Error("the stream never opened with the task") + } + if !sawFinal { + t.Error("the stream never reported the task finishing") + } + if !sawArtifact { + t.Error("the output never arrived") } } diff --git a/a2aremote/jsonrpc.go b/a2aremote/jsonrpc.go index 9756841..3031fa5 100644 --- a/a2aremote/jsonrpc.go +++ b/a2aremote/jsonrpc.go @@ -75,6 +75,13 @@ func (s *Service) JSONRPCHandler() http.Handler { writeRPC(w, rpcResponse{JSONRPC: "2.0", Error: ErrParse()}) return } + + // A streaming method answers with an event stream rather than one + // response, so it leaves the request/response path here. + if isStreamingMethod(req.Method) { + s.serveRPCStream(w, r, req) + return + } // A request with no id is a notification: it is acted on and it // gets no response, which JSON-RPC is explicit about. notification := len(req.ID) == 0 @@ -143,11 +150,6 @@ func (s *Service) call(ctx context.Context, cred Credentials, method string, par } return s.CancelTask(ctx, cred, req) - case MethodSendStreamingMessage, MethodSubscribeToTask: - // Declared unsupported in the card too, so a client that read the - // card never gets here. - return nil, ErrUnsupportedOperation(method) - case MethodGetExtendedCard: return nil, ErrExtendedCardNotConfigured() @@ -241,6 +243,7 @@ func (s *Service) writeCard(w http.ResponseWriter, r *http.Request, name string) } opts := s.opts.Card + opts.Streaming = s.opts.Streaming card := BuildCard(a, s.skillsOf(ctx, a), opts) w.Header().Set("Content-Type", "application/json") @@ -272,3 +275,69 @@ func (s *Service) skillsOf(ctx context.Context, a *agent.Config) []*skill.Skill } return out } + +func isStreamingMethod(method string) bool { + return method == MethodSendStreamingMessage || method == MethodSubscribeToTask +} + +// serveRPCStream answers a streaming method with server-sent events, +// each frame carrying a JSON-RPC response whose result is one event. +func (s *Service) serveRPCStream(w http.ResponseWriter, r *http.Request, req rpcRequest) { + if !s.opts.Streaming { + // The card says the same thing, so a client that read it never + // arrives here. + writeRPC(w, rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: ErrUnsupportedOperation(req.Method)}) + return + } + + cred := credentialsOf(r) + // Everything that can refuse the stream is settled before the status + // line goes out. Once a stream has started, the only way left to + // report a failure is to hang up, which tells a client nothing. + var ( + tenant string + taskID string + send SendMessageRequest + ) + switch req.Method { + case MethodSendStreamingMessage: + if err := decodeParams(req.Params, &send); err != nil { + writeRPC(w, rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: asProtocolError(err)}) + return + } + tenant = send.Tenant + case MethodSubscribeToTask: + var sub GetTaskRequest + if err := decodeParams(req.Params, &sub); err != nil { + writeRPC(w, rpcResponse{JSONRPC: "2.0", ID: req.ID, Error: asProtocolError(err)}) + return + } + tenant, taskID = sub.Tenant, sub.ID + } + + stream, err := newSSEWriter(w, func(ev StreamEvent) any { + return rpcResponse{JSONRPC: "2.0", ID: req.ID, Result: ev} + }) + if err != nil { + writeSSEError(w, ErrInternal("this server cannot stream")) + return + } + + // Once the stream has started there is no status line left to say a + // failure with, so the error ends the stream and the client sees the + // connection close without a final event. That is the protocol's own + // answer to a mid-stream failure. + if req.Method == MethodSendStreamingMessage { + endStream(s.StreamMessage(r.Context(), cred, send, stream.emit)) + return + } + endStream(s.SubscribeTask(r.Context(), cred, tenant, taskID, stream.emit)) +} + +// endStream is where a stream's error goes. +// +// There is nowhere left to report it: the status line is spent, the +// client has been reading events, and inventing a final event that said +// "something went wrong" would be indistinguishable from the task +// itself failing. Closing without a final event is the signal. +func endStream(error) {} diff --git a/a2aremote/rest.go b/a2aremote/rest.go index 1bbb2f4..c9a934a 100644 --- a/a2aremote/rest.go +++ b/a2aremote/rest.go @@ -40,7 +40,16 @@ func (s *Service) RESTHandler() http.Handler { }) writeREST(w, task, err) case "subscribe": - writeREST(w, nil, ErrUnsupportedOperation("SubscribeToTask")) + if !s.opts.Streaming { + writeREST(w, nil, ErrUnsupportedOperation("SubscribeToTask")) + return + } + stream, err := newSSEWriter(w, nil) + if err != nil { + writeSSEError(w, ErrInternal("this server cannot stream")) + return + } + endStream(s.SubscribeTask(r.Context(), credentialsOf(r), r.PathValue("tenant"), id, stream.emit)) default: writeREST(w, nil, ErrMethodNotFound(verb)) } @@ -65,8 +74,23 @@ func (s *Service) RESTHandler() http.Handler { writeREST(w, task, err) }) - mux.HandleFunc("POST /{tenant}/message:stream", func(w http.ResponseWriter, _ *http.Request) { - writeREST(w, nil, ErrUnsupportedOperation("SendStreamingMessage")) + mux.HandleFunc("POST /{tenant}/message:stream", func(w http.ResponseWriter, r *http.Request) { + if !s.opts.Streaming { + writeREST(w, nil, ErrUnsupportedOperation("SendStreamingMessage")) + return + } + var req SendMessageRequest + if !decodeREST(w, r, &req) { + return + } + req.Tenant = r.PathValue("tenant") + + stream, err := newSSEWriter(w, nil) + if err != nil { + writeSSEError(w, ErrInternal("this server cannot stream")) + return + } + endStream(s.StreamMessage(r.Context(), credentialsOf(r), req, stream.emit)) }) mux.HandleFunc("GET /{tenant}/extendedAgentCard", func(w http.ResponseWriter, _ *http.Request) { writeREST(w, nil, ErrExtendedCardNotConfigured()) diff --git a/a2aremote/service.go b/a2aremote/service.go index 949b608..fb8ed49 100644 --- a/a2aremote/service.go +++ b/a2aremote/service.go @@ -3,6 +3,7 @@ package a2aremote import ( "context" "errors" + "time" "github.com/xraph/cortex" "github.com/xraph/cortex/a2a" @@ -36,6 +37,13 @@ type Options struct { // DefaultAgent also gets its card served at the root well-known // path, so plain discovery finds something. DefaultAgent string + // Streaming turns on SendStreamingMessage and SubscribeToTask, and + // makes the card advertise them. Off by default: a card that offers + // a stream nobody serves is a promise the server cannot keep. + Streaming bool + // StreamPoll is how often a subscription re-reads its task. It + // bounds how stale an update can be and nothing else. + StreamPoll time.Duration } // Service holds every semantic decision the remote transport makes. diff --git a/a2aremote/sse.go b/a2aremote/sse.go new file mode 100644 index 0000000..4929aa5 --- /dev/null +++ b/a2aremote/sse.go @@ -0,0 +1,71 @@ +package a2aremote + +import ( + "encoding/json" + "errors" + "net/http" +) + +// ErrStreamingUnsupported means the response writer cannot flush, so +// events would sit in a buffer until the handler returned. That is not a +// stream, and pretending otherwise strands the client. +var ErrStreamingUnsupported = errors.New("cortex/a2aremote: the response writer cannot stream") + +// sseWriter emits stream events as server-sent events. +type sseWriter struct { + w http.ResponseWriter + flusher http.Flusher + // wrap renders one event as the frame body. JSON-RPC wraps each event + // in a response envelope; REST sends the event alone. + wrap func(StreamEvent) any +} + +func newSSEWriter(w http.ResponseWriter, wrap func(StreamEvent) any) (*sseWriter, error) { + flusher, ok := w.(http.Flusher) + if !ok { + return nil, ErrStreamingUnsupported + } + w.Header().Set("Content-Type", "text/event-stream") + w.Header().Set("Cache-Control", "no-cache") + w.Header().Set("Connection", "keep-alive") + w.Header().Set(versionHeader, ProtocolVersion) + w.WriteHeader(http.StatusOK) + flusher.Flush() + + return &sseWriter{w: w, flusher: flusher, wrap: wrap}, nil +} + +// emit writes one event and flushes it. Flushing per event is the whole +// point: an event held in a buffer has not been streamed. +func (s *sseWriter) emit(ev StreamEvent) error { + payload := any(ev) + if s.wrap != nil { + payload = s.wrap(ev) + } + body, err := json.Marshal(payload) + if err != nil { + return err + } + if _, err := s.w.Write([]byte("data: ")); err != nil { + return err + } + if _, err := s.w.Write(body); err != nil { + return err + } + if _, err := s.w.Write([]byte("\n\n")); err != nil { + return err + } + s.flusher.Flush() + return nil +} + +// writeSSEError reports a failure that happened before the stream +// started, when the status line is still ours to set. +func writeSSEError(w http.ResponseWriter, err error) { + perr := asProtocolError(err) + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(httpStatusFor(perr)) + if encErr := json.NewEncoder(w).Encode(map[string]any{"error": perr}); encErr != nil { + return + } +} diff --git a/a2aremote/stream.go b/a2aremote/stream.go new file mode 100644 index 0000000..35fd0d7 --- /dev/null +++ b/a2aremote/stream.go @@ -0,0 +1,146 @@ +package a2aremote + +import ( + "context" + "time" +) + +// DefaultStreamPoll is how often a subscription re-reads a task. +const DefaultStreamPoll = 500 * time.Millisecond + +// StreamEvent is one thing a subscriber is told. +// +// Exactly one field is set, mirroring the protocol's own oneof. A task +// is the opening snapshot, a status update is a transition, and an +// artifact update carries output as it appears. +type StreamEvent struct { + Task *Task `json:"task,omitempty"` + StatusUpdate *TaskStatusUpdateEvent `json:"statusUpdate,omitempty"` + ArtifactUpdate *TaskArtifactUpdate `json:"artifactUpdate,omitempty"` + Message *Message `json:"message,omitempty"` +} + +// TaskStatusUpdateEvent says a task moved. +type TaskStatusUpdateEvent struct { + TaskID string `json:"taskId"` + ContextID string `json:"contextId,omitempty"` + Status TaskStatus `json:"status"` + // Final says no more events follow, which is what tells a client to + // stop reading rather than to keep a dead connection open. + Final bool `json:"final"` +} + +// TaskArtifactUpdate carries a task output as it appears. +type TaskArtifactUpdate struct { + TaskID string `json:"taskId"` + ContextID string `json:"contextId,omitempty"` + Artifact Artifact `json:"artifact"` +} + +// Emit is how a binding receives events. Returning an error stops the +// stream, which is how a disconnected client ends its own subscription. +type Emit func(StreamEvent) error + +// StreamMessage sends a message and then follows the work it started. +// +// The first event is the task as it exists the moment the message is +// accepted, so a client has something to hold on to immediately. After +// that come status transitions, and the last event carries the artifacts +// with Final set. +// +// What this is not is token-level streaming. A2A's streaming is about a +// task's progress, and that is what this reports: cortex learns of a +// transition by re-reading the task on an interval, and the client sees +// the transitions in order either way. The interval bounds the latency +// and nothing else. +func (s *Service) StreamMessage(ctx context.Context, cred Credentials, req SendMessageRequest, emit Emit) error { + result, err := s.SendMessage(ctx, cred, req) + if err != nil { + return err + } + + // An informative starts no work, so the acknowledgement is the whole + // stream. Holding the connection open for a message nobody is acting + // on would be a subscription to nothing. + if result.Task == nil { + return emit(StreamEvent{Message: result.Message}) + } + if err := emit(StreamEvent{Task: result.Task}); err != nil { + return err + } + return s.follow(ctx, cred, req.Tenant, result.Task.ID, result.Task.Status.State, emit) +} + +// SubscribeTask follows a task that is already running. +func (s *Service) SubscribeTask(ctx context.Context, cred Credentials, tenant, taskID string, emit Emit) error { + task, err := s.GetTask(ctx, cred, GetTaskRequest{Tenant: tenant, ID: taskID}) + if err != nil { + return err + } + // A finished task has nothing to subscribe to. The protocol says so + // too: subscribing to a terminal task is an unsupported operation + // rather than an empty stream. + if task.Status.State.Terminal() { + return ErrUnsupportedOperation("SubscribeToTask on a task that has already finished") + } + if err := emit(StreamEvent{Task: task}); err != nil { + return err + } + return s.follow(ctx, cred, tenant, task.ID, task.Status.State, emit) +} + +// follow watches one task until it stops moving. +func (s *Service) follow(ctx context.Context, cred Credentials, tenant, taskID string, from TaskState, emit Emit) error { + interval := s.opts.StreamPoll + if interval <= 0 { + interval = DefaultStreamPoll + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + + last := from + for { + select { + case <-ctx.Done(): + // The client went away. That ends its subscription and + // nothing else: the task carries on without it. + return ctx.Err() + case <-ticker.C: + } + + task, err := s.GetTask(ctx, cred, GetTaskRequest{Tenant: tenant, ID: taskID}) + if err != nil { + return err + } + if task.Status.State == last { + continue + } + last = task.Status.State + + final := task.Status.State.Terminal() + if err := emit(StreamEvent{StatusUpdate: &TaskStatusUpdateEvent{ + TaskID: task.ID, + ContextID: task.ContextID, + Status: task.Status, + Final: final, + }}); err != nil { + return err + } + if !final { + continue + } + + // The output arrives with the last transition rather than after + // it, so a client that stops reading on Final has everything. + for i := range task.Artifacts { + if err := emit(StreamEvent{ArtifactUpdate: &TaskArtifactUpdate{ + TaskID: task.ID, + ContextID: task.ContextID, + Artifact: task.Artifacts[i], + }}); err != nil { + return err + } + } + return nil + } +} diff --git a/a2aremote/stream_test.go b/a2aremote/stream_test.go new file mode 100644 index 0000000..26aa4b5 --- /dev/null +++ b/a2aremote/stream_test.go @@ -0,0 +1,270 @@ +package a2aremote + +import ( + "bufio" + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "net/http/httptest" + "strings" + "sync" + "testing" + "time" + + "github.com/xraph/cortex/a2a" + "github.com/xraph/cortex/id" + "github.com/xraph/cortex/run" +) + +func streamingService(t *testing.T, gw Gateway) *Service { + t.Helper() + return NewService(gw, okResolver(), Options{Streaming: true, StreamPoll: time.Millisecond}) +} + +// movingGateway walks a run through its states on successive reads, so a +// subscription sees transitions rather than a single snapshot. +type movingGateway struct { + *fakeGateway + mu sync.Mutex + states []run.State + runID id.AgentRunID +} + +func newMovingGateway(states ...run.State) *movingGateway { + g := &movingGateway{fakeGateway: newFakeGateway(), states: states, runID: id.NewAgentRunID()} + dlvID := id.NewDeliveryID() + g.addDelivery(&a2a.Delivery{ID: dlvID, State: a2a.DeliveryDelivered, RunID: g.runID}) + g.sendResult = &a2a.SendResult{ + MessageID: id.NewMessageID(), + ConversationID: id.NewConversationID(), + Deliveries: []a2a.DeliveryOutcome{{Receiver: a2a.Address{Agent: "worker"}, DeliveryID: dlvID}}, + } + return g +} + +func (g *movingGateway) GetRun(_ context.Context, runID id.AgentRunID) (*run.Run, error) { + g.mu.Lock() + defer g.mu.Unlock() + if runID != g.runID { + return nil, errors.New("not found") + } + state := g.states[0] + if len(g.states) > 1 { + g.states = g.states[1:] + } + r := &run.Run{ID: g.runID, State: state} + if state == run.StateCompleted { + r.Output = "the work is done" + } + return r, nil +} + +func collect(t *testing.T, gw Gateway, run func(*Service, Emit) error) []StreamEvent { + t.Helper() + var events []StreamEvent + if err := run(streamingService(t, gw), func(ev StreamEvent) error { + events = append(events, ev) + return nil + }); err != nil { + t.Fatalf("stream: %v", err) + } + return events +} + +// The opening event is the task as it exists right now, so a client has +// a handle immediately rather than after the first transition. +func TestStreamMessageOpensWithTheTask(t *testing.T) { + gw := newMovingGateway(run.StateRunning, run.StateCompleted) + events := collect(t, gw, func(s *Service, emit Emit) error { + return s.StreamMessage(context.Background(), Credentials{}, plainRequest("do the thing"), emit) + }) + + if len(events) == 0 || events[0].Task == nil { + t.Fatalf("the first event must be the task: %+v", events) + } +} + +func TestStreamMessageReportsTransitionsAndEndsFinal(t *testing.T) { + gw := newMovingGateway(run.StateRunning, run.StateRunning, run.StateCompleted) + events := collect(t, gw, func(s *Service, emit Emit) error { + return s.StreamMessage(context.Background(), Credentials{}, plainRequest("do the thing"), emit) + }) + + var updates []*TaskStatusUpdateEvent + var artifacts []*TaskArtifactUpdate + for _, ev := range events { + switch { + case ev.StatusUpdate != nil: + updates = append(updates, ev.StatusUpdate) + case ev.ArtifactUpdate != nil: + artifacts = append(artifacts, ev.ArtifactUpdate) + } + } + + if len(updates) == 0 { + t.Fatalf("no transitions were reported: %+v", events) + } + last := updates[len(updates)-1] + if !last.Final { + t.Error("the last transition must say it is final, or a client keeps a dead connection open") + } + if last.Status.State != TaskStateCompleted { + t.Errorf("last state = %s, want completed", last.Status.State) + } + // The output arrives with the last transition rather than after it, + // so a client that stops on Final still has everything. + if len(artifacts) != 1 || artifacts[0].Artifact.Parts[0].Text != "the work is done" { + t.Fatalf("artifacts = %+v", artifacts) + } +} + +// The same state read twice is not a transition, and reporting it would +// fill a client's stream with noise. +func TestStreamDoesNotRepeatAState(t *testing.T) { + gw := newMovingGateway(run.StateRunning, run.StateRunning, run.StateRunning, run.StateCompleted) + events := collect(t, gw, func(s *Service, emit Emit) error { + return s.StreamMessage(context.Background(), Credentials{}, plainRequest("go"), emit) + }) + + var working int + for _, ev := range events { + if ev.StatusUpdate != nil && ev.StatusUpdate.Status.State == TaskStateWorking { + working++ + } + } + if working > 1 { + t.Fatalf("the same state was reported %d times", working) + } +} + +// An informative starts no work, so the acknowledgement is the whole +// stream. Holding a connection open for it would be a subscription to +// nothing. +func TestStreamOfAnInformativeIsJustTheAcknowledgement(t *testing.T) { + gw := newFakeGateway() + req := plainRequest("the build is green") + req.Message.Metadata = map[string]any{FIPAExtensionURI: map[string]any{"performative": "inform"}} + + events := collect(t, gw, func(s *Service, emit Emit) error { + return s.StreamMessage(context.Background(), Credentials{}, req, emit) + }) + if len(events) != 1 || events[0].Message == nil { + t.Fatalf("events = %+v, want one acknowledgement", events) + } +} + +// Subscribing to something already finished is an unsupported operation +// rather than an empty stream, which is what the protocol says too. +func TestSubscribeToATerminalTaskIsRefused(t *testing.T) { + gw := newFakeGateway() + runID := id.NewAgentRunID() + gw.addRun(&run.Run{ID: runID, State: run.StateCompleted, Output: "already done"}) + + err := streamingService(t, gw).SubscribeTask(context.Background(), Credentials{}, "worker", runID.String(), + func(StreamEvent) error { return nil }) + + var aerr *Error + if !errors.As(err, &aerr) || aerr.Code != CodeUnsupportedOperation { + t.Fatalf("err = %v, want UnsupportedOperationError", err) + } +} + +// A client that goes away ends its own subscription and nothing else. +func TestStreamStopsWhenTheClientLeaves(t *testing.T) { + gw := newMovingGateway(run.StateRunning) + ctx, cancel := context.WithCancel(context.Background()) + + done := make(chan error, 1) + go func() { + done <- streamingService(t, gw).StreamMessage(ctx, Credentials{}, plainRequest("go"), func(StreamEvent) error { + return nil + }) + }() + + cancel() + select { + case err := <-done: + if !errors.Is(err, context.Canceled) { + t.Fatalf("err = %v, want the cancellation", err) + } + case <-time.After(5 * time.Second): + t.Fatal("the stream kept running after its client left") + } +} + +func TestStreamingIsOffUnlessAskedFor(t *testing.T) { + gw := newFakeGateway() + // The default service has streaming off, and its card says so. + svc := NewService(gw, okResolver(), Options{Card: CardOptions{BaseURL: "https://x/a2a"}, Exposed: []string{"worker"}}) + + rec := httptest.NewRecorder() + svc.JSONRPCHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodPost, "/", bytes.NewBufferString( + `{"jsonrpc":"2.0","id":1,"method":"SendStreamingMessage","params":{"tenant":"worker"}}`))) + + var resp map[string]any + if err := json.Unmarshal(rec.Body.Bytes(), &resp); err != nil { + t.Fatalf("body: %q", rec.Body.String()) + } + e, _ := resp["error"].(map[string]any) + if e == nil || e["code"] != float64(CodeUnsupportedOperation) { + t.Fatalf("resp = %+v, want an unsupported-operation refusal", resp) + } +} + +// The transport has to actually stream: frames arrive as they happen +// rather than in one lump when the handler returns. +func TestSSEFramesArriveAsEvents(t *testing.T) { + gw := newMovingGateway(run.StateRunning, run.StateCompleted) + srv := httptest.NewServer(streamingService(t, gw).RESTHandler()) + defer srv.Close() + + req, err := http.NewRequestWithContext(context.Background(), http.MethodPost, + srv.URL+"/worker/message:stream", + bytes.NewBufferString(`{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"go"}]}}`)) + if err != nil { + t.Fatalf("build request: %v", err) + } + req.Header.Set("Content-Type", "application/json") + + resp, err := srv.Client().Do(req) + if err != nil { + t.Fatalf("post: %v", err) + } + defer func() { _ = resp.Body.Close() }() + + if ct := resp.Header.Get("Content-Type"); !strings.HasPrefix(ct, "text/event-stream") { + t.Fatalf("content type = %q, want an event stream", ct) + } + + var frames []StreamEvent + scanner := bufio.NewScanner(resp.Body) + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + var ev StreamEvent + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &ev); err != nil { + t.Fatalf("frame is not an event: %q", line) + } + frames = append(frames, ev) + } + + if len(frames) < 2 { + t.Fatalf("got %d frames, want the task and at least one transition", len(frames)) + } + if frames[0].Task == nil { + t.Errorf("the first frame must be the task: %+v", frames[0]) + } + var sawFinal bool + for _, f := range frames { + if f.StatusUpdate != nil && f.StatusUpdate.Final { + sawFinal = true + } + } + if !sawFinal { + t.Error("the stream never said it was finished") + } +} diff --git a/docs/content/docs/execution/remote-messaging.mdx b/docs/content/docs/execution/remote-messaging.mdx index feeec6b..4f8c0e1 100644 --- a/docs/content/docs/execution/remote-messaging.mdx +++ b/docs/content/docs/execution/remote-messaging.mdx @@ -128,18 +128,80 @@ sqlite answers a concurrent writer with `SQLITE_BUSY` unless told to wait. Without the timeout you will see run writes fail under load for no reason you can see. Postgres needs nothing here. -## What is not implemented +## All three bindings + +A2A defines three, all equivalent, and cortex serves all three over one +service. Whatever a rule says about scope or sender namespacing holds on every +binding, because the rule lives in the service and the bindings only translate +formats. + +**JSON-RPC 2.0** is the default and needs nothing beyond `svc.Handler()`. + +**HTTP+JSON** is `svc.RESTHandler()`, at the protocol's own paths: +`POST /{tenant}/message:send`, `GET /{tenant}/tasks/{id}`, +`POST /{tenant}/tasks/{id}:cancel`. Errors say the same thing twice, as an HTTP +status and as the protocol's numeric code, so a client that reads only one of +them still knows what happened. + +**gRPC** lives in its own module, because grpc-go and protobuf are a large +dependency graph and a host serving JSON-RPC should not inherit it: + +```bash +go get github.com/xraph/cortex/a2aremote/grpcbind +``` + +```go +srv := grpc.NewServer() +grpcbind.Register(srv, svc) +``` + +Its types are generated from the normative `a2a.proto`, vendored verbatim, so +the wire format is the specification's rather than an approximation. + +Advertise only what you actually serve. A card is a promise, and a client that +picks `GRPC` because the card offered it has nowhere to go when nothing answers: + +```go +Card: a2aremote.CardOptions{ + BaseURL: "https://agents.example.com/a2a", + Bindings: []string{a2aremote.BindingJSONRPC, a2aremote.BindingREST, a2aremote.BindingGRPC}, + URLs: map[string]string{a2aremote.BindingGRPC: "grpc.example.com:443"}, +}, +``` + +## Streaming + +Off by default, on with one option: + +```go +Service: a2aremote.Options{ + Streaming: true, + StreamPoll: 500 * time.Millisecond, +}, +``` + +`SendStreamingMessage` and `SubscribeToTask` then work on every binding, over +server-sent events for the two HTTP ones and native server streaming for gRPC. +A subscriber gets the task immediately, then each status transition, and the +last one carries the output with `final` set so a client knows to stop reading. + +This is task-level streaming, which is what A2A's streaming is: the events are +`TaskStatusUpdateEvent` and `TaskArtifactUpdateEvent`, not tokens. Cortex learns +of a transition by re-reading the task on `StreamPoll`, so that interval bounds +how stale an update can be and nothing else. A client that disconnects ends its +own subscription; the task carries on without it. + +## What is still not implemented Declared in the card rather than discovered by failing: -- **Streaming.** `SendStreamingMessage` and `SubscribeToTask` return - `UnsupportedOperationError`, and `capabilities.streaming` is false. - **Push notifications.** The config methods return - `PushNotificationNotSupportedError`. + `PushNotificationNotSupportedError`, and `capabilities.pushNotifications` is + false. - **The extended agent card.** `GetExtendedAgentCard` returns `ExtendedAgentCardNotConfiguredError`. -- **gRPC and HTTP+JSON bindings.** Only `JSONRPC` appears in the card's - `supportedInterfaces`. +- **Agent card signatures.** Cards are unsigned, which matters for a public + registry and not for peers you configured by hand. One behaviour worth knowing about rather than discovering: a peer that quotes a `contextId` from its own database gets a fresh conversation on this side, with From eda789b24cdee3a92736d621144d17831be52169 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 22:10:56 -0500 Subject: [PATCH 46/50] fix(a2a): reclaim deliveries a dead process left behind A delivery claimed by a worker that then died stayed marked delivering forever. Nothing wedged, since an ask resolves on its deadline either way, but an inform caught in that window was lost. Reclaiming queues the row rather than delivering it, so a recovered message takes the ordinary path with the ordinary claim and a worker that turns out to be alive loses the race instead of duplicating the work. The TTL is fifteen minutes because a remote delivery legitimately holds its claim while the peer is polled. --- CHANGELOG.md | 22 +++-- a2a/delivery.go | 16 ++-- a2a/dispatcher_test.go | 104 ++++++++++++++++++++++ a2a/memstore_test.go | 35 ++++++++ a2a/options.go | 18 +++- a2a/options_test.go | 5 +- a2a/store.go | 8 ++ a2a/sweep.go | 12 +++ docs/content/docs/execution/messaging.mdx | 13 +++ engine/a2a_wiring.go | 17 ++++ store/mongo/a2a.go | 44 +++++++++ store/mongo/models.go | 2 + store/postgres/a2a.go | 48 ++++++++++ store/postgres/models.go | 1 + store/sqlite/a2a.go | 49 ++++++++++ store/sqlite/models.go | 1 + store/storetest/conformance.go | 66 ++++++++++++++ 17 files changed, 448 insertions(+), 13 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 462984b..af2cf03 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -198,6 +198,23 @@ the output with `final` set. backoff. On sqlite, where a concurrent write is answered with SQLITE_BUSY, that race is ordinary rather than exceptional. +### Fixed: abandoned deliveries are reclaimed + +A delivery claimed by a process that then died used to stay marked +`delivering` forever. Nothing wedged, because an ask resolves on its +deadline either way, but an `inform` caught in that window was lost. + +The dispatcher now puts those rows back in the queue, at startup and on +its sweep. `DeliveryClaimTTL` is how long a claim may sit before it is +assumed abandoned, and it defaults to fifteen minutes: a remote delivery +legitimately holds its claim while the peer is polled, and reclaiming +one somebody is still carrying would deliver the message twice. + +Reclaiming queues a row rather than delivering it, so a recovered +message takes the ordinary path with the ordinary claim, and a worker +that turns out to be alive after all loses the race instead of +duplicating the work. + ### Known gaps - **Sqlite needs a busy timeout** once messaging is on. The dispatcher @@ -207,11 +224,6 @@ the output with `final` set. - Conversations are not stitched across engines. A peer quoting a `contextId` from its own database gets a fresh conversation on this side, with its id kept as metadata. -- A delivery claimed by a process that then dies stays marked - `delivering` and is not redriven. Nothing wedges, because an ask - resolves on its deadline either way, but an informative message caught - in that window is lost. The delivery row already carries `claimed_at` - for the reclaim to key on. - Mongo was written against the conformance suite but never executed: the environment this landed in could not start a mongo container, and could not before this branch either. Sqlite and postgres both run the diff --git a/a2a/delivery.go b/a2a/delivery.go index d86c52f..3448fa6 100644 --- a/a2a/delivery.go +++ b/a2a/delivery.go @@ -34,12 +34,16 @@ var ErrDeliveryNotFound = errors.New("cortex: a2a: delivery not found") // B" an answerable question. type Delivery struct { cortex.Entity - ID id.DeliveryID `json:"id"` - Scope cortex.Scope `json:"scope"` - MessageID id.MessageID `json:"message_id"` - Receiver Address `json:"receiver"` - State string `json:"state"` - Error string `json:"error,omitempty"` + ID id.DeliveryID `json:"id"` + Scope cortex.Scope `json:"scope"` + MessageID id.MessageID `json:"message_id"` + Receiver Address `json:"receiver"` + State string `json:"state"` + Error string `json:"error,omitempty"` + // ClaimedAt is when a worker took the row. It is what tells a + // reclaim the difference between a delivery in flight and one a dead + // process was carrying. + ClaimedAt *time.Time `json:"claimed_at,omitempty"` DeliveredAt *time.Time `json:"delivered_at,omitempty"` ReadAt *time.Time `json:"read_at,omitempty"` RunID id.AgentRunID `json:"run_id,omitempty"` // the run a directive started diff --git a/a2a/dispatcher_test.go b/a2a/dispatcher_test.go index 685371e..7803a07 100644 --- a/a2a/dispatcher_test.go +++ b/a2a/dispatcher_test.go @@ -197,3 +197,107 @@ func TestATransientStoreErrorIsRetriedPromptly(t *testing.T) { t.Fatal("a delivery stranded by one busy claim was never retried") } } + +// A process that dies mid-delivery leaves its claim behind. Nothing +// wedges, because an ask resolves on its deadline either way, but an +// informative caught in that window is simply lost unless somebody +// reclaims the row. +func TestStaleClaimsAreReclaimed(t *testing.T) { + st, runner := newMemStore(), newFakeRunner() + clk := &fakeClock{now: testNow} + st.useClock(clk) + b, err := NewBus(BusConfig{ + Store: st, Runner: runner, Clock: clk, Synchronous: true, + Options: Options{Workers: 1, DeliveryClaimTTL: 10 * time.Minute}, + }) + if err != nil { + t.Fatalf("NewBus: %v", err) + } + ctx := testCtx() + + if _, sendErr := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "do not lose me", + }); sendErr != nil { + t.Fatalf("Send: %v", sendErr) + } + + // A worker claims the row and the process dies before delivering it. + queued, _ := st.ListQueuedDeliveries(ctx, 10) + if _, claimErr := st.ClaimDelivery(ctx, queued[0].ID); claimErr != nil { + t.Fatalf("ClaimDelivery: %v", claimErr) + } + if left, _ := st.ListQueuedDeliveries(ctx, 10); len(left) != 0 { + t.Fatal("the claim should have taken the row out of the queue") + } + + // Too soon: a delivery legitimately in flight must not be taken away + // from the worker carrying it. + clk.advance(time.Minute) + n, err := b.ReclaimStaleDeliveries(ctx) + if err != nil { + t.Fatalf("ReclaimStaleDeliveries: %v", err) + } + if n != 0 { + t.Fatalf("reclaimed %d rows that were still in flight", n) + } + + // Past the TTL, nobody is coming back for it. + clk.advance(11 * time.Minute) + n, err = b.ReclaimStaleDeliveries(ctx) + if err != nil { + t.Fatalf("ReclaimStaleDeliveries: %v", err) + } + if n != 1 { + t.Fatalf("reclaimed %d, want the abandoned row", n) + } + + // Reclaiming puts it back in the queue rather than delivering it + // directly, so it takes the ordinary path with its ordinary claim. + again, _ := st.ListQueuedDeliveries(ctx, 10) + if len(again) != 1 { + t.Fatalf("%d rows queued after a reclaim, want 1", len(again)) + } + if _, drainErr := b.Drain(ctx); drainErr != nil { + t.Fatalf("Drain: %v", drainErr) + } + inbox, _ := st.ListInbox(ctx, "w1", InboxFilter{UnreadOnly: true}) + if len(inbox) != 1 { + t.Fatalf("the reclaimed message never arrived: %+v", inbox) + } +} + +// A delivered row is finished, and reclaiming it would deliver the same +// message twice. +func TestReclaimLeavesFinishedRowsAlone(t *testing.T) { + st, runner := newMemStore(), newFakeRunner() + clk := &fakeClock{now: testNow} + st.useClock(clk) + b, err := NewBus(BusConfig{ + Store: st, Runner: runner, Clock: clk, Synchronous: true, + Options: Options{Workers: 1, DeliveryClaimTTL: time.Minute}, + }) + if err != nil { + t.Fatalf("NewBus: %v", err) + } + ctx := testCtx() + + if _, sendErr := b.Send(ctx, SendParams{ + Sender: Address{Agent: "planner"}, Receivers: []Address{{Agent: "w1"}}, + Performative: Inform, Content: "already arrived", + }); sendErr != nil { + t.Fatalf("Send: %v", sendErr) + } + if _, drainErr := b.Drain(ctx); drainErr != nil { + t.Fatalf("Drain: %v", drainErr) + } + + clk.advance(time.Hour) + n, err := b.ReclaimStaleDeliveries(ctx) + if err != nil { + t.Fatalf("ReclaimStaleDeliveries: %v", err) + } + if n != 0 { + t.Fatalf("reclaimed %d finished rows, which would deliver them twice", n) + } +} diff --git a/a2a/memstore_test.go b/a2a/memstore_test.go index bfb8460..f1e060e 100644 --- a/a2a/memstore_test.go +++ b/a2a/memstore_test.go @@ -48,6 +48,20 @@ type memStore struct { // claimErrs are returned by the next N ClaimDelivery calls, standing // in for a store that is momentarily busy. claimErrs int + + // now is the store's own clock. A real backend stamps a claim from + // the database's time, so the double needs its own source too, and a + // reclaim test has to be able to move it. + now func() time.Time +} + +func (s *memStore) useClock(c *fakeClock) { s.now = c.Now } + +func (s *memStore) stamp() time.Time { + if s.now != nil { + return s.now() + } + return time.Now().UTC() } func (s *memStore) failNextClaims(n int) { @@ -192,6 +206,8 @@ func (s *memStore) ClaimDelivery(_ context.Context, deliveryID id.DeliveryID) (* return nil, ErrDeliveryAlreadyClaimed } d.State = DeliveryDelivering + now := s.stamp() + d.ClaimedAt = &now cp := *d return &cp, nil } @@ -241,6 +257,25 @@ func (s *memStore) ListQueuedDeliveries(_ context.Context, limit int) ([]*Delive return out, nil } +func (s *memStore) ReclaimStaleDeliveries(_ context.Context, olderThan time.Time, limit int) (int, error) { + s.mu.Lock() + defer s.mu.Unlock() + var n int + for _, key := range s.deliveryIDs { + d := s.deliveries[key] + if d.State != DeliveryDelivering || d.ClaimedAt == nil || !d.ClaimedAt.Before(olderThan) { + continue + } + d.State = DeliveryQueued + d.ClaimedAt = nil + n++ + if limit > 0 && n >= limit { + break + } + } + return n, nil +} + func (s *memStore) MarkDeliveryRead(_ context.Context, deliveryID id.DeliveryID) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/a2a/options.go b/a2a/options.go index 506e915..64e7463 100644 --- a/a2a/options.go +++ b/a2a/options.go @@ -10,6 +10,15 @@ const ( DefaultWorkers = 4 DefaultReplyBy = 5 * time.Minute DefaultSweepInterval = 30 * time.Second + // DefaultDeliveryClaimTTL is how long a claimed delivery may sit + // before it is assumed abandoned. + // + // It is generous on purpose. A remote delivery legitimately holds its + // claim while the peer is polled, which the client bounds at two + // minutes, and reclaiming a delivery somebody is still carrying would + // deliver the same message twice. Fifteen minutes is well past any + // honest delivery and well short of a human noticing. + DefaultDeliveryClaimTTL = 15 * time.Minute ) // Options tunes the messaging subsystem. @@ -22,8 +31,12 @@ type Options struct { Workers int // DefaultReplyBy is the deadline stamped on an ask that names none. DefaultReplyBy time.Duration - // SweepInterval is how often overdue asks are resolved into failures. + // SweepInterval is how often overdue asks are resolved into failures, + // and how often abandoned deliveries are reclaimed. SweepInterval time.Duration + // DeliveryClaimTTL is how long a claimed delivery may sit before it + // is treated as abandoned by a process that died mid-delivery. + DeliveryClaimTTL time.Duration } func (o Options) withDefaults() Options { @@ -39,5 +52,8 @@ func (o Options) withDefaults() Options { if o.SweepInterval <= 0 { o.SweepInterval = DefaultSweepInterval } + if o.DeliveryClaimTTL <= 0 { + o.DeliveryClaimTTL = DefaultDeliveryClaimTTL + } return o } diff --git a/a2a/options_test.go b/a2a/options_test.go index aaa524a..ca138a1 100644 --- a/a2a/options_test.go +++ b/a2a/options_test.go @@ -19,10 +19,13 @@ func TestOptionsDefaults(t *testing.T) { if got.SweepInterval != DefaultSweepInterval { t.Errorf("SweepInterval = %s, want %s", got.SweepInterval, DefaultSweepInterval) } + if got.DeliveryClaimTTL != DefaultDeliveryClaimTTL { + t.Errorf("DeliveryClaimTTL = %s, want %s", got.DeliveryClaimTTL, DefaultDeliveryClaimTTL) + } } func TestOptionsKeepExplicitValues(t *testing.T) { - in := Options{HopCeiling: 2, Workers: 1, DefaultReplyBy: time.Second, SweepInterval: time.Minute} + in := Options{HopCeiling: 2, Workers: 1, DefaultReplyBy: time.Second, SweepInterval: time.Minute, DeliveryClaimTTL: time.Hour} if got := in.withDefaults(); got != in { t.Fatalf("withDefaults changed explicit values: %+v", got) } diff --git a/a2a/store.go b/a2a/store.go index 774e97d..1e1bd1e 100644 --- a/a2a/store.go +++ b/a2a/store.go @@ -70,6 +70,14 @@ type Store interface { ListInbox(ctx context.Context, agentName string, filter InboxFilter) ([]*Delivery, error) ListQueuedDeliveries(ctx context.Context, limit int) ([]*Delivery, error) MarkDeliveryRead(ctx context.Context, deliveryID id.DeliveryID) error + // ReclaimStaleDeliveries puts abandoned deliveries back in the queue + // and reports how many it moved. + // + // A delivery is abandoned when it was claimed before olderThan and + // never finished, which is what a process dying mid-delivery leaves + // behind. Like the queued read, it deliberately crosses scopes: the + // dispatcher runs per process rather than per tenant. + ReclaimStaleDeliveries(ctx context.Context, olderThan time.Time, limit int) (int, error) CreatePendingAsk(ctx context.Context, a *PendingAsk) error // ClaimPendingAsk takes ownership of the ask carrying replyWith. It diff --git a/a2a/sweep.go b/a2a/sweep.go index 67edf65..6e76fe7 100644 --- a/a2a/sweep.go +++ b/a2a/sweep.go @@ -8,6 +8,18 @@ import ( // sweepBatch caps one pass so a large backlog cannot hold a worker forever. const sweepBatch = 100 +// ReclaimStaleDeliveries puts back any delivery a dead process was +// carrying, and reports how many it moved. +// +// It puts them back in the queue rather than delivering them here, so a +// reclaimed message takes the ordinary path with the ordinary claim, and +// a worker that turns out to still be alive loses the race rather than +// duplicating the delivery. +func (b *Bus) ReclaimStaleDeliveries(ctx context.Context) (int, error) { + cutoff := b.clock.Now().Add(-b.opts.DeliveryClaimTTL) + return b.store.ReclaimStaleDeliveries(ctx, cutoff, sweepBatch) +} + // SweepExpiredAsks resolves every ask whose deadline has passed into a // timeout failure and resumes the run waiting on it, returning how many it // resolved. diff --git a/docs/content/docs/execution/messaging.mdx b/docs/content/docs/execution/messaging.mdx index 5fa8178..677e79d 100644 --- a/docs/content/docs/execution/messaging.mdx +++ b/docs/content/docs/execution/messaging.mdx @@ -154,6 +154,19 @@ suspension sweep fails a run nobody answered in time, which is the wrong verb here. A peer that went quiet is something the asking agent can react to, and killing the run throws that away. +## When a process dies + +Whatever was in flight is picked up by the next one. A run waiting on a +peer is a row rather than a goroutine, so it resumes where it stopped. A +message that was queued but never carried is redriven at startup. And a +message a dead worker had claimed is put back in the queue once its claim +goes stale, which `DeliveryClaimTTL` sets and which defaults to fifteen +minutes. + +That default is generous on purpose. A remote delivery holds its claim +while the peer is polled, and reclaiming one somebody is still carrying +would deliver the same message twice. + ## When things go wrong A peer's problem reaches the asker as something it can read, never as a crash. diff --git a/engine/a2a_wiring.go b/engine/a2a_wiring.go index 8a7d724..82c205d 100644 --- a/engine/a2a_wiring.go +++ b/engine/a2a_wiring.go @@ -119,6 +119,14 @@ func (e *Engine) startA2A(ctx context.Context) { if e.a2a == nil { return } + // Startup is when abandoned deliveries are most likely: whatever was + // in flight when the last process stopped is still marked as being + // carried. Reclaiming before the redrive means the redrive finds them. + if n, err := e.a2a.ReclaimStaleDeliveries(ctx); err != nil { + e.logger.Warn("cortex: a2a delivery reclaim failed", log.Error(err)) + } else if n > 0 { + e.logger.Info("cortex: a2a reclaimed abandoned deliveries", log.Int("count", n)) + } if n, err := e.a2a.Redrive(ctx); err != nil { e.logger.Warn("cortex: a2a redrive failed", log.Error(err)) } else if n > 0 { @@ -157,6 +165,15 @@ func (e *Engine) startAskSweep(ctx context.Context) { if _, err := e.a2a.SweepExpiredAsks(sweepCtx); err != nil { e.logger.Warn("cortex: a2a ask sweep failed", log.Error(err)) } + // A delivery whose worker died is put back in the queue + // on the same pass. Nothing wedges without this, because + // an ask resolves on its deadline either way, but an + // informative caught mid-delivery would simply be lost. + if n, err := e.a2a.ReclaimStaleDeliveries(sweepCtx); err != nil { + e.logger.Warn("cortex: a2a delivery reclaim failed", log.Error(err)) + } else if n > 0 { + e.logger.Info("cortex: a2a reclaimed abandoned deliveries", log.Int("count", n)) + } } } }() diff --git a/store/mongo/a2a.go b/store/mongo/a2a.go index dc82c1b..626b01b 100644 --- a/store/mongo/a2a.go +++ b/store/mongo/a2a.go @@ -266,6 +266,7 @@ func (s *Store) UpdateDelivery(ctx context.Context, d *a2a.Delivery) error { set := bson.M{ "state": m.State, "error": m.Error, + "claimed_at": m.ClaimedAt, "delivered_at": m.DeliveredAt, "read_at": m.ReadAt, "run_id": m.RunID, @@ -393,6 +394,49 @@ func (s *Store) ListQueuedDeliveries(ctx context.Context, limit int) ([]*a2a.Del return a2aDeliveriesFromModels(models) } +// ReclaimStaleDeliveries puts abandoned deliveries back in the queue. +// +// Like the queued read it crosses scopes deliberately: the dispatcher +// runs per process rather than per tenant, and a document stranded by a +// dead worker belongs to whoever is alive now. The claimed_at filter is +// what keeps a delivery somebody is still carrying out of it, which is +// the difference between recovering a message and delivering it twice. +func (s *Store) ReclaimStaleDeliveries(ctx context.Context, olderThan time.Time, limit int) (int, error) { + filter := bson.M{ + "state": a2a.DeliveryDelivering, + "claimed_at": bson.M{"$ne": nil, "$lt": olderThan.UTC()}, + } + + // UpdateMany has no limit, so the batch is bounded by reading the ids + // first. The state filter stays on the update, so a worker that comes + // back to life still wins its own document. + var models []a2aDeliveryModel + q := s.mdb.NewFind(&models).Filter(filter).Sort(bson.D{{Key: "claimed_at", Value: 1}}) + if limit > 0 { + q = q.Limit(int64(limit)) + } + if err := q.Scan(ctx); err != nil { + return 0, fmt.Errorf("cortex/mongo: list stale a2a deliveries: %w", err) + } + + var n int + for i := range models { + res, err := s.mdb.NewUpdate((*a2aDeliveryModel)(nil)). + Filter(bson.M{"_id": models[i].ID, "state": a2a.DeliveryDelivering}). + SetUpdate(bson.M{"$set": bson.M{ + "state": a2a.DeliveryQueued, + "claimed_at": nil, + "updated_at": now(), + }}). + Exec(ctx) + if err != nil { + return n, fmt.Errorf("cortex/mongo: reclaim a2a delivery: %w", err) + } + n += int(res.ModifiedCount()) + } + return n, nil +} + func (s *Store) MarkDeliveryRead(ctx context.Context, deliveryID id.DeliveryID) error { scope := cortex.ScopeFromContext(ctx) if scope.IsZero() { diff --git a/store/mongo/models.go b/store/mongo/models.go index e3c5cd1..6272c89 100644 --- a/store/mongo/models.go +++ b/store/mongo/models.go @@ -1416,6 +1416,7 @@ func a2aDeliveryToModel(d *a2a.Delivery) *a2aDeliveryModel { ReceiverNode: d.Receiver.Node, State: d.State, Error: d.Error, + ClaimedAt: d.ClaimedAt, DeliveredAt: d.DeliveredAt, ReadAt: d.ReadAt, RunID: d.RunID.String(), @@ -1450,6 +1451,7 @@ func a2aDeliveryFromModel(m *a2aDeliveryModel) (*a2a.Delivery, error) { Receiver: a2a.Address{Agent: m.ReceiverAgent, Node: m.ReceiverNode}, State: m.State, Error: m.Error, + ClaimedAt: m.ClaimedAt, DeliveredAt: m.DeliveredAt, ReadAt: m.ReadAt, } diff --git a/store/postgres/a2a.go b/store/postgres/a2a.go index d3d61bb..8f1b3cd 100644 --- a/store/postgres/a2a.go +++ b/store/postgres/a2a.go @@ -34,6 +34,7 @@ var mutableA2AConversationColumns = []string{ var mutableA2ADeliveryColumns = []string{ "state", "error", + "claimed_at", "delivered_at", "read_at", "run_id", @@ -397,6 +398,53 @@ func (s *Store) ListQueuedDeliveries(ctx context.Context, limit int) ([]*a2a.Del return out, nil } +// ReclaimStaleDeliveries puts abandoned deliveries back in the queue. +// +// Like ListQueuedDeliveries it crosses scopes deliberately: the +// dispatcher runs per process rather than per tenant, and a row stranded +// by a dead worker belongs to whoever is alive now. The claimed_at +// predicate is what keeps a delivery somebody is still carrying out of +// it, which is the difference between recovering a message and +// delivering it twice. +func (s *Store) ReclaimStaleDeliveries(ctx context.Context, olderThan time.Time, limit int) (int, error) { + // The rows are selected first and updated by id, mirroring the + // sqlite implementation. The claim predicate stays on the update, so + // a worker that comes back to life still wins its own row. + var models []a2aDeliveryModel + q := s.pgdb.NewSelect(&models). + Where("state = ?", a2a.DeliveryDelivering). + Where("claimed_at IS NOT NULL"). + Where("claimed_at < ?", olderThan.UTC()). + OrderExpr("claimed_at ASC") + if limit > 0 { + q = q.Limit(limit) + } + if err := q.Scan(ctx); err != nil { + return 0, fmt.Errorf("cortex: list stale a2a deliveries: %w", err) + } + + var n int + now := time.Now().UTC() + for i := range models { + res, err := s.pgdb.NewUpdate((*a2aDeliveryModel)(nil)). + Set("state = ?", a2a.DeliveryQueued). + Set("claimed_at = ?", nil). + Set("updated_at = ?", now). + Where("id = ?", models[i].ID). + Where("state = ?", a2a.DeliveryDelivering). + Exec(ctx) + if err != nil { + return n, fmt.Errorf("cortex: reclaim a2a delivery: %w", err) + } + affected, rowsErr := res.RowsAffected() + if rowsErr != nil { + return n, fmt.Errorf("cortex: reclaim a2a delivery rows affected: %w", rowsErr) + } + n += int(affected) + } + return n, nil +} + func (s *Store) MarkDeliveryRead(ctx context.Context, deliveryID id.DeliveryID) error { scope := cortex.ScopeFromContext(ctx) if scope.IsZero() { diff --git a/store/postgres/models.go b/store/postgres/models.go index 5b405be..1fd33e5 100644 --- a/store/postgres/models.go +++ b/store/postgres/models.go @@ -1578,6 +1578,7 @@ func a2aDeliveryFromModel(m *a2aDeliveryModel) (*a2a.Delivery, error) { Receiver: a2a.Address{Agent: m.ReceiverAgent, Node: m.ReceiverNode}, State: m.State, Error: m.Error, + ClaimedAt: m.ClaimedAt, DeliveredAt: m.DeliveredAt, ReadAt: m.ReadAt, } diff --git a/store/sqlite/a2a.go b/store/sqlite/a2a.go index 7c025a0..111d2dc 100644 --- a/store/sqlite/a2a.go +++ b/store/sqlite/a2a.go @@ -32,6 +32,7 @@ var mutableA2AConversationColumns = []string{ var mutableA2ADeliveryColumns = []string{ "state", "error", + "claimed_at", "delivered_at", "read_at", "run_id", @@ -395,6 +396,54 @@ func (s *Store) ListQueuedDeliveries(ctx context.Context, limit int) ([]*a2a.Del return out, nil } +// ReclaimStaleDeliveries puts abandoned deliveries back in the queue. +// +// Like ListQueuedDeliveries it crosses scopes deliberately: the +// dispatcher runs per process rather than per tenant, and a row stranded +// by a dead worker belongs to whoever is alive now. The claimed_at +// predicate is what keeps a delivery somebody is still carrying out of +// it, which is the difference between recovering a message and +// delivering it twice. +func (s *Store) ReclaimStaleDeliveries(ctx context.Context, olderThan time.Time, limit int) (int, error) { + // Sqlite has no UPDATE ... LIMIT in the default build, so the rows + // are selected first and updated by id. The claim predicate is on the + // update either way, so a worker that comes back to life still wins + // its own row. + var models []a2aDeliveryModel + q := s.sdb.NewSelect(&models). + Where("state = ?", a2a.DeliveryDelivering). + Where("claimed_at IS NOT NULL"). + Where("claimed_at < ?", olderThan.UTC()). + OrderExpr("claimed_at ASC") + if limit > 0 { + q = q.Limit(limit) + } + if err := q.Scan(ctx); err != nil { + return 0, fmt.Errorf("cortex/sqlite: list stale a2a deliveries: %w", err) + } + + var n int + now := time.Now().UTC() + for i := range models { + res, err := s.sdb.NewUpdate((*a2aDeliveryModel)(nil)). + Set("state = ?", a2a.DeliveryQueued). + Set("claimed_at = ?", nil). + Set("updated_at = ?", now). + Where("id = ?", models[i].ID). + Where("state = ?", a2a.DeliveryDelivering). + Exec(ctx) + if err != nil { + return n, fmt.Errorf("cortex/sqlite: reclaim a2a delivery: %w", err) + } + affected, rowsErr := res.RowsAffected() + if rowsErr != nil { + return n, fmt.Errorf("cortex/sqlite: reclaim a2a delivery rows affected: %w", rowsErr) + } + n += int(affected) + } + return n, nil +} + func (s *Store) MarkDeliveryRead(ctx context.Context, deliveryID id.DeliveryID) error { scope := cortex.ScopeFromContext(ctx) if scope.IsZero() { diff --git a/store/sqlite/models.go b/store/sqlite/models.go index 8faf69d..3cd9b60 100644 --- a/store/sqlite/models.go +++ b/store/sqlite/models.go @@ -1586,6 +1586,7 @@ func a2aDeliveryFromModel(m *a2aDeliveryModel) (*a2a.Delivery, error) { Receiver: a2a.Address{Agent: m.ReceiverAgent, Node: m.ReceiverNode}, State: m.State, Error: m.Error, + ClaimedAt: m.ClaimedAt, DeliveredAt: m.DeliveredAt, ReadAt: m.ReadAt, } diff --git a/store/storetest/conformance.go b/store/storetest/conformance.go index 0c40e58..40ecdda 100644 --- a/store/storetest/conformance.go +++ b/store/storetest/conformance.go @@ -98,6 +98,7 @@ func Conformance(t *testing.T, newStore func(t *testing.T) store.Store) { t.Run("A2AClaimDeliveryConcurrency", func(t *testing.T) { testA2AClaimDeliveryConcurrency(t, newStore) }) t.Run("A2AExpiredAsks", func(t *testing.T) { testA2AExpiredAsks(t, newStore) }) t.Run("A2AScopeIsolation", func(t *testing.T) { testA2AScopeIsolation(t, newStore) }) + t.Run("A2AReclaimStaleDeliveries", func(t *testing.T) { testA2AReclaimStaleDeliveries(t, newStore) }) } // ────────────────────────────────────────────────── @@ -4429,3 +4430,68 @@ func testA2AScopeIsolation(t *testing.T, newStore func(t *testing.T) store.Store t.Errorf("another scope lists %d messages, want 0", len(msgs)) } } + +// testA2AReclaimStaleDeliveries proves a process dying mid-delivery does +// not lose the message. The claim age is the only thing separating a +// delivery in flight from one nobody is coming back for, so both sides of +// that line are asserted. +func testA2AReclaimStaleDeliveries(t *testing.T, newStore func(t *testing.T) store.Store) { + s := newStore(t) + ctx := ctxWithScope("acme") + + d := &a2a.Delivery{ + Entity: cortex.NewEntity(), ID: id.NewDeliveryID(), MessageID: id.NewMessageID(), + Receiver: a2a.Address{Agent: "worker"}, State: a2a.DeliveryQueued, + } + if err := s.CreateDelivery(ctx, d); err != nil { + t.Fatalf("CreateDelivery: %v", err) + } + if _, err := s.ClaimDelivery(ctx, d.ID); err != nil { + t.Fatalf("ClaimDelivery: %v", err) + } + + // A delivery claimed a moment ago is in flight, and taking it away + // from the worker carrying it would deliver the message twice. + n, err := s.ReclaimStaleDeliveries(ctx, time.Now().UTC().Add(-time.Hour), 10) + if err != nil { + t.Fatalf("ReclaimStaleDeliveries: %v", err) + } + if n != 0 { + t.Fatalf("reclaimed %d rows that were still in flight", n) + } + + // Past the cutoff, nobody is coming back for it. + // + // The count is a floor rather than an equality: a reclaim is + // deliberately process-wide, so it sweeps up whatever else a + // neighbouring test abandoned in the same database. What this test + // owns is its own row, which is asserted directly below. + n, err = s.ReclaimStaleDeliveries(ctx, time.Now().UTC().Add(time.Hour), 10) + if err != nil { + t.Fatalf("ReclaimStaleDeliveries: %v", err) + } + if n < 1 { + t.Fatalf("reclaimed %d, want at least the abandoned row", n) + } + + // Reclaiming queues it rather than delivering it, so it takes the + // ordinary path with the ordinary claim. + queued, err := s.ListQueuedDeliveries(ctx, 10) + if err != nil { + t.Fatalf("ListQueuedDeliveries: %v", err) + } + var found bool + for _, q := range queued { + if q.ID == d.ID { + found = true + } + } + if !found { + t.Fatalf("the reclaimed delivery is not back in the queue: %+v", queued) + } + + // It is claimable again, which is the whole point. + if _, err := s.ClaimDelivery(ctx, d.ID); err != nil { + t.Fatalf("a reclaimed delivery must be claimable: %v", err) + } +} From f0cf7273290827e22ca633be1e24a0027db2921f Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 22:14:18 -0500 Subject: [PATCH 47/50] feat(a2a): keep a peer's thread together across turns A contextId from a peer names a conversation in the peer's own database, so an inbound thread used to open a new conversation on this side every turn. That was worse than untidy. A new conversation is a new hop budget, so a peer that never reused an id could keep talking past a ceiling it should have hit. A conversation now records which remote thread it stands in for, keyed by node as well as context id: two peers can perfectly well use the same id, and joining one peer's thread to another's would leak a conversation across a trust boundary. --- a2a/bus.go | 38 +++++++++++--- a2a/conversation.go | 12 +++++ a2a/conversation_test.go | 96 ++++++++++++++++++++++++++++++++-- a2a/memstore_test.go | 13 +++++ a2a/store.go | 5 ++ a2aremote/service.go | 15 ++++-- store/mongo/a2a.go | 31 +++++++++++ store/mongo/migrations.go | 1 + store/mongo/models.go | 6 +++ store/postgres/a2a.go | 33 ++++++++++++ store/postgres/migrations.go | 3 ++ store/postgres/models.go | 24 +++++---- store/sqlite/a2a.go | 33 ++++++++++++ store/sqlite/migrations.go | 3 ++ store/sqlite/models.go | 24 +++++---- store/storetest/conformance.go | 45 ++++++++++++++++ 16 files changed, 349 insertions(+), 33 deletions(-) diff --git a/a2a/bus.go b/a2a/bus.go index d25bda6..5faf3c6 100644 --- a/a2a/bus.go +++ b/a2a/bus.go @@ -138,6 +138,14 @@ type SendParams struct { ReplyBy *time.Time OriginRunID id.AgentRunID Metadata map[string]any + + // PeerNode and PeerContext name a remote thread this message + // belongs to, for a message arriving from another engine. When they + // are set and no conversation id is, the send joins the conversation + // opened for that remote thread, or opens one and records the + // pairing. + PeerNode string + PeerContext string } // DeliveryOutcome is one receiver's result from a send. A broadcast reports @@ -234,14 +242,30 @@ func (b *Bus) resolveConversation(ctx context.Context, p SendParams, scope corte if !p.ConversationID.IsNil() { return b.store.GetConversation(ctx, p.ConversationID) } + + // A message from a peer continues that peer's thread when we already + // opened one for it. Starting fresh instead would hand the peer a + // new hop budget every turn. + if p.PeerNode != "" && p.PeerContext != "" { + existing, err := b.store.GetConversationByPeerContext(ctx, p.PeerNode, p.PeerContext) + switch { + case err == nil: + return existing, nil + case !errors.Is(err, ErrConversationNotFound): + return nil, err + } + } + conv := &Conversation{ - Entity: cortex.NewEntity(), - ID: id.NewConversationID(), - Scope: scope, - Protocol: p.Protocol, - Initiator: p.Sender, - Status: StatusOpen, - HopCeiling: b.opts.HopCeiling, + Entity: cortex.NewEntity(), + ID: id.NewConversationID(), + Scope: scope, + Protocol: p.Protocol, + Initiator: p.Sender, + Status: StatusOpen, + HopCeiling: b.opts.HopCeiling, + PeerNode: p.PeerNode, + PeerContext: p.PeerContext, } if err := b.store.CreateConversation(ctx, conv); err != nil { return nil, err diff --git a/a2a/conversation.go b/a2a/conversation.go index 65c8d9a..dcbc2ca 100644 --- a/a2a/conversation.go +++ b/a2a/conversation.go @@ -31,6 +31,18 @@ type Conversation struct { HopCeiling int `json:"hop_ceiling"` HopsUsed int `json:"hops_used"` Deadline *time.Time `json:"deadline,omitempty"` + + // PeerNode and PeerContext record the remote conversation this one + // stands in for. + // + // A contextId from a peer names a conversation in the peer's own + // database and means nothing here, so an inbound thread gets a + // conversation of ours and the pairing is written down. Without it + // every inbound message would open a new thread, and a new thread is + // a new hop budget: a peer could talk forever simply by never + // reusing an id. + PeerNode string `json:"peer_node,omitempty"` + PeerContext string `json:"peer_context,omitempty"` } // IsOpen reports whether the conversation still accepts messages. diff --git a/a2a/conversation_test.go b/a2a/conversation_test.go index 554479f..17cc3a0 100644 --- a/a2a/conversation_test.go +++ b/a2a/conversation_test.go @@ -14,14 +14,18 @@ func testCtx() context.Context { }) } -func TestMemStoreRoundTripsAConversation(t *testing.T) { - ctx, s := testCtx(), newMemStore() - c := &Conversation{ +func newTestConversation(initiator string) *Conversation { + return &Conversation{ ID: id.NewConversationID(), Status: StatusOpen, HopCeiling: 8, - Initiator: Address{Agent: "planner"}, + Initiator: Address{Agent: initiator}, } +} + +func TestMemStoreRoundTripsAConversation(t *testing.T) { + ctx, s := testCtx(), newMemStore() + c := newTestConversation("planner") if err := s.CreateConversation(ctx, c); err != nil { t.Fatalf("CreateConversation: %v", err) } @@ -133,3 +137,87 @@ func TestListQueuedDeliveriesIsWhatRedriveReadsFrom(t *testing.T) { t.Fatalf("redrive must see only queued rows, got %+v", got) } } + +// A peer's contextId names a conversation in the peer's database, so the +// pairing has to be recorded on this side or every inbound message starts +// a new thread. That is worse than untidy: a fresh conversation is a +// fresh hop budget, so a peer could talk forever by never reusing an id. +func TestConversationsCanBeFoundByAPeersContext(t *testing.T) { + ctx, s := testCtx(), newMemStore() + + conv := newTestConversation("planner") + conv.PeerNode = "peer.example" + conv.PeerContext = "their-context-1" + if err := s.CreateConversation(ctx, conv); err != nil { + t.Fatalf("CreateConversation: %v", err) + } + + got, err := s.GetConversationByPeerContext(ctx, "peer.example", "their-context-1") + if err != nil { + t.Fatalf("GetConversationByPeerContext: %v", err) + } + if got.ID != conv.ID { + t.Fatalf("found %s, want %s", got.ID, conv.ID) + } +} + +// Two peers can perfectly well use the same context id, and joining one +// peer's thread to another's would leak a conversation across a trust +// boundary. +func TestAPeersContextIsNamespacedByItsNode(t *testing.T) { + ctx, s := testCtx(), newMemStore() + + conv := newTestConversation("planner") + conv.PeerNode = "peer-a" + conv.PeerContext = "shared-id" + if err := s.CreateConversation(ctx, conv); err != nil { + t.Fatalf("CreateConversation: %v", err) + } + + if _, err := s.GetConversationByPeerContext(ctx, "peer-b", "shared-id"); !errorsIs(err, ErrConversationNotFound) { + t.Fatalf("err = %v, want not found: one peer's id must not reach another's thread", err) + } +} + +func TestAnUnknownPeerContextIsNotFound(t *testing.T) { + ctx, s := testCtx(), newMemStore() + if _, err := s.GetConversationByPeerContext(ctx, "peer.example", "never-seen"); !errorsIs(err, ErrConversationNotFound) { + t.Fatalf("err = %v, want ErrConversationNotFound", err) + } +} + +// A message that names a peer context joins the conversation opened for +// it, so the hop budget carries across turns rather than resetting. +func TestSendJoinsAConversationByPeerContext(t *testing.T) { + b, st, _, _, _, _ := newTestBus(t) + ctx := testCtx() + + first, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "worker", Node: "peer.example"}, Receivers: []Address{{Agent: "planner"}}, + Performative: Inform, Content: "one", + PeerNode: "peer.example", PeerContext: "their-context-1", + }) + if err != nil { + t.Fatalf("first Send: %v", err) + } + + second, err := b.Send(ctx, SendParams{ + Sender: Address{Agent: "worker", Node: "peer.example"}, Receivers: []Address{{Agent: "planner"}}, + Performative: Inform, Content: "two", + PeerNode: "peer.example", PeerContext: "their-context-1", + }) + if err != nil { + t.Fatalf("second Send: %v", err) + } + if second.ConversationID != first.ConversationID { + t.Fatal("the second message opened a new thread rather than continuing the peer's") + } + + msg, err := st.GetMessage(ctx, second.MessageID) + if err != nil { + t.Fatalf("GetMessage: %v", err) + } + if msg.Hops != 2 { + t.Fatalf("Hops = %d, want 2: a peer must not get a fresh budget by continuing a thread", msg.Hops) + } +} diff --git a/a2a/memstore_test.go b/a2a/memstore_test.go index f1e060e..702a995 100644 --- a/a2a/memstore_test.go +++ b/a2a/memstore_test.go @@ -134,6 +134,19 @@ func (s *memStore) GetConversation(_ context.Context, convID id.ConversationID) return &cp, nil } +func (s *memStore) GetConversationByPeerContext(_ context.Context, node, peerContext string) (*Conversation, error) { + s.mu.Lock() + defer s.mu.Unlock() + for _, key := range s.convIDs { + c := s.convs[key] + if c.PeerNode == node && c.PeerContext == peerContext && node != "" && peerContext != "" { + cp := *c + return &cp, nil + } + } + return nil, ErrConversationNotFound +} + func (s *memStore) UpdateConversation(_ context.Context, c *Conversation) error { s.mu.Lock() defer s.mu.Unlock() diff --git a/a2a/store.go b/a2a/store.go index 1e1bd1e..e4dbe12 100644 --- a/a2a/store.go +++ b/a2a/store.go @@ -54,6 +54,11 @@ type Store interface { CreateConversation(ctx context.Context, c *Conversation) error GetConversation(ctx context.Context, convID id.ConversationID) (*Conversation, error) UpdateConversation(ctx context.Context, c *Conversation) error + // GetConversationByPeerContext finds the conversation opened for a + // remote thread. The node is part of the key because two peers can + // use the same context id, and joining one peer's thread to + // another's would leak a conversation across a trust boundary. + GetConversationByPeerContext(ctx context.Context, node, peerContext string) (*Conversation, error) ListConversations(ctx context.Context, filter *ConversationListFilter) ([]*Conversation, error) CreateDelivery(ctx context.Context, d *Delivery) error diff --git a/a2aremote/service.go b/a2aremote/service.go index fb8ed49..73cd402 100644 --- a/a2aremote/service.go +++ b/a2aremote/service.go @@ -99,12 +99,19 @@ func (s *Service) SendMessage(ctx context.Context, cred Credentials, req SendMes // The peer quoted a context id from its own world. // // A contextId names a conversation in whichever engine issued - // it, and a peer continuing a thread on its side has no idea + // it, so a peer continuing a thread on its side has no idea // whether that id means anything here. Rather than refuse a - // perfectly good message, the exchange starts a conversation on - // this side and the peer's id is kept as metadata, so a reader - // can still line the two up later. + // perfectly good message, the send is retried against the + // conversation this engine keeps for that remote thread, opening + // one if this is the first message of it. + // + // The pairing is what makes the thread continue rather than + // fragment, and fragmenting would matter for more than tidiness: + // a new conversation is a new hop budget, so a peer that never + // reused an id could talk forever. params.ConversationID = id.ConversationID{} + params.PeerNode = peer.Node + params.PeerContext = req.Message.ContextID params.Metadata = withPeerContext(params.Metadata, req.Message.ContextID) sent, err = s.gw.SendMessage(ctx, params) } diff --git a/store/mongo/a2a.go b/store/mongo/a2a.go index 626b01b..b52d308 100644 --- a/store/mongo/a2a.go +++ b/store/mongo/a2a.go @@ -145,6 +145,35 @@ func (s *Store) GetConversation(ctx context.Context, convID id.ConversationID) ( // fields are deliberately absent from the $set: a conversation's scope is // fixed at creation, and an update issued from a broader context would // otherwise widen it. +// GetConversationByPeerContext finds the conversation opened for a +// remote thread. The node is half the key: two peers can use the same +// context id, and joining one peer's thread to another's would leak a +// conversation across a trust boundary. +func (s *Store) GetConversationByPeerContext(ctx context.Context, node, peerContext string) (*a2a.Conversation, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + if node == "" || peerContext == "" { + // An empty pairing would match every locally started + // conversation, which is the opposite of what the caller meant. + return nil, a2a.ErrConversationNotFound + } + + var m a2aConversationModel + filter := bson.M{"peer_node": node, "peer_context": peerContext} + for k, v := range scopeFilter(scope, false) { + filter[k] = v + } + if err := s.mdb.NewFind(&m).Filter(filter).Scan(ctx); err != nil { + if isNoDocuments(err) { + return nil, a2a.ErrConversationNotFound + } + return nil, fmt.Errorf("cortex/mongo: get a2a conversation by peer context: %w", err) + } + return a2aConversationFromModel(&m) +} + func (s *Store) UpdateConversation(ctx context.Context, c *a2a.Conversation) error { scope := cortex.ScopeFromContext(ctx) if scope.IsZero() { @@ -159,6 +188,8 @@ func (s *Store) UpdateConversation(ctx context.Context, c *a2a.Conversation) err } set := bson.M{ "protocol": m.Protocol, + "peer_node": m.PeerNode, + "peer_context": m.PeerContext, "participants": m.Participants, "status": m.Status, "hop_ceiling": m.HopCeiling, diff --git a/store/mongo/migrations.go b/store/mongo/migrations.go index fa4922c..404be1a 100644 --- a/store/mongo/migrations.go +++ b/store/mongo/migrations.go @@ -420,6 +420,7 @@ func migrationIndexes() map[string][]mongo.IndexModel { scopeIndex, }, colA2AConversations: { + {Keys: bson.D{{Key: "peer_node", Value: 1}, {Key: "peer_context", Value: 1}, {Key: "scope_canon", Value: 1}}}, {Keys: bson.D{{Key: "status", Value: 1}}}, {Keys: bson.D{{Key: "created_at", Value: -1}}}, scopeIndex, diff --git a/store/mongo/models.go b/store/mongo/models.go index 6272c89..bfbe295 100644 --- a/store/mongo/models.go +++ b/store/mongo/models.go @@ -1332,6 +1332,8 @@ type a2aConversationModel struct { HopCeiling int `grove:"hop_ceiling" bson:"hop_ceiling"` HopsUsed int `grove:"hops_used" bson:"hops_used"` Deadline *time.Time `grove:"deadline" bson:"deadline,omitempty"` + PeerNode string `grove:"peer_node" bson:"peer_node"` + PeerContext string `grove:"peer_context" bson:"peer_context"` ScopeL0 string `grove:"scope_l0" bson:"scope_l0"` ScopeL1 string `grove:"scope_l1" bson:"scope_l1"` ScopeL2 string `grove:"scope_l2" bson:"scope_l2"` @@ -1353,6 +1355,8 @@ func a2aConversationToModel(c *a2a.Conversation) *a2aConversationModel { HopCeiling: c.HopCeiling, HopsUsed: c.HopsUsed, Deadline: c.Deadline, + PeerNode: c.PeerNode, + PeerContext: c.PeerContext, ScopeL0: l0, ScopeL1: l1, ScopeL2: l2, @@ -1383,6 +1387,8 @@ func a2aConversationFromModel(m *a2aConversationModel) (*a2a.Conversation, error HopCeiling: m.HopCeiling, HopsUsed: m.HopsUsed, Deadline: m.Deadline, + PeerNode: m.PeerNode, + PeerContext: m.PeerContext, }, nil } diff --git a/store/postgres/a2a.go b/store/postgres/a2a.go index 8f1b3cd..50e6515 100644 --- a/store/postgres/a2a.go +++ b/store/postgres/a2a.go @@ -20,6 +20,8 @@ import ( // the row's stored scope. var mutableA2AConversationColumns = []string{ "protocol", + "peer_node", + "peer_context", "participants", "status", "hop_ceiling", @@ -163,6 +165,37 @@ func (s *Store) GetConversation(ctx context.Context, convID id.ConversationID) ( return a2aConversationFromModel(m) } +// GetConversationByPeerContext finds the conversation opened for a +// remote thread. The node is half the key: two peers can use the same +// context id, and joining one peer's thread to another's would leak a +// conversation across a trust boundary. +func (s *Store) GetConversationByPeerContext(ctx context.Context, node, peerContext string) (*a2a.Conversation, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + if node == "" || peerContext == "" { + // An empty pairing would match every locally started + // conversation, which is the opposite of what the caller meant. + return nil, a2a.ErrConversationNotFound + } + + m := new(a2aConversationModel) + q := s.pgdb.NewSelect(m). + Where("peer_node = ?", node). + Where("peer_context = ?", peerContext) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + if errors.Is(err, sql.ErrNoRows) { + return nil, a2a.ErrConversationNotFound + } + return nil, fmt.Errorf("cortex: get a2a conversation by peer context: %w", err) + } + return a2aConversationFromModel(m) +} + func (s *Store) UpdateConversation(ctx context.Context, c *a2a.Conversation) error { scope := cortex.ScopeFromContext(ctx) if scope.IsZero() { diff --git a/store/postgres/migrations.go b/store/postgres/migrations.go index 0c5017b..e89b94a 100644 --- a/store/postgres/migrations.go +++ b/store/postgres/migrations.go @@ -1285,6 +1285,8 @@ CREATE TABLE IF NOT EXISTS cortex_a2a_conversations ( hop_ceiling INTEGER NOT NULL DEFAULT 0, hops_used INTEGER NOT NULL DEFAULT 0, deadline TIMESTAMPTZ, + peer_node TEXT NOT NULL DEFAULT '', + peer_context TEXT NOT NULL DEFAULT '', scope_l0 TEXT NOT NULL DEFAULT '', scope_l1 TEXT NOT NULL DEFAULT '', scope_l2 TEXT NOT NULL DEFAULT '', @@ -1295,6 +1297,7 @@ CREATE TABLE IF NOT EXISTS cortex_a2a_conversations ( ); CREATE INDEX IF NOT EXISTS idx_cortex_a2a_conversations_scope_status ON cortex_a2a_conversations (scope_canon, status); +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_conversations_peer ON cortex_a2a_conversations (scope_canon, peer_node, peer_context); CREATE TABLE IF NOT EXISTS cortex_a2a_deliveries ( id TEXT PRIMARY KEY, diff --git a/store/postgres/models.go b/store/postgres/models.go index 1fd33e5..2b8eb2c 100644 --- a/store/postgres/models.go +++ b/store/postgres/models.go @@ -1457,6 +1457,8 @@ type a2aConversationModel struct { HopCeiling int `grove:"hop_ceiling,notnull"` HopsUsed int `grove:"hops_used,notnull"` Deadline *time.Time `grove:"deadline"` + PeerNode string `grove:"peer_node,notnull"` + PeerContext string `grove:"peer_context,notnull"` ScopeL0 string `grove:"scope_l0,notnull"` ScopeL1 string `grove:"scope_l1,notnull"` ScopeL2 string `grove:"scope_l2,notnull"` @@ -1478,6 +1480,8 @@ func a2aConversationToModel(c *a2a.Conversation) *a2aConversationModel { HopCeiling: c.HopCeiling, HopsUsed: c.HopsUsed, Deadline: c.Deadline, + PeerNode: c.PeerNode, + PeerContext: c.PeerContext, ScopeL0: l0, ScopeL1: l1, ScopeL2: l2, @@ -1498,15 +1502,17 @@ func a2aConversationFromModel(m *a2aConversationModel) (*a2a.Conversation, error return nil, fmt.Errorf("a2a conversation %s: %w", convID, err) } c := &a2a.Conversation{ - Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, - ID: convID, - Scope: scope, - Protocol: m.Protocol, - Initiator: a2a.Address{Agent: m.InitiatorAgent, Node: m.InitiatorNode}, - Status: m.Status, - HopCeiling: m.HopCeiling, - HopsUsed: m.HopsUsed, - Deadline: m.Deadline, + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: convID, + Scope: scope, + Protocol: m.Protocol, + Initiator: a2a.Address{Agent: m.InitiatorAgent, Node: m.InitiatorNode}, + Status: m.Status, + HopCeiling: m.HopCeiling, + HopsUsed: m.HopsUsed, + Deadline: m.Deadline, + PeerNode: m.PeerNode, + PeerContext: m.PeerContext, } if err := unmarshalField("participants", m.Participants, &c.Participants); err != nil { return nil, err diff --git a/store/sqlite/a2a.go b/store/sqlite/a2a.go index 111d2dc..68b5481 100644 --- a/store/sqlite/a2a.go +++ b/store/sqlite/a2a.go @@ -18,6 +18,8 @@ import ( // the row's stored scope. var mutableA2AConversationColumns = []string{ "protocol", + "peer_node", + "peer_context", "participants", "status", "hop_ceiling", @@ -161,6 +163,37 @@ func (s *Store) GetConversation(ctx context.Context, convID id.ConversationID) ( return a2aConversationFromModel(m) } +// GetConversationByPeerContext finds the conversation opened for a +// remote thread. The node is half the key: two peers can use the same +// context id, and joining one peer's thread to another's would leak a +// conversation across a trust boundary. +func (s *Store) GetConversationByPeerContext(ctx context.Context, node, peerContext string) (*a2a.Conversation, error) { + scope := cortex.ScopeFromContext(ctx) + if scope.IsZero() { + return nil, cortex.ErrNoScope + } + if node == "" || peerContext == "" { + // An empty pairing would match every locally started + // conversation, which is the opposite of what the caller meant. + return nil, a2a.ErrConversationNotFound + } + + m := new(a2aConversationModel) + q := s.sdb.NewSelect(m). + Where("peer_node = ?", node). + Where("peer_context = ?", peerContext) + for _, p := range scopePredicates(scope, false) { + q = q.Where(p.Column+" = ?", p.Value) + } + if err := q.Scan(ctx); err != nil { + if isNoRows(err) { + return nil, a2a.ErrConversationNotFound + } + return nil, fmt.Errorf("cortex/sqlite: get a2a conversation by peer context: %w", err) + } + return a2aConversationFromModel(m) +} + func (s *Store) UpdateConversation(ctx context.Context, c *a2a.Conversation) error { scope := cortex.ScopeFromContext(ctx) if scope.IsZero() { diff --git a/store/sqlite/migrations.go b/store/sqlite/migrations.go index 0f34c38..250ddcd 100644 --- a/store/sqlite/migrations.go +++ b/store/sqlite/migrations.go @@ -1269,6 +1269,8 @@ CREATE TABLE IF NOT EXISTS cortex_a2a_conversations ( hop_ceiling INTEGER NOT NULL DEFAULT 0, hops_used INTEGER NOT NULL DEFAULT 0, deadline TEXT, + peer_node TEXT NOT NULL DEFAULT '', + peer_context TEXT NOT NULL DEFAULT '', scope_l0 TEXT NOT NULL DEFAULT '', scope_l1 TEXT NOT NULL DEFAULT '', scope_l2 TEXT NOT NULL DEFAULT '', @@ -1279,6 +1281,7 @@ CREATE TABLE IF NOT EXISTS cortex_a2a_conversations ( ); CREATE INDEX IF NOT EXISTS idx_cortex_a2a_conversations_scope_status ON cortex_a2a_conversations (scope_canon, status); +CREATE INDEX IF NOT EXISTS idx_cortex_a2a_conversations_peer ON cortex_a2a_conversations (scope_canon, peer_node, peer_context); CREATE TABLE IF NOT EXISTS cortex_a2a_deliveries ( id TEXT PRIMARY KEY, diff --git a/store/sqlite/models.go b/store/sqlite/models.go index 3cd9b60..387d796 100644 --- a/store/sqlite/models.go +++ b/store/sqlite/models.go @@ -1465,6 +1465,8 @@ type a2aConversationModel struct { HopCeiling int `grove:"hop_ceiling,notnull"` HopsUsed int `grove:"hops_used,notnull"` Deadline *time.Time `grove:"deadline"` + PeerNode string `grove:"peer_node,notnull"` + PeerContext string `grove:"peer_context,notnull"` ScopeL0 string `grove:"scope_l0,notnull"` ScopeL1 string `grove:"scope_l1,notnull"` ScopeL2 string `grove:"scope_l2,notnull"` @@ -1486,6 +1488,8 @@ func a2aConversationToModel(c *a2a.Conversation) *a2aConversationModel { HopCeiling: c.HopCeiling, HopsUsed: c.HopsUsed, Deadline: c.Deadline, + PeerNode: c.PeerNode, + PeerContext: c.PeerContext, ScopeL0: l0, ScopeL1: l1, ScopeL2: l2, @@ -1506,15 +1510,17 @@ func a2aConversationFromModel(m *a2aConversationModel) (*a2a.Conversation, error return nil, fmt.Errorf("a2a conversation %s: %w", convID, err) } c := &a2a.Conversation{ - Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, - ID: convID, - Scope: scope, - Protocol: m.Protocol, - Initiator: a2a.Address{Agent: m.InitiatorAgent, Node: m.InitiatorNode}, - Status: m.Status, - HopCeiling: m.HopCeiling, - HopsUsed: m.HopsUsed, - Deadline: m.Deadline, + Entity: cortex.Entity{CreatedAt: m.CreatedAt, UpdatedAt: m.UpdatedAt}, + ID: convID, + Scope: scope, + Protocol: m.Protocol, + Initiator: a2a.Address{Agent: m.InitiatorAgent, Node: m.InitiatorNode}, + Status: m.Status, + HopCeiling: m.HopCeiling, + HopsUsed: m.HopsUsed, + Deadline: m.Deadline, + PeerNode: m.PeerNode, + PeerContext: m.PeerContext, } if err := unmarshalField("participants", m.Participants, &c.Participants); err != nil { return nil, err diff --git a/store/storetest/conformance.go b/store/storetest/conformance.go index 40ecdda..ccf806a 100644 --- a/store/storetest/conformance.go +++ b/store/storetest/conformance.go @@ -99,6 +99,7 @@ func Conformance(t *testing.T, newStore func(t *testing.T) store.Store) { t.Run("A2AExpiredAsks", func(t *testing.T) { testA2AExpiredAsks(t, newStore) }) t.Run("A2AScopeIsolation", func(t *testing.T) { testA2AScopeIsolation(t, newStore) }) t.Run("A2AReclaimStaleDeliveries", func(t *testing.T) { testA2AReclaimStaleDeliveries(t, newStore) }) + t.Run("A2APeerContext", func(t *testing.T) { testA2APeerContext(t, newStore) }) } // ────────────────────────────────────────────────── @@ -4105,6 +4106,9 @@ func newA2AEnvelope(convID id.ConversationID, sender, receiver string) *a2a.Enve } } +// parameter is what stops the next one having to change the helper. +// +//nolint:unparam // every current case initiates as planner; the func newA2AConversation(initiator string) *a2a.Conversation { return &a2a.Conversation{ Entity: cortex.NewEntity(), @@ -4495,3 +4499,44 @@ func testA2AReclaimStaleDeliveries(t *testing.T, newStore func(t *testing.T) sto t.Fatalf("a reclaimed delivery must be claimable: %v", err) } } + +// testA2APeerContext proves an inbound thread can be found again. A +// peer's context id names a conversation in the peer's database, so the +// pairing is kept on this side; without it every inbound message would +// open a new conversation, and a new conversation is a new hop budget. +func testA2APeerContext(t *testing.T, newStore func(t *testing.T) store.Store) { + s := newStore(t) + ctx := ctxWithScope("acme") + + conv := newA2AConversation("planner") + conv.PeerNode = "peer.example" + conv.PeerContext = "their-context-1" + if err := s.CreateConversation(ctx, conv); err != nil { + t.Fatalf("CreateConversation: %v", err) + } + + got, err := s.GetConversationByPeerContext(ctx, "peer.example", "their-context-1") + if err != nil { + t.Fatalf("GetConversationByPeerContext: %v", err) + } + if got.ID != conv.ID { + t.Fatalf("found %s, want %s", got.ID, conv.ID) + } + + // Two peers can use the same id, and joining one peer's thread to + // another's would leak a conversation across a trust boundary. + if _, err := s.GetConversationByPeerContext(ctx, "other-peer", "their-context-1"); !errors.Is(err, a2a.ErrConversationNotFound) { + t.Errorf("cross-peer lookup: err = %v, want not found", err) + } + + // And it stays inside the scope it was written in. + if _, err := s.GetConversationByPeerContext(ctxWithScope("other"), "peer.example", "their-context-1"); !errors.Is(err, a2a.ErrConversationNotFound) { + t.Errorf("cross-scope lookup: err = %v, want not found", err) + } + + // An empty pairing must match nothing, or it would find every + // conversation this engine started on its own. + if _, err := s.GetConversationByPeerContext(ctx, "", ""); !errors.Is(err, a2a.ErrConversationNotFound) { + t.Errorf("empty pairing: err = %v, want not found", err) + } +} From 4e7b4c51cc4d6852823ad3ca62a38e740020097e Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Wed, 26 Aug 2026 22:14:30 -0500 Subject: [PATCH 48/50] docs: record the reclaim and thread-stitching fixes --- CHANGELOG.md | 16 +++++++++++++--- docs/content/docs/execution/remote-messaging.mdx | 8 +++++--- 2 files changed, 18 insertions(+), 6 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index af2cf03..a440567 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -215,15 +215,25 @@ message takes the ordinary path with the ordinary claim, and a worker that turns out to be alive after all loses the race instead of duplicating the work. +### Fixed: a peer's thread stays together + +An inbound `contextId` names a conversation in the peer's own database, +and cortex used to open a new conversation for every inbound message +because of it. That was worse than untidy: a new conversation is a new +hop budget, so a peer that never reused an id could keep talking past a +ceiling it should have hit. + +A conversation now records which remote thread it stands in for, keyed +by node as well as context id. Two peers can use the same id, and +joining one peer's thread to another's would leak a conversation across +a trust boundary. + ### Known gaps - **Sqlite needs a busy timeout** once messaging is on. The dispatcher writes while your runs write, and sqlite refuses a concurrent writer rather than waiting unless told to. Open with `cortex.db?_pragma=busy_timeout(5000)`. -- Conversations are not stitched across engines. A peer quoting a - `contextId` from its own database gets a fresh conversation on this - side, with its id kept as metadata. - Mongo was written against the conformance suite but never executed: the environment this landed in could not start a mongo container, and could not before this branch either. Sqlite and postgres both run the diff --git a/docs/content/docs/execution/remote-messaging.mdx b/docs/content/docs/execution/remote-messaging.mdx index 4f8c0e1..f582069 100644 --- a/docs/content/docs/execution/remote-messaging.mdx +++ b/docs/content/docs/execution/remote-messaging.mdx @@ -203,6 +203,8 @@ Declared in the card rather than discovered by failing: - **Agent card signatures.** Cards are unsigned, which matters for a public registry and not for peers you configured by hand. -One behaviour worth knowing about rather than discovering: a peer that quotes a -`contextId` from its own database gets a fresh conversation on this side, with -its id kept as metadata. Conversations are not stitched across engines. +One behaviour worth knowing about rather than discovering: a peer's `contextId` +names a conversation in the peer's own database, so cortex keeps a conversation +of its own for each remote thread and remembers which is which. A peer +continuing a thread continues the same conversation here, which is what keeps +the hop budget accumulating across turns rather than resetting every message. From f36d089715b0cd511bb710b27c3662e15edf9326 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Fri, 28 Aug 2026 15:44:16 -0500 Subject: [PATCH 49/50] chore: bump the deps --- extension/go.mod | 2 +- extension/go.sum | 4 ++-- 2 files changed, 3 insertions(+), 3 deletions(-) diff --git a/extension/go.mod b/extension/go.mod index bca095b..49b6958 100644 --- a/extension/go.mod +++ b/extension/go.mod @@ -12,7 +12,7 @@ require ( github.com/xraph/cortex v1.6.1 github.com/xraph/cortex/dashboard v1.6.1 github.com/xraph/cortex/sentinel v1.6.1 - github.com/xraph/forge v1.9.13 + github.com/xraph/forge v1.9.14 github.com/xraph/grove v1.6.2 github.com/xraph/nexus v1.6.3 github.com/xraph/shield v1.6.0 diff --git a/extension/go.sum b/extension/go.sum index 5e4ec04..0679473 100644 --- a/extension/go.sum +++ b/extension/go.sum @@ -370,8 +370,8 @@ github.com/xdg-go/stringprep v1.0.4 h1:XLI/Ng3O1Atzq0oBs3TWm+5ZVgkq2aqdlvP9JtoZ6 github.com/xdg-go/stringprep v1.0.4/go.mod h1:mPGuuIYwz7CmR2bT9j4GbQqutWS1zV24gijq1dTyGkM= github.com/xraph/confy v1.0.2 h1:90jmVLdw9J0uqJOnfDxqatOVJHeZ/IM1R89zd3df1FA= github.com/xraph/confy v1.0.2/go.mod h1:/jKqCF8cMpCatuNO2uFQ/7VClDBP2+V74fM5KFM42o8= -github.com/xraph/forge v1.9.13 h1:Q6wGM/QhvFf5veDep4IavTn2nEFuVvvXSJ0L8vekWsY= -github.com/xraph/forge v1.9.13/go.mod h1:5K24g2dtEObi2PKvarLEGgQhsMc1+HZsjEdP+qwK+zI= +github.com/xraph/forge v1.9.14 h1:mYRpq1efGncNiWxa+hN6ri+7KZtQzsIdUn/UrBuPFN8= +github.com/xraph/forge v1.9.14/go.mod h1:5K24g2dtEObi2PKvarLEGgQhsMc1+HZsjEdP+qwK+zI= github.com/xraph/forgeui v1.4.1 h1:LHK1t/sZ+9zL+MNUZralO9/rc0f5UCa19dpbWTuRMNg= github.com/xraph/forgeui v1.4.1/go.mod h1:rH/+wb1tt2pXSHotWAvoP+Lt846xlIjuwPDSpS5K5mw= github.com/xraph/go-utils v1.1.8 h1:O8+Vie/u/ntn2cEbvh47jJLzQ6S7qwxhYwgRm2SL1sw= From 01f9e4997523db9823ed0d09379b98208b1faed9 Mon Sep 17 00:00:00 2001 From: Rex Raphael Date: Thu, 3 Sep 2026 08:30:27 -0500 Subject: [PATCH 50/50] Potential fix for pull request finding 'CodeQL / Database query built from user-controlled sources' Co-authored-by: Copilot Autofix powered by AI <62310815+github-advanced-security[bot]@users.noreply.github.com> --- a2aremote/mapping.go | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/a2aremote/mapping.go b/a2aremote/mapping.go index 07bcdbe..8460492 100644 --- a/a2aremote/mapping.go +++ b/a2aremote/mapping.go @@ -95,7 +95,13 @@ func EnvelopeParamsFromMessage(m Message, sender, receiver a2a.Address) (a2a.Sen Language: meta.Language, Encoding: meta.Encoding, ReplyWith: meta.ReplyWith, - InReplyTo: meta.InReplyTo, + } + if meta.InReplyTo != "" { + inReplyTo, inReplyToErr := id.ParseWithPrefix(meta.InReplyTo, id.PrefixMessage) + if inReplyToErr != nil { + return a2a.SendParams{}, ErrInvalidParams("inReplyTo is not a message id: " + inReplyToErr.Error()) + } + params.InReplyTo = inReplyTo.String() } if m.ContextID != "" { convID, convErr := id.ParseWithPrefix(m.ContextID, id.PrefixConversation)