a2aproject / a2aproject/a2a-cli

feat: Plugin system for custom transports via discoverable binaries

Ouverte
#20 14 commentaires 0 réactions 0 personnes assignées Voir sur GitHub
enhancement
Langage dominant
Go
Étoiles
95
Forks
4
Merge moyen
3 j 2 h
PR mergées (30 j)
24

Description

## Problem Statement

Adding a new transport to the A2A CLI today requires modifying four tightly-coupled locations and recompiling:

1. `internal/flagparse/transports.go:54-67` — add alias to `parseTransport()` switch
2. `internal/cli/root.go:47-70` — add transport-specific config fields to `globalConfig` + register new flags (lines 133-138)
3. `internal/cli/client.go:119-143` — wire the new transport's `a2aclient.FactoryOption` into `clientFactoryOpts()`
4. A dedicated file (e.g. `slim.go` + `slim_nocgo.go`) — the transport implementation itself, possibly behind build tags

The SLIMRPC transport demonstrates this pain: it requires CGO, adds 6 global flags, needs conditional compilation, and anyone wanting to use it must rebuild from source with the right build tags. This model doesn't scale for the growing ecosystem of transport protocols that community and enterprise users need (WebSocket, SLIM variants, proprietary message buses, etc.).

A plugin system would allow third parties to ship transport implementations as standalone binaries without forking or recompiling the CLI.

## Goals

- Users install a transport plugin by placing a binary on PATH — no CLI recompile required
- `--transport ` works uniformly for built-in transports and plugins
- Plugin authors can implement transports in any language (at minimum Go via go-plugin, and any language that can implement a CLI subcommand contract)
- Plugin-specific configuration (flags or env vars) can be declared and passed through
- Crash isolation — a misbehaving plugin does not corrupt the host CLI process
- Existing built-in transports (rest, jsonrpc, grpc, slimrpc) continue to work unchanged

## Non-Goals

- Server-side plugins (`a2asrv.RequestHandler` extensions) — this proposal covers client transports only
- Plugin distribution / registry — users install binaries themselves (like kubectl plugins)
- Hot-reloading or plugin auto-update
- Replacing the build-tag mechanism for existing optional transports (SLIMRPC stays compiled-in)

## Discovery Mechanism

Follow the **kubectl plugin convention**:

1. **Naming:** `a2acli-transport-` (e.g. `a2acli-transport-websocket`, `a2acli-transport-slim-v2`)
2. **Discovery:** search `$PATH` when `--transport ` does not match a built-in
3. **Validation:** run a handshake/capabilities check before delegating

Resolution order in `parseTransport()`:
1. Match built-in aliases: rest, jsonrpc, grpc, slimrpc
2. Search PATH for `a2acli-transport-`
3. Found → validate + use; not found → error listing available plugins (like kubectl does)

A new `a2a transport list` subcommand (or `a2a plugin list`) should scan PATH and report discovered plugin binaries with their declared metadata (version, supported protocol version, description).

## Approach A: hashicorp/go-plugin (gRPC IPC)

The host CLI launches the plugin binary as a subprocess and communicates over a local gRPC channel using [hashicorp/go-plugin](https://github.com/hashicorp/go-plugin).

**Plugin contract:** a protobuf service matching the `a2aclient.Transport` interface (12 methods) plus `Handshake` and `DeclareFlags` methods.

The host constructs a `TransportFactory` that:
1. Launches `a2acli-transport-` via go-plugin
2. Performs a versioned handshake (protocol version negotiation built into go-plugin)
3. Returns a `Transport` adapter proxying each method call over gRPC to the plugin

This adapter is registered as an `a2aclient.FactoryOption` and slots into `clientFactoryOpts()` exactly like built-in transports.

**Pros:**
- Typed contract via protobuf — compile-time errors for plugin authors using Go
- Versioned handshake with automatic negotiation (go-plugin built-in)
- Full crash isolation — plugin crashes produce a clean error, not a CLI segfault
- Efficient for streaming methods (`SendStreamingMessage`, `SubscribeToTask`) via multiplexed gRPC
- Cross-language: any language with a gRPC implementation can author plugins

**Cons:**
- Plugin authors must depend on the go-plugin SDK or implement the gRPC handshake manually
- Heavier startup cost — spawns process + establishes gRPC channel
- The protobuf service definition must be kept in sync with the `a2aclient.Transport` interface
- Adds `hashicorp/go-plugin` as a new dependency (gRPC is already present for the built-in gRPC transport)

## Approach B: Subcommand + JSONL (subprocess per call)

Each transport operation maps to a subcommand of the plugin binary. The host spawns the process, passes inputs as CLI arguments and/or stdin JSON, and reads JSONL output from stdout. No long-lived process or bespoke IPC protocol — just a binary that behaves like a well-specified CLI tool.

**Subcommand mapping:**

| A2A operation | Plugin invocation |
|---|---|
| `SendMessage` | `a2acli-transport-websocket send-message ` |
| `SendStreamingMessage` | `a2acli-transport-websocket send-message-stream ` |
| `GetTask` | `a2acli-transport-websocket get-task ` |
| `CancelTask` | `a2acli-transport-websocket cancel-task ` |
| `ListTasks` | `a2acli-transport-websocket list-tasks ` |
| `SubscribeToTask` | `a2acli-transport-websocket subscribe-task ` |
| `GetExtendedAgentCard` | `a2acli-transport-websocket get-agent-card ` |
| capabilities query | `a2acli-transport-websocket info` |

**Output format:**
- Non-streaming subcommands: single JSON object on stdout, exit 0 on success, non-zero + JSON error on failure
- Streaming subcommands (e.g. `send-message-stream`, `subscribe-task`): one JSONL event per line until stream ends, then process exits

**Example:**

```
$ a2acli-transport-websocket send-message '{"endpoint":"ws://agents.example.com/weather","message":{"role":"user","parts":[{"text":"hello"}]}}'
{"task":{"id":"t-123","status":{"state":"completed"},"result":{...}}}

$ a2acli-transport-websocket send-message-stream '{"endpoint":"ws://agents.example.com/weather","message":{...}}'
{"type":"status","status":{"state":"working"}}
{"type":"artifact","artifact":{"parts":[{"text":"The weather is"}]}}
{"type":"artifact","artifact":{"parts":[{"text":" sunny."}],"lastChunk":true}}
{"type":"status","status":{"state":"completed"}}
```

**`info` subcommand** returns plugin metadata and supported subcommands — used by `a2a transport list` and for version compatibility checks:

```
$ a2acli-transport-websocket info
{"name":"websocket","version":"1.0.0","protocol_version":"0.4.0","description":"WebSocket transport for A2A CLI","env":[{"name":"A2A_TRANSPORT_WEBSOCKET_TLS_SKIP_VERIFY","default":"false","usage":"Skip TLS certificate verification"}]}
```

Same `FactoryOption` integration as Approach A — host wraps subcommand invocation behind a `Transport` adapter.

**Pros:**
- Language-agnostic — any language that can exec as a CLI (Python, Node, Rust, shell scripts)
- No SDK dependency; plugin is just a CLI tool with a defined subcommand contract
- Trivially testable — call subcommands directly from a shell
- Crash isolation per call — process exits after each operation; no stale state
- Simpler streaming model — process writes lines until done, then exits; no framing protocol needed

**Cons:**
- Process spawn overhead per call (acceptable for CLI use; not for high-frequency operations)
- Config passed via env vars only (no flag integration with host cobra tree)
- Streaming requires the host to keep the process alive and read lines until EOF — needs careful timeout and error handling
- No multiplexing — one concurrent operation per plugin process

## Integration Points (both approaches)

| File | Change |
|---|---|
| `internal/flagparse/transports.go` | `parseTransport()` falls through to plugin lookup for unknown transport names |
| `internal/cli/root.go` | New `globalConfig` field for plugin pass-through args |
| `internal/cli/client.go` | `clientFactoryOpts()` appends the plugin's `FactoryOption` when a plugin transport is active |
| `internal/cli/plugin.go` (new) | Plugin discovery, lifecycle management, Transport adapter |
| `internal/cli/plugin_transport.go` (new) | `TransportFactory` implementation proxying to the plugin process |

### Flag passthrough options

- **Two-phase parse:** First pass identifies `--transport `, discovers plugin, queries its flag declarations, then re-parses with those flags registered on cobra. Full `--help` integration, but complex.
- **Opaque passthrough:** Flags prefixed `---*` or after `--` are passed as raw key-value pairs to the plugin. Simpler, weaker help/validation UX.
- **Env vars only:** Plugin reads `A2A_TRANSPORT__*`. Aligns with the existing `.env`/`clicfg` pattern in `internal/clicfg/binder.go`. No flag plumbing needed.

## Open Questions

1. **Preferred approach?** go-plugin/gRPC gives stronger typing and a long-lived connection; subcommand/JSONL is simpler and language-agnostic but pays a process-spawn cost per call.
2. **Plugin flag registration:** Approach B uses env vars (`A2A_TRANSPORT__*`) declared in `info` output. Approach A could support declared flags. Should plugins ever appear in `a2a send --help`?
3. **Protocol version contract:** How to handle `a2aclient.Transport` interface evolution? The `info` subcommand (B) or handshake (A) should declare supported operations so the host can detect missing capability.
4. **Streaming error handling (Approach B):** If the plugin process exits non-zero mid-stream, how should the host surface the error to the caller?
5. **Security:** Should there be a plugin allowlist or signature verification?
6. **Conformance test harness:** Should the project provide `a2a plugin test ./a2acli-transport-websocket` to validate a plugin binary against the contract?

## Acceptance Criteria

- [ ] `a2a send --transport websocket -e ws://agents.example.com/weather "hello"` works when `a2acli-transport-websocket` is on PATH
- [ ] `a2a transport list` shows discovered plugins with version/description
- [ ] Unknown `--transport` name with no matching plugin produces a clear error with discovery hints
- [ ] Plugin crash during a streaming operation produces a user-friendly error, not a panic
- [ ] Plugin can declare its own configuration requirements (flags, env vars, or both)
- [ ] Example plugin exists in the repo (e.g. `examples/a2acli-transport-echo/`) demonstrating the contract
- [ ] Plugin author guide: protocol spec, minimal Go template, and a non-Go example (Python or shell)
- [ ] Existing built-in transports are unaffected — no regressions in rest, jsonrpc, grpc, slimrpc
- [ ] The plugin protocol is versioned so future CLI releases maintain backward compatibility with older plugins

## References

- [kubectl plugin mechanism](https://kubernetes.io/docs/tasks/extend-kubectl/kubectl-plugins/) — PATH-based discovery, naming convention
- [hashicorp/go-plugin](https://github.com/hashicorp/go-plugin) — gRPC-based plugin framework
- Current SLIMRPC pattern: `internal/cli/slim.go` + `slim_nocgo.go` (build-tag gated, 6 global flags)
- Transport registration: `internal/cli/client.go:119-143` (`clientFactoryOpts`)

Guide de contribution

Ouvrir le guide de contribution

Évaluation

Cette issue n'a pas encore été évaluée.

Recevez les nouvelles issues par e-mail

Un résumé court des issues GitHub adaptées aux débutants.