Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 10 additions & 0 deletions docs/guide/a2a.md
Original file line number Diff line number Diff line change
Expand Up @@ -173,3 +173,13 @@ a2a:
Outgoing calls automatically attach the `A2A-Extensions` header. Before sending, the client checks the remote agent card: if an extension is marked `required` there but not activated here, the call **fails fast** with a clear error instead of violating the remote agent's contract.

The canonical well-known path `/.well-known/agent-card.json` is served in addition to `/.well-known/agent.json` and `/.well-known/a2a.json`.

## A2A v1.0 compatibility

ggcode stays interoperable with both 0.2.x/0.3.x peers and A2A v1.0 (2026-03) agents:

**Task state enums (1.0.0 #1384, ADR-001 ProtoJSON).** The wire accepts both the legacy lowercase names (`working`, `input-required`) and the v1.0 ProtoJSON enum names (`TASK_STATE_WORKING`, `TASK_STATE_INPUT_REQUIRED`), plus historical spellings (`cancelled`, `input_required`). Incoming values normalize to the canonical constants, so terminal-state detection works regardless of the remote encoding. Outgoing states stay lowercase - the SDK's backwards-compat allowance (1.0.0 #1401).

**Well-known URI fallback (0.3.0 rename).** `Discover` probes `/.well-known/agent-card.json` first, falling back to `/.well-known/agent.json` only when the path returns 404/405 or the response is not a usable card. Errors on a real card (bad signature, tampering) surface as-is instead of being masked by a retry.

**`application/a2a+json` (1.0.1 #1753).** Servers advertise `protocolVersion` in the agent card and prefer the v1.0 media type: requests that arrive with (or accept) `a2a+json` get `a2a+json` responses; legacy peers keep `application/json`. On the client side, a discovered card declaring a 1.x version switches request bodies to `a2a+json`; sync-error detection accepts both media types (a plain `application/json` substring check would misclassify an `a2a+json` error response as SSE).
80 changes: 71 additions & 9 deletions internal/a2a/client.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"context"
"crypto/tls"
"encoding/json"
"errors"
"fmt"
"io"
"net/http"
Expand Down Expand Up @@ -203,19 +204,52 @@ func NewClient(baseURL, apiKey string, opts ...ClientOption) *Client {
}

// Discover fetches and caches the remote agent's Agent Card.
//
// A2A 0.3.0 renamed the well-known URI from agent.json to agent-card.json
// and 0.3.x/v1.0 servers may serve only the new path, while legacy ggcode
// peers serve only the old one. Fallback is limited to 404/405 (path
// absent) - a card that exists but fails signature verification, decoding,
// or transport must surface its real error instead of masking it with a
// second request.
func (c *Client) Discover(ctx context.Context) (*AgentCard, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet,
c.baseURL+"/.well-known/agent.json", nil)
card, err := c.fetchCard(ctx, "/.well-known/agent-card.json")
if err != nil {
if !errors.Is(err, errCardWellKnownNotFound) && !errors.Is(err, errCardInvalid) {
return nil, err
}
card, err = c.fetchCard(ctx, "/.well-known/agent.json")
}
return card, err
}

// errCardWellKnownNotFound marks a well-known card URI that the server does
// not serve (404/405), the only condition that triggers path fallback.
var errCardWellKnownNotFound = errors.New("a2a discover: well-known card path not served")

// errCardInvalid marks a 200 response that is not a usable agent card. The
// spec requires name and url; without this check a redirect landing on an
// unrelated JSON endpoint would silently produce an empty card.
var errCardInvalid = errors.New("a2a discover: response is not a valid agent card")

// fetchCard fetches the card from one well-known path.
func (c *Client) fetchCard(ctx context.Context, path string) (*AgentCard, error) {
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil)
if err != nil {
return nil, fmt.Errorf("a2a discover: %w", err)
}
// Advertise both A2A JSON media types (1.0.1 prefers a2a+json).
req.Header.Set("Accept", acceptHeader)

resp, err := c.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("a2a discover: %w", err)
}
defer resp.Body.Close()

if resp.StatusCode == http.StatusNotFound || resp.StatusCode == http.StatusMethodNotAllowed {
return nil, errCardWellKnownNotFound
}

if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("a2a discover: HTTP %d", resp.StatusCode)
}
Expand All @@ -229,6 +263,9 @@ func (c *Client) Discover(ctx context.Context) (*AgentCard, error) {
if err := json.Unmarshal(body, &card); err != nil {
return nil, fmt.Errorf("a2a discover: decode: %w", err)
}
if card.Name == "" && card.URL == "" {
return nil, errCardInvalid
}

// A2A §8.4: refuse a card whose JWS signature fails to verify.
if len(card.Signatures) > 0 {
Expand Down Expand Up @@ -520,7 +557,8 @@ func (c *Client) SendMessageStream(ctx context.Context, skill, text string) (<-c
if err != nil {
return nil, fmt.Errorf("a2a stream: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", c.requestContentType())
req.Header.Set("Accept", acceptHeader)
if err := c.applyExtensions(req); err != nil {
return nil, fmt.Errorf("a2a stream: %w", err)
}
Expand All @@ -535,7 +573,7 @@ func (c *Client) SendMessageStream(ctx context.Context, skill, text string) (<-c
// server writes errors as HTTP 200 + application/json before SSE
// headers are set) — parse that instead of feeding the JSON body to
// the SSE decoder (which would yield a silent empty stream + nil error).
if ct := resp.Header.Get("Content-Type"); strings.Contains(ct, "application/json") {
if ct := resp.Header.Get("Content-Type"); isJSONMedia(ct) {
respBody, _ := util.ReadAll(resp.Body, util.ReadLimitGeneral)
resp.Body.Close()
var rpcResp JSONRPCResponse
Expand Down Expand Up @@ -677,7 +715,8 @@ func (c *Client) Resubscribe(ctx context.Context, taskID string) (<-chan JSONRPC
if err != nil {
return nil, fmt.Errorf("a2a resubscribe: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", c.requestContentType())
req.Header.Set("Accept", acceptHeader)
if err := c.applyExtensions(req); err != nil {
return nil, fmt.Errorf("a2a resubscribe: %w", err)
}
Expand All @@ -690,7 +729,7 @@ func (c *Client) Resubscribe(ctx context.Context, taskID string) (<-chan JSONRPC

// Check Content-Type: if JSON (not SSE), this is a sync error response.
ct := resp.Header.Get("Content-Type")
if strings.Contains(ct, "application/json") {
if isJSONMedia(ct) {
defer resp.Body.Close()
respBody, _ := util.ReadAll(resp.Body, util.ReadLimitGeneral)
var rpcResp JSONRPCResponse
Expand Down Expand Up @@ -720,6 +759,30 @@ func (c *Client) Resubscribe(ctx context.Context, taskID string) (<-chan JSONRPC
// Internal helpers
// ---------------------------------------------------------------------------

// acceptHeader advertises both A2A JSON media types. 1.0.1 prefers
// application/a2a+json in the HTTP binding; servers may still answer with
// plain application/json (0.2.x/0.3.x peers always do).
const acceptHeader = "application/a2a+json, application/json"

// requestContentType returns the media type for JSON-RPC POST bodies. Peers
// whose card declares a 1.x protocolVersion receive the v1.0 preferred type;
// legacy peers keep application/json so older strict servers are unaffected.
func (c *Client) requestContentType() string {
if card := c.Card(); card != nil && strings.HasPrefix(card.ProtocolReversion, "1") {
return "application/a2a+json"
}
return "application/json"
}

// isJSONMedia reports whether a Content-Type is one of the A2A JSON media
// types. "application/a2a+json" does NOT contain the substring
// "application/json", so a plain Contains check misclassified a v1.0
// server's sync error response as SSE and fed JSON into the SSE decoder.
func isJSONMedia(ct string) bool {
ct = strings.ToLower(ct)
return strings.Contains(ct, "application/json") || strings.Contains(ct, "a2a+json")
}

func (c *Client) rpc(ctx context.Context, method string, params interface{}, result interface{}) error {
paramsJSON, err := json.Marshal(params)
if err != nil {
Expand All @@ -740,7 +803,8 @@ func (c *Client) rpc(ctx context.Context, method string, params interface{}, res
if err != nil {
return fmt.Errorf("a2a %s: %w", method, err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Content-Type", c.requestContentType())
req.Header.Set("Accept", acceptHeader)
if err := c.applyExtensions(req); err != nil {
return fmt.Errorf("a2a %s: %w", method, err)
}
Expand All @@ -756,7 +820,6 @@ func (c *Client) rpc(ctx context.Context, method string, params interface{}, res
if err != nil {
return fmt.Errorf("a2a %s: read: %w", method, err)
}

if resp.StatusCode != http.StatusOK {
var rpcResp JSONRPCResponse
if err := json.Unmarshal(respBody, &rpcResp); err == nil && rpcResp.Error != nil {
Expand All @@ -773,7 +836,6 @@ func (c *Client) rpc(ctx context.Context, method string, params interface{}, res
if err := json.Unmarshal(respBody, &rpcResp); err != nil {
return fmt.Errorf("a2a %s: decode: %w", method, err)
}

if rpcResp.Error != nil {
return rpcResp.Error
}
Expand Down
58 changes: 52 additions & 6 deletions internal/a2a/server.go
Original file line number Diff line number Diff line change
Expand Up @@ -106,9 +106,10 @@ func NewServer(cfg ServerConfig, handler *TaskHandler) *Server {
// Build Agent Card.
meta := handler.WorkspaceMetadata()
s.card = AgentCard{
Name: "ggcode",
Description: fmt.Sprintf("AI coding agent for %s", meta.ProjName),
Version: "1.0.0",
Name: "ggcode",
Description: fmt.Sprintf("AI coding agent for %s", meta.ProjName),
Version: "1.0.0",
ProtocolReversion: A2AProtocolVersion,
Provider: &AgentProvider{
URL: "https://github.com/topcheer/ggcode",
Organization: "topcheer",
Expand Down Expand Up @@ -322,7 +323,7 @@ func (s *Server) handleAgentCard(w http.ResponseWriter, r *http.Request) {
http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
return
}
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Type", jsonRPCContentType(w))
// #565 C: copy under read lock — setters can run concurrently (hot config).
s.cardMu.RLock()
cardCopy := s.card
Expand All @@ -342,6 +343,14 @@ func (s *Server) handleRPC(w http.ResponseWriter, r *http.Request) {
return
}

// Response media-type negotiation (A2A 1.0.1 "prefer application/a2a+json"
// in the HTTP binding): remember what the caller speaks so every
// writeRPCResult/writeRPCError below emits the matching type. Requests
// that don't signal a2a+json keep the legacy application/json responses.
if prefersA2AJSON(r) {
w = &rpcWriter{ResponseWriter: w, respCT: "application/a2a+json"}
}

// Required-extension gate (A2A v1.0 "Required Extensions"): a client
// that has not activated every required extension cannot comply with the
// agent's request contract, so reject with the UnsupportedOperation
Expand Down Expand Up @@ -1211,8 +1220,45 @@ func writeTaskResultOrNotFound(w http.ResponseWriter, id json.RawMessage, h *Tas
writeRPCError(w, id, ErrTaskNotFound)
}

// rpcWriter carries the negotiated JSON response media type alongside the
// underlying writer (set once per request in handleRPC).
type rpcWriter struct {
http.ResponseWriter
respCT string
}

// Flush forwards to the underlying writer so message/stream handlers that
// type-assert http.Flusher keep working through the wrapper (without this,
// SSE degrades to a synchronous JSON response).
func (rw *rpcWriter) Flush() {
if f, ok := rw.ResponseWriter.(http.Flusher); ok {
f.Flush()
}
}

// jsonRPCContentType resolves the response media type for w. Non-rpcWriter
// writers (direct callers, tests, wrapped SSE paths) default to the legacy
// application/json.
func jsonRPCContentType(w http.ResponseWriter) string {
if rw, ok := w.(*rpcWriter); ok && rw.respCT != "" {
return rw.respCT
}
return "application/json"
}

// prefersA2AJSON reports whether the request signals the v1.0 a2a+json media
// type in its Content-Type or Accept header.
func prefersA2AJSON(r *http.Request) bool {
for _, h := range []string{"Content-Type", "Accept"} {
if v := strings.ToLower(r.Header.Get(h)); strings.Contains(v, "a2a+json") {
return true
}
}
return false
}

func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result interface{}) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Type", jsonRPCContentType(w))
json.NewEncoder(w).Encode(JSONRPCResponse{
JSONRPC: "2.0",
ID: normalizeResponseID(id),
Expand All @@ -1221,7 +1267,7 @@ func writeRPCResult(w http.ResponseWriter, id json.RawMessage, result interface{
}

func writeRPCError(w http.ResponseWriter, id json.RawMessage, rpcErr *JSONRPCError) {
w.Header().Set("Content-Type", "application/json")
w.Header().Set("Content-Type", jsonRPCContentType(w))
json.NewEncoder(w).Encode(JSONRPCResponse{
JSONRPC: "2.0",
// #565 E: JSON-RPC 2.0 requires the id member to be present (null
Expand Down
100 changes: 96 additions & 4 deletions internal/a2a/types.go
Original file line number Diff line number Diff line change
Expand Up @@ -67,10 +67,14 @@ var (
// AgentCard describes an agent's identity, capabilities, and skills.
// Served at GET /.well-known/agent.json
type AgentCard struct {
Name string `json:"name"`
Description string `json:"description"`
URL string `json:"url"`
Version string `json:"version,omitempty"`
Name string `json:"name"`
Description string `json:"description"`
URL string `json:"url"`
Version string `json:"version,omitempty"`
// ProtocolReversion is the A2A protocol version this card speaks (spec
// field "protocolVersion", required since 0.2.5). Clients use it to
// select v1.0 media types and encodings.
ProtocolReversion string `json:"protocolVersion,omitempty"`
Provider *AgentProvider `json:"provider,omitempty"`
Capabilities AgentCapabilities `json:"capabilities"`
SecuritySchemes map[string]Security `json:"securitySchemes,omitempty"` // deprecated, kept for compat
Expand Down Expand Up @@ -315,6 +319,94 @@ const (
TaskStateAuthRequired TaskState = "auth-required"
)

// A2A v1.0 (2026-03, ADR-001 ProtoJSON) enum value names. JSON-RPC 0.2.x/0.3.x
// peers serialize states as the legacy lowercase names above; v1.0 transports
// (gRPC / transcoded HTTP) serialize the proto enum value names instead.
// See the 1.0.0 changelog entry "Align enum format with ADR-001 ProtoJSON
// specification" (a2aproject/A2A #1384).
const (
TaskStateV1Submitted = "TASK_STATE_SUBMITTED"
TaskStateV1Working = "TASK_STATE_WORKING"
TaskStateV1InputRequired = "TASK_STATE_INPUT_REQUIRED"
TaskStateV1Completed = "TASK_STATE_COMPLETED"
TaskStateV1Canceled = "TASK_STATE_CANCELED"
TaskStateV1Failed = "TASK_STATE_FAILED"
TaskStateV1Rejected = "TASK_STATE_REJECTED"
TaskStateV1AuthRequired = "TASK_STATE_AUTH_REQUIRED"
)

// taskStateAliases maps every known wire encoding of a task state - legacy
// lowercase, v1.0 ProtoJSON names, the pre-#1283 British spelling, and the
// underscored variants seen from SDK transcoders - to the canonical constant.
var taskStateAliases = map[string]TaskState{
// legacy (JSON-RPC binding, 0.2.x/0.3.x)
"submitted": TaskStateSubmitted,
"working": TaskStateWorking,
"input-required": TaskStateInputRequired,
"completed": TaskStateCompleted,
"canceled": TaskStateCanceled,
"failed": TaskStateFailed,
"rejected": TaskStateRejected,
"auth-required": TaskStateAuthRequired,
// historical spellings tolerated for interop
"cancelled": TaskStateCanceled,
"input_required": TaskStateInputRequired,
"auth_required": TaskStateAuthRequired,
// v1.0 ProtoJSON names (ADR-001)
TaskStateV1Submitted: TaskStateSubmitted,
TaskStateV1Working: TaskStateWorking,
TaskStateV1InputRequired: TaskStateInputRequired,
TaskStateV1Completed: TaskStateCompleted,
TaskStateV1Canceled: TaskStateCanceled,
TaskStateV1Failed: TaskStateFailed,
TaskStateV1Rejected: TaskStateRejected,
TaskStateV1AuthRequired: TaskStateAuthRequired,
}

// taskStateV1Names is the canonical → v1.0 ProtoJSON name mapping.
var taskStateV1Names = map[TaskState]string{
TaskStateSubmitted: TaskStateV1Submitted,
TaskStateWorking: TaskStateV1Working,
TaskStateInputRequired: TaskStateV1InputRequired,
TaskStateCompleted: TaskStateV1Completed,
TaskStateCanceled: TaskStateV1Canceled,
TaskStateFailed: TaskStateV1Failed,
TaskStateRejected: TaskStateV1Rejected,
TaskStateAuthRequired: TaskStateV1AuthRequired,
}

// UnmarshalJSON accepts both the legacy lowercase state names and the A2A
// v1.0 ProtoJSON enum names (TASK_STATE_*) on the wire, normalizing to the
// canonical constants. Without this, a v1.0 remote agent reporting
// "TASK_STATE_COMPLETED" decoded as an unknown state whose IsTerminal() is
// false - the caller waited forever on a task that had already finished.
func (s *TaskState) UnmarshalJSON(b []byte) error {
var raw string
if err := json.Unmarshal(b, &raw); err != nil {
return err
}
if mapped, ok := taskStateAliases[raw]; ok {
*s = mapped
return nil
}
*s = TaskState(raw) // unknown states preserved verbatim (forward compat)
return nil
}

// MarshalJSON emits the legacy lowercase name so ggcode peers running older
// builds keep round-tripping unchanged (SDK backwards-compat allowance,
// 1.0.0 changelog #1401). Use V1Name when speaking to a v1.0 transport.
func (s TaskState) MarshalJSON() ([]byte, error) { return json.Marshal(string(s)) }

// V1Name returns the A2A v1.0 ProtoJSON enum name for the state
// ("working" → "TASK_STATE_WORKING"); unknown states pass through.
func (s TaskState) V1Name() string {
if n, ok := taskStateV1Names[s]; ok {
return n
}
return string(s)
}

// IsTerminal returns true for states that cannot transition further.
// #1107: TaskStateAuthRequired is NOT terminal per the A2A spec - listing
// it here froze the task forever (done closed, transitions blocked, cancel
Expand Down
Loading
Loading