diff --git a/.github/workflows/go.yml b/.github/workflows/go.yml index 0b443f3..38b66f2 100644 --- a/.github/workflows/go.yml +++ b/.github/workflows/go.yml @@ -1,6 +1,3 @@ -# This workflow will build a golang project -# For more information see: https://docs.github.com/en/actions/automating-builds-and-tests/building-and-testing-go - name: Go on: @@ -10,19 +7,28 @@ on: branches: [ "main" ] jobs: - build: runs-on: ubuntu-latest steps: - uses: actions/checkout@v4 - name: Set up Go - uses: actions/setup-go@v4 + uses: actions/setup-go@v5 with: - go-version: '1.20' + go-version-file: go.mod + cache: true - name: Build run: go build -v ./... - name: Test run: go test -v ./... + + - name: Vet + run: go vet ./... + + - name: Repeat A2A tests + run: go test -count=20 ./pkg/clients/a2a ./pkg/bridges/a2a + + - name: Race A2A tests + run: go test -race ./pkg/clients/a2a ./pkg/bridges/a2a diff --git a/docs/README.md b/docs/README.md index 5d0d787..87404e5 100644 --- a/docs/README.md +++ b/docs/README.md @@ -14,6 +14,7 @@ | [`run-policy.md`](./run-policy.md) | `RunPolicy`、HITL mode、adapter capability 矩阵。 | | [`streaming.md`](./streaming.md) | `WithStreaming`、`RunHandle.StreamEvents`、AG-UI / SSE bridge 用法。 | | [`streaming-adapter-contract.md`](./streaming-adapter-contract.md) | 新 adapter 接入 streaming 时必须满足的合同。 | +| [`a2a.md`](./a2a.md) | A2A bridge / client 的当前集成方式与边界。 | | [`skill-api-design.md`](./skill-api-design.md) | 当前 skills API 合同与迁移说明。 | | [`public-errors.md`](./public-errors.md) | 公开错误清单与宿主建议映射。 | | [`workstream-transcript-contract.md`](./workstream-transcript-contract.md) | 输出 / transcript / raw stream 分层合同。 | diff --git a/docs/a2a.md b/docs/a2a.md new file mode 100644 index 0000000..99313c6 --- /dev/null +++ b/docs/a2a.md @@ -0,0 +1,148 @@ +# A2A Bridge And Client + +`agent-adaptor` supports A2A through two localized packages: + +- `pkg/bridges/a2a` exposes an existing `agentadaptor.Runner` as an A2A-compatible agent. +- `pkg/clients/a2a` provides thin client primitives for calling remote A2A agents. + +The core SDK remains protocol-agnostic. There is no `WithA2A`, no remote A2A `AgentBinding`, no built-in HTTP server, and no automatic remote-agent routing in core. + +## Dependency Choice + +The implementation uses `github.com/a2aproject/a2a-go/v2`. + +Reliability: A2A card parsing, JSON-RPC/SSE transport, task events, protocol errors, and request handlers are delegated to the official Go SDK instead of duplicated with hand-rolled wire code. + +Maintainability: The dependency is maintained by the A2A project and currently exposes protocol `1.0` through the `a2a.Version` constant. The package is pinned in `go.mod` so protocol updates are explicit. + +Localization: Imports from `github.com/a2aproject/a2a-go/v2` are confined to `pkg/bridges/a2a` and `pkg/clients/a2a`. The bridge imports only the core `Runner` contract from this repository and does not import concrete provider adapters. + +## Bridge + +The bridge maps A2A task execution onto the single SDK execution path: + +1. `SendMessage` / `SendStreamingMessage` decode inbound A2A messages. +2. The configured `PromptBuilder` produces a prompt and optional `RunOption`s. +3. The configured `SessionMapper` may bind A2A `contextId` or `taskId` to SDK session options. +4. The bridge calls `Runner.Start(..., WithStreaming())`. +5. `StreamEvents`, `Wait`, and `Cancel` are translated to A2A status, artifact, and terminal events. + +Bridge capability advertisement is strict: + +- `AgentCard.Capabilities.Streaming` is a tri-state. The zero value keeps A2A streaming enabled; use `a2a.CapabilityDisabled` to publish `streaming=false`. +- `PushNotifications` is not exposed unless `AgentCard.Capabilities.PushNotifications=true` and `ServerOptions.PushNotifications` provides both an A2A push `ConfigStore` and `Sender`. +- `ExtendedAgentCard` is not exposed unless `AgentCard.Capabilities.ExtendedAgentCard=true` and `ServerOptions.ExtendedAgentCard` provides either a static card or a provider. + +```go +runner := sdk.Default() + +server := a2a.NewServer(runner, a2a.ServerOptions{ + AgentCard: a2a.AgentCard{ + Name: "Local Codex", + Description: "Runs local agent-adaptor tasks", + Version: "1.0.0", + URL: "https://host.example/a2a", + Skills: []a2a.Skill{{ + ID: "chat", + Name: "Chat", + Description: "Run a prompt through the configured default agent", + Tags: []string{"agent", "coding"}, + }}, + }, + Session: a2a.SessionByContextID("a2a"), + TaskLifecycle: a2a.TaskLifecycleOptions{ + Ephemeral: &a2a.EphemeralTaskStoreOptions{ + MaxTasks: 512, + TTL: 2 * time.Hour, + }, + }, +}) + +mux := http.NewServeMux() +mux.Handle("/.well-known/agent-card.json", server.AgentCardHandler()) +mux.Handle("/a2a", server.Handler()) +``` + +See [`examples/a2a-local`](../examples/a2a-local) for a runnable local +end-to-end demo that starts this bridge around a real local SDK runner, calls +it with `pkg/clients/a2a`, consumes streaming artifacts, and verifies the final +task with `GetTask`. The example defaults to an isolated temporary workspace +and isolated cloned provider profile seeded from native settings so custom API +key / base URL setups work without writing demo state into a host's active +local agent profile. + +The terminal result is emitted as a structured artifact named `agent-adaptor-result`. Assistant-facing output remains in the final A2A status message. The default artifact contains only the safe summary; diagnostics such as metadata, usage, provider result payloads, transcript, raw streams, reasoning, tool-call internals, and HITL payloads require explicit `ExposurePolicy` opt-in and are sanitized before they cross the A2A boundary. + +Bridge-owned artifact names are part of the package contract for hosts that +compose this bridge with higher-level stream overlays: + +- `a2a.ArtifactAssistantOutput` (`assistant-output`) carries streamed + assistant-facing text deltas and closes with `lastChunk=true`. +- `a2a.ArtifactAgentAdaptorResult` (`agent-adaptor-result`) carries the + terminal summary and any opt-in sanitized diagnostics, and is emitted as a + single final chunk. + +Task retention is explicit. `NewServer` no longer hides the upstream unbounded in-memory task store. If `ServerOptions.TaskLifecycle.Store` is nil, the bridge uses a bounded ephemeral store with a default `MaxTasks=256` and `TTL=1h`. Hosts that need durable retention, custom paging/auth behavior, or cross-process lifecycle ownership must inject their own `a2asrv/taskstore.Store`. + +Hosts own serving concerns: route layout, authentication, authorization, TLS, tenancy, durability, task retention, and observability. + +## Client + +The client package is intentionally protocol-shaped. It does not wrap remote A2A tasks in local `RunResult` semantics and does not expose stdout/stderr concepts. + +```go +client := a2a.New(a2a.Options{ + AgentCardURL: "https://remote.example/.well-known/agent-card.json", + Auth: a2a.BearerTokenFromEnv("REMOTE_A2A_TOKEN"), +}) + +card, err := client.AgentCard(ctx) +if err != nil { + return err +} + +task, err := client.Send(ctx, a2a.SendRequest{ + Message: a2a.Message{ + Role: "user", + Parts: []a2a.Part{{ + Kind: a2a.PartText, + Text: "Review this change", + MediaType: "text/plain", + }}, + }, + AcceptedOutputModes: card.DefaultOutputModes, +}) +if err != nil { + return err +} +_ = task +``` + +`SendStream` and `Subscribe` return ordered protocol events. The client uses the same execution-final semantics as the official server stack: a `Message`, a terminal task/status, or `TASK_STATE_INPUT_REQUIRED` ends the stream. Duplicate or late events after the first final event are ignored. If a stream fails before a final event and the task ID is known, the client attempts one `GetTask` recovery; a final recovered task is returned with `RecoveredState=true`. + +Bearer credentials are origin-pinned. With `Auth` configured, the client only sends credentials to the Agent Card origin by default. Agent Card interface URLs on another origin require explicit `TrustedAuthOrigins`, and redirects to untrusted origins have authorization headers stripped. `SubscribeRequest.Since` is rejected when set because A2A 1.0 `SubscribeToTask` has no cursor replay field. + +Task lookup and cancellation use request DTOs so hosts can pass A2A tenant and +retention/cancellation metadata without changing the client API later: + +```go +task, err := client.GetTask(ctx, a2a.GetTaskRequest{ + TaskID: "task-123", + Tenant: "tenant-a", +}) +if err != nil { + return err +} + +_, err = client.CancelTask(ctx, a2a.CancelTaskRequest{ + TaskID: "task-123", + Tenant: "tenant-a", + Metadata: map[string]any{ + "reason": "parent_cancelled", + }, +}) +``` + +## Non-Goals + +Visual subagent delegation, A2A-to-local adapter routing, bridge-managed durable task persistence, and default push delivery infrastructure are outside this slice. Issue #5 tracks the visual subagent flow above these protocol primitives. diff --git a/examples/README.md b/examples/README.md index 59ad471..f583880 100644 --- a/examples/README.md +++ b/examples/README.md @@ -179,6 +179,18 @@ Vite + React + `@ag-ui/client`,浏览器直接调用 Go backend,不经过 Co See [`streaming-chat-aguiclient/README.md`](./streaming-chat-aguiclient/README.md). +### `a2a-local` + +本地端到端 A2A demo:启动一个 HTTP A2A server,把选定的真实本机 agent-adaptor Runner 暴露为 A2A JSON-RPC;随后用 `pkg/clients/a2a` 读取 Agent Card、执行 streaming 调用,并用 `GetTask` 轮询最终任务。 + +```bash +go run ./examples/a2a-local -agent=codex +go run ./examples/a2a-local -agent=claude -prompt="Reply with one sentence" +go run ./examples/a2a-local -serve-only -addr=127.0.0.1:8080 +``` + +默认使用临时 workspace + 临时 cloned provider profile,并把 native settings 复制到临时 profile 以支持 custom API key / base URL;auth files 通过 `CloneProfileAuthLink` 共享,避免复制 OAuth refresh token。示例不会写入宿主正在使用的 profile,不会复制 native skills/MCP 目录。默认会校验最终输出包含 `A2A demo OK`,避免把未登录提示误判为成功;可用 `-expect=` 关闭该校验。默认输出包含隔离目录、Agent Card fingerprint、streaming 状态、bridge artifact 统计、最终 task state 与 assistant 输出预览。`-serve-only` 只启动 server,方便外部 A2A client 连接 `/.well-known/agent-card.json` 与 `/a2a`。需要排查时加 `-keep-workspace` 保留临时 workspace/profile;该目录可能包含复制出的 provider settings。 + ### `session-codec-inspect` 静态 inspection utility,用来查看某个 adapter 的 session codec 参数形状;它不启动本机 CLI。 diff --git a/examples/a2a-local/main.go b/examples/a2a-local/main.go new file mode 100644 index 0000000..573a72e --- /dev/null +++ b/examples/a2a-local/main.go @@ -0,0 +1,558 @@ +// a2a-local starts an in-process A2A server around a real local +// agent-adaptor Runner, then calls it through the A2A client. +// +// Usage: +// +// go run ./examples/a2a-local -agent=codex +// go run ./examples/a2a-local -agent=claude -prompt="Reply with one sentence" +// go run ./examples/a2a-local -serve-only -addr=127.0.0.1:8080 +// +// The example requires the selected local CLI in PATH and existing +// authentication. +package main + +import ( + "context" + "errors" + "flag" + "fmt" + "io" + "net" + "net/http" + "net/url" + "os" + "os/signal" + "path/filepath" + "runtime" + "strings" + "time" + + agentadaptor "github.com/agent-dance/agent-adaptor" + "github.com/agent-dance/agent-adaptor/examples/internal/exampleutil" + "github.com/agent-dance/agent-adaptor/memory" + bridgea2a "github.com/agent-dance/agent-adaptor/pkg/bridges/a2a" + clienta2a "github.com/agent-dance/agent-adaptor/pkg/clients/a2a" +) + +const ( + agentCardPath = "/.well-known/agent-card.json" + jsonRPCPath = "/a2a" +) + +func main() { + agent := flag.String("agent", "", "Local CLI agent to use: "+exampleutil.SupportedAgents()+" (default codex, or AGENT_ADAPTOR_EXAMPLE_AGENT)") + model := flag.String("model", "", "Model to use. Defaults by agent or CODEX_MODEL/CLAUDE_MODEL/CURSOR_MODEL.") + command := flag.String("command", "", "Optional explicit local CLI command. Defaults by agent or CODEX_COMMAND/CLAUDE_COMMAND/CURSOR_COMMAND/PATH.") + addr := flag.String("addr", "127.0.0.1:0", "HTTP listen address for the local A2A server") + prompt := flag.String("prompt", "Reply exactly with: A2A demo OK", "Prompt to send through the A2A client") + expect := flag.String("expect", "A2A demo OK", "Text expected in the final assistant output; empty disables output validation") + contextID := flag.String("context", "a2a-demo/thread-1", "A2A contextId mapped to an SDK session key") + workspace := flag.String("workspace", "", "Isolated workspace directory. Defaults to a temporary directory.") + profile := flag.String("profile", "", "Isolated provider profile directory. Defaults to a temporary directory under the demo root.") + keepWorkspace := flag.Bool("keep-workspace", false, "Keep the temporary workspace/profile after the example exits") + timeout := flag.Duration("timeout", 3*time.Minute, "Maximum time for the demo; use 0 with -serve-only to run until interrupted") + serveOnly := flag.Bool("serve-only", false, "Only serve the local A2A endpoint; do not run the built-in client demo") + flag.Parse() + + ctx, cancel := demoContext(*timeout) + defer cancel() + + isolation, err := newIsolation(*agent, *workspace, *profile, *keepWorkspace) + exampleutil.Must(err, "create isolated A2A example workspace") + defer isolation.Cleanup() + + agentCfg := exampleutil.ResolveLiveAgentConfig(*agent, *model, *command, isolation.WorkspaceDir) + agentCfg = preferDirectlyExecutableCommand(agentCfg) + if agentCfg.Agent == exampleutil.AgentCodex { + agentCfg.ExtraArgs = append(agentCfg.ExtraArgs, "--skip-git-repo-check") + } + isolation.Agent = agentCfg.Agent + + listener, err := net.Listen("tcp", *addr) + exampleutil.Must(err, "listen for local A2A server") + defer listener.Close() + + baseURL := publicBaseURL(listener.Addr()) + jsonRPCURL := baseURL + jsonRPCPath + agentCardURL := baseURL + agentCardPath + + sdk := agentadaptor.New( + agentadaptor.WithDefaultAgent(exampleutil.NewLiveAgentBinding( + agentCfg, + agentadaptor.WithCloneProfile(isolation.ProfileDir, agentadaptor.CloneProfileOptions{ + IncludeSettings: true, + AuthMode: agentadaptor.CloneProfileAuthLink, + }), + agentadaptor.WithDefaultWorkspace(agentadaptor.SharedWorkspace{}), + agentadaptor.WithDefaultIdentity(agentadaptor.AgentIdentity{ + ID: "a2a-local-demo", + TenantID: "example", + ProfileID: "isolated", + Name: "a2a-local", + }), + agentadaptor.WithDefaultMetadata("example", "a2a-local"), + agentadaptor.WithDefaultMetadata("isolation", "temporary-workspace-and-profile"), + )), + agentadaptor.WithSessionStore(memory.NewSessionStore()), + ) + + server := bridgea2a.NewServer(sdk.Default(), bridgea2a.ServerOptions{ + AgentCard: bridgea2a.AgentCard{ + Name: "agent-adaptor local A2A demo", + Description: "Exposes the selected local agent-adaptor Runner through A2A JSON-RPC.", + Version: "1.0.0", + URL: jsonRPCURL, + Provider: &bridgea2a.Provider{ + Organization: "agent-dance", + URL: "https://github.com/agent-dance/agent-adaptor", + }, + Skills: []bridgea2a.Skill{{ + ID: "local-agent", + Name: "Local agent", + Description: "Runs a prompt through the configured local CLI agent.", + Tags: []string{"agent-adaptor", "a2a", agentCfg.Agent}, + Examples: []string{"Reply exactly with: A2A demo OK"}, + }}, + }, + Session: bridgea2a.SessionByContextID("a2a-local"), + RunOptions: []agentadaptor.RunOption{ + exampleutil.NonInteractiveRunOption(agentadaptor.IsolationReadOnly), + }, + TaskLifecycle: bridgea2a.TaskLifecycleOptions{ + Ephemeral: &bridgea2a.EphemeralTaskStoreOptions{ + MaxTasks: 64, + TTL: 30 * time.Minute, + }, + }, + }) + + httpServer := newHTTPServer(server) + serveErr := make(chan error, 1) + go func() { + if err := httpServer.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + serveErr <- err + return + } + serveErr <- nil + }() + defer shutdownServer(httpServer) + + if *serveOnly { + fmt.Fprintf(os.Stderr, "A2A agent card: %s\n", agentCardURL) + fmt.Fprintf(os.Stderr, "A2A JSON-RPC: %s\n", jsonRPCURL) + fmt.Fprintf(os.Stderr, "Workspace: %s\n", isolation.WorkspaceDir) + fmt.Fprintf(os.Stderr, "Profile: %s\n", isolation.ProfileDir) + select { + case <-ctx.Done(): + case err := <-serveErr: + exampleutil.Must(err, "serve local A2A endpoint") + } + return + } + + summary, err := runClientDemo(ctx, agentCardURL, *contextID, *prompt, *expect) + exampleutil.Must(err, "run A2A client demo") + + select { + case err := <-serveErr: + exampleutil.Must(err, "serve local A2A endpoint") + default: + } + + exampleutil.PrintJSON(map[string]any{ + "example": "a2a-local", + "agent": exampleutil.LiveAgentSummary(agentCfg), + "isolation": map[string]any{ + "workspace": isolation.WorkspaceDir, + "profile": isolation.ProfileDir, + "profile_mode": "WithCloneProfile(IncludeSettings: true, AuthMode: CloneProfileAuthLink)", + "cleanup_on_exit": !isolation.Keep, + "native_settings": "copied into isolated profile", + "native_mcp": "not copied", + "native_skills": "not copied", + "native_auth_files": "linked when present", + }, + "server": map[string]any{ + "agent_card_url": agentCardURL, + "jsonrpc_url": jsonRPCURL, + }, + "agent_card": summary.AgentCard, + "request": map[string]any{ + "context_id": *contextID, + "prompt": *prompt, + "expect": *expect, + }, + "stream": summary.Stream, + "poll": summary.Poll, + "assistant_output": map[string]any{ + "chars": len([]rune(summary.AssistantOutput)), + "preview": preview(summary.AssistantOutput, 240), + }, + }) +} + +type isolationConfig struct { + Agent string + RootDir string + WorkspaceDir string + ProfileDir string + Keep bool + cleanupRoot bool +} + +func newIsolation(agent, workspace, profile string, keep bool) (isolationConfig, error) { + cfg := isolationConfig{Agent: exampleutil.ResolveLiveAgent(agent), Keep: keep} + if strings.TrimSpace(workspace) == "" || strings.TrimSpace(profile) == "" { + root, err := os.MkdirTemp("", "agent-adaptor-a2a-local-*") + if err != nil { + return isolationConfig{}, err + } + cfg.RootDir = root + cfg.cleanupRoot = !keep + } + + workspaceDir := strings.TrimSpace(workspace) + if workspaceDir == "" { + workspaceDir = filepath.Join(cfg.RootDir, "workspace") + } + profileDir := strings.TrimSpace(profile) + if profileDir == "" { + profileDir = filepath.Join(cfg.RootDir, cfg.Agent+"-profile") + } + + var err error + cfg.WorkspaceDir, err = ensureDir(workspaceDir) + if err != nil { + cfg.Cleanup() + return isolationConfig{}, err + } + cfg.ProfileDir, err = ensureDir(profileDir) + if err != nil { + cfg.Cleanup() + return isolationConfig{}, err + } + return cfg, nil +} + +func (cfg isolationConfig) Cleanup() { + if cfg.cleanupRoot && cfg.RootDir != "" { + _ = os.RemoveAll(cfg.RootDir) + } +} + +func ensureDir(path string) (string, error) { + cleaned := filepath.Clean(path) + absolute, err := filepath.Abs(cleaned) + if err != nil { + return "", err + } + if err := os.MkdirAll(absolute, 0o755); err != nil { + return "", err + } + return absolute, nil +} + +func preferDirectlyExecutableCommand(cfg exampleutil.LiveAgentConfig) exampleutil.LiveAgentConfig { + if runtime.GOOS != "windows" || !strings.EqualFold(filepath.Ext(cfg.Command), ".ps1") { + return cfg + } + base := strings.TrimSuffix(cfg.Command, filepath.Ext(cfg.Command)) + for _, candidate := range []string{base + ".cmd", base + ".exe"} { + if exampleutil.ProbeAgentCommand(candidate) { + cfg.Command = candidate + cfg.CommandNote += " Streaming examples execute the provider command directly; using the Windows executable shim instead of the PowerShell shim." + return cfg + } + } + return cfg +} + +type demoSummary struct { + AgentCard map[string]any + Stream streamSummary + Poll map[string]any + AssistantOutput string +} + +type streamSummary struct { + TaskID string `json:"task_id"` + ContextID string `json:"context_id"` + States []string `json:"states"` + ArtifactChunks int `json:"artifact_chunks"` + ResultArtifactSeen bool `json:"result_artifact_seen"` + TerminalState string `json:"terminal_state"` + TerminalMessage string `json:"terminal_message,omitempty"` + RecoveredState bool `json:"recovered_state"` +} + +func runClientDemo(ctx context.Context, agentCardURL, contextID, prompt, expect string) (demoSummary, error) { + client := clienta2a.New(clienta2a.Options{ + AgentCardURL: agentCardURL, + PreferredTransports: []clienta2a.TransportProtocol{clienta2a.TransportJSONRPC}, + }) + defer client.Close() + + card, err := client.AgentCard(ctx) + if err != nil { + return demoSummary{}, err + } + if !card.Capabilities.Streaming { + return demoSummary{}, fmt.Errorf("expected demo agent card to advertise streaming") + } + + stream, err := client.SendStream(ctx, clienta2a.SendRequest{ + ContextID: contextID, + AcceptedOutputModes: card.DefaultOutputModes, + Message: clienta2a.Message{ + Role: "user", + Parts: []clienta2a.Part{{ + Kind: clienta2a.PartText, + Text: prompt, + MediaType: "text/plain", + }}, + }, + Metadata: map[string]any{ + "example": "a2a-local", + }, + }) + if err != nil { + return demoSummary{}, err + } + defer stream.Close() + + streamOut, assistantOutput, err := consumeStream(stream) + if err != nil { + return demoSummary{}, err + } + if streamOut.TaskID == "" { + return demoSummary{}, fmt.Errorf("A2A stream did not return a task id") + } + if streamOut.TerminalState != string(clienta2a.TaskStateCompleted) { + return demoSummary{}, fmt.Errorf("A2A task ended in %s: %s", streamOut.TerminalState, defaultString(streamOut.TerminalMessage, "no terminal message")) + } + + historyLength := 4 + task, err := client.GetTask(ctx, clienta2a.GetTaskRequest{ + TaskID: streamOut.TaskID, + HistoryLength: &historyLength, + }) + if err != nil { + return demoSummary{}, err + } + if task.Status.State != clienta2a.TaskStateCompleted { + return demoSummary{}, fmt.Errorf("GetTask returned state %s: %s", task.Status.State, statusMessage(task.Status)) + } + if strings.TrimSpace(assistantOutput) == "" && task.Status.Message != nil { + assistantOutput = partsText(task.Status.Message.Parts) + } + if expected := strings.TrimSpace(expect); expected != "" && !strings.Contains(assistantOutput, expected) { + return demoSummary{}, fmt.Errorf("A2A task completed but assistant output did not contain %q: %q", expected, preview(assistantOutput, 240)) + } + + return demoSummary{ + AgentCard: map[string]any{ + "name": card.Name, + "url": card.URL, + "fingerprint": card.Fingerprint, + "streaming": card.Capabilities.Streaming, + "default_output_modes": card.DefaultOutputModes, + "skills": len(card.Skills), + }, + Stream: streamOut, + AssistantOutput: assistantOutput, + Poll: map[string]any{ + "task_id": task.ID, + "context_id": task.ContextID, + "state": task.Status.State, + "history_messages": len(task.Messages), + "artifacts": len(task.Artifacts), + "result_artifact_seen": taskHasArtifact(task, bridgea2a.ArtifactAgentAdaptorResult), + }, + }, nil +} + +func consumeStream(stream *clienta2a.Stream) (streamSummary, string, error) { + var summary streamSummary + var output strings.Builder + + for { + event, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + return summary, "", err + } + + if event.TaskID != "" { + summary.TaskID = event.TaskID + } + if event.ContextID != "" { + summary.ContextID = event.ContextID + } + + switch event.Kind { + case clienta2a.EventTask: + if event.Task != nil { + rememberState(&summary, event.Task.Status.State) + } + case clienta2a.EventStatus: + if event.Status != nil { + rememberState(&summary, event.Status.State) + } + case clienta2a.EventArtifact: + summary.ArtifactChunks++ + if event.Artifact == nil { + continue + } + switch event.Artifact.Name { + case bridgea2a.ArtifactAssistantOutput: + if !event.LastChunk { + output.WriteString(partsText(event.Artifact.Parts)) + } + case bridgea2a.ArtifactAgentAdaptorResult: + summary.ResultArtifactSeen = true + } + case clienta2a.EventTerminal: + applyTerminal(&summary, &output, event) + return summary, output.String(), nil + } + } + + return summary, output.String(), nil +} + +func applyTerminal(summary *streamSummary, output *strings.Builder, event clienta2a.Event) { + summary.RecoveredState = event.RecoveredState + if event.Task != nil { + rememberState(summary, event.Task.Status.State) + summary.TerminalState = string(event.Task.Status.State) + if output.Len() == 0 && event.Task.Status.Message != nil { + output.WriteString(partsText(event.Task.Status.Message.Parts)) + } + summary.TerminalMessage = statusMessage(event.Task.Status) + return + } + if event.Status != nil { + rememberState(summary, event.Status.State) + summary.TerminalState = string(event.Status.State) + if output.Len() == 0 && event.Status.Message != nil { + output.WriteString(partsText(event.Status.Message.Parts)) + } + summary.TerminalMessage = statusMessage(*event.Status) + return + } + if event.Message != nil { + summary.TerminalState = string(clienta2a.TaskStateCompleted) + if output.Len() == 0 { + output.WriteString(partsText(event.Message.Parts)) + } + summary.TerminalMessage = partsText(event.Message.Parts) + } +} + +func rememberState(summary *streamSummary, state clienta2a.TaskState) { + if state == "" { + return + } + value := string(state) + for _, existing := range summary.States { + if existing == value { + return + } + } + summary.States = append(summary.States, value) +} + +func taskHasArtifact(task clienta2a.Task, name string) bool { + for _, artifact := range task.Artifacts { + if artifact.Name == name { + return true + } + } + return false +} + +func partsText(parts []clienta2a.Part) string { + var out strings.Builder + for _, part := range parts { + if part.Kind == clienta2a.PartText { + out.WriteString(part.Text) + } + } + return out.String() +} + +func statusMessage(status clienta2a.TaskStatus) string { + if status.Message == nil { + return "" + } + return partsText(status.Message.Parts) +} + +func defaultString(value, fallback string) string { + if strings.TrimSpace(value) == "" { + return fallback + } + return value +} + +func newHTTPServer(server *bridgea2a.Server) *http.Server { + mux := http.NewServeMux() + mux.Handle(agentCardPath, server.AgentCardHandler()) + mux.Handle(jsonRPCPath, server.Handler()) + mux.HandleFunc("/", func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path != "/" { + http.NotFound(w, r) + return + } + w.Header().Set("Content-Type", "text/plain; charset=utf-8") + fmt.Fprintf(w, "agent-adaptor A2A demo\nagent card: %s\njson-rpc: %s\n", agentCardPath, jsonRPCPath) + }) + return &http.Server{ + Handler: mux, + ReadHeaderTimeout: 10 * time.Second, + } +} + +func shutdownServer(server *http.Server) { + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + _ = server.Shutdown(ctx) +} + +func demoContext(timeout time.Duration) (context.Context, context.CancelFunc) { + base, stop := signal.NotifyContext(context.Background(), os.Interrupt) + if timeout <= 0 { + return base, stop + } + ctx, cancel := context.WithTimeout(base, timeout) + return ctx, func() { + cancel() + stop() + } +} + +func publicBaseURL(addr net.Addr) string { + host, port, err := net.SplitHostPort(addr.String()) + if err != nil { + return "http://" + addr.String() + } + switch host { + case "", "::", "0.0.0.0": + host = "localhost" + } + return (&url.URL{Scheme: "http", Host: net.JoinHostPort(host, port)}).String() +} + +func preview(value string, maxRunes int) string { + value = strings.TrimSpace(value) + runes := []rune(value) + if len(runes) <= maxRunes { + return value + } + if maxRunes <= 3 { + return string(runes[:maxRunes]) + } + return string(runes[:maxRunes-3]) + "..." +} diff --git a/examples/run_examples.ps1 b/examples/run_examples.ps1 index fd2f080..ce5c02e 100644 --- a/examples/run_examples.ps1 +++ b/examples/run_examples.ps1 @@ -121,3 +121,4 @@ Run-Example -Name "sessions" -Arguments (@("./examples/codex-sessions") + $commo Run-Example -Name "admin-named" -Arguments (@("./examples/codex-admin-named") + $common) Run-Example -Name "skills-live" -Arguments (@("./examples/codex-skills-live") + $common) Run-Example -Name "profile-resources" -Arguments (@("./examples/profile-resources") + $common) +Run-Example -Name "a2a-local" -Arguments (@("./examples/a2a-local") + $common) diff --git a/go.mod b/go.mod index 19cb9c9..6d6d720 100644 --- a/go.mod +++ b/go.mod @@ -3,6 +3,7 @@ module github.com/agent-dance/agent-adaptor go 1.26.0 require ( + github.com/a2aproject/a2a-go/v2 v2.3.1 github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260420210844-ad3c22477b34 github.com/pelletier/go-toml/v2 v2.3.0 github.com/sourcegraph/jsonrpc2 v0.2.1 @@ -11,5 +12,7 @@ require ( require ( github.com/google/uuid v1.6.0 // indirect github.com/sirupsen/logrus v1.9.3 // indirect - golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 // indirect + golang.org/x/mod v0.33.0 // indirect + golang.org/x/sync v0.19.0 // indirect + golang.org/x/sys v0.40.0 // indirect ) diff --git a/go.sum b/go.sum index f225996..322f623 100644 --- a/go.sum +++ b/go.sum @@ -1,8 +1,12 @@ +github.com/a2aproject/a2a-go/v2 v2.3.1 h1:QWMdOX2UsJ8BJmjs952eo1FRyGsOVl0gFCKeM76AgGE= +github.com/a2aproject/a2a-go/v2 v2.3.1/go.mod h1:mkZr8y2bUgAVQsjs/5fHK7xrRlAHDybMEyxWh2tKRC8= github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260420210844-ad3c22477b34 h1:F0GE+2V9416XhJBiMptcU9oB1pjUUvj0/ib9bsLzqZo= github.com/ag-ui-protocol/ag-ui/sdks/community/go v0.0.0-20260420210844-ad3c22477b34/go.mod h1:ERAMOexUee4AIuoxksuuGoEcHl3aqLwaazjGwlR9ZCI= github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= +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/gorilla/websocket v1.4.1 h1:q7AeDBpnBk8AogcD4DSag/Ukw/KV+YhzLj2bP5HvKCM= @@ -18,8 +22,13 @@ github.com/sourcegraph/jsonrpc2 v0.2.1/go.mod h1:ZafdZgk/axhT1cvZAPOhw+95nz2I/Ra github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME= github.com/stretchr/testify v1.7.0 h1:nwc3DEeHmmLAfoZucVR881uASk0Mfjw8xYJ99tb5CcY= github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg= -golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8 h1:0A+M6Uqn+Eje4kHMK80dtF3JCXC4ykBgQG4Fe06QRhQ= +golang.org/x/mod v0.33.0 h1:tHFzIWbBifEmbwtGz65eaWyGiGZatSrT9prnU8DbVL8= +golang.org/x/mod v0.33.0/go.mod h1:swjeQEj+6r7fODbD2cqrnje9PnziFuw4bmLbBZFrQ5w= +golang.org/x/sync v0.19.0 h1:vV+1eWNmZ5geRlYjzm2adRgW2/mcpevXNg50YZtPCE4= +golang.org/x/sync v0.19.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI= golang.org/x/sys v0.0.0-20220715151400-c0bba94af5f8/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg= +golang.org/x/sys v0.40.0 h1:DBZZqJ2Rkml6QMQsZywtnjnnGvHza6BTfYFWY9kjEWQ= +golang.org/x/sys v0.40.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks= gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c h1:dUUwHk2QECo/6vqA44rthZ8ie2QXMNeKRTHCNY2nXvo= gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= diff --git a/pkg/bridges/a2a/card.go b/pkg/bridges/a2a/card.go new file mode 100644 index 0000000..c3f2626 --- /dev/null +++ b/pkg/bridges/a2a/card.go @@ -0,0 +1,200 @@ +package a2a + +import ( + "fmt" + "sort" + + a2aproto "github.com/a2aproject/a2a-go/v2/a2a" +) + +func buildAgentCard(in AgentCard) (*a2aproto.AgentCard, error) { + if in.Name == "" { + return nil, fmt.Errorf("a2a bridge: agent card name is required") + } + if in.Version == "" { + return nil, fmt.Errorf("a2a bridge: agent card version is required") + } + if in.URL == "" && len(in.Interfaces) == 0 { + return nil, fmt.Errorf("a2a bridge: agent card URL or interfaces are required") + } + + card := &a2aproto.AgentCard{ + Name: in.Name, + Description: in.Description, + Version: in.Version, + DocumentationURL: in.DocumentationURL, + IconURL: in.IconURL, + DefaultInputModes: defaultModes(in.DefaultInputModes), + DefaultOutputModes: defaultModes(in.DefaultOutputModes), + Capabilities: a2aproto.AgentCapabilities{ + Streaming: in.Capabilities.Streaming.enabledByDefault(true), + PushNotifications: in.Capabilities.PushNotifications, + ExtendedAgentCard: in.Capabilities.ExtendedAgentCard, + }, + } + if in.Provider != nil { + card.Provider = &a2aproto.AgentProvider{Org: in.Provider.Organization, URL: in.Provider.URL} + } + for _, ext := range in.Capabilities.Extensions { + card.Capabilities.Extensions = append(card.Capabilities.Extensions, a2aproto.AgentExtension{ + URI: ext.URI, Description: ext.Description, Required: ext.Required, Params: cloneMap(ext.Params), + }) + } + if len(in.Interfaces) == 0 { + card.SupportedInterfaces = append(card.SupportedInterfaces, a2aproto.NewAgentInterface(in.URL, a2aproto.TransportProtocolJSONRPC)) + } else { + for _, iface := range in.Interfaces { + u := iface.URL + if u == "" { + u = in.URL + } + if u == "" { + return nil, fmt.Errorf("a2a bridge: interface URL is required") + } + protocol := a2aproto.TransportProtocol(iface.ProtocolBinding) + if protocol == "" { + protocol = a2aproto.TransportProtocolJSONRPC + } + up := a2aproto.NewAgentInterface(u, protocol) + up.Tenant = iface.Tenant + if iface.ProtocolVersion != "" { + up.ProtocolVersion = a2aproto.ProtocolVersion(iface.ProtocolVersion) + } + card.SupportedInterfaces = append(card.SupportedInterfaces, up) + } + } + for _, skill := range in.Skills { + if skill.ID == "" { + return nil, fmt.Errorf("a2a bridge: skill id is required") + } + card.Skills = append(card.Skills, a2aproto.AgentSkill{ + ID: skill.ID, Name: skill.Name, Description: skill.Description, + Tags: append([]string(nil), skill.Tags...), Examples: append([]string(nil), skill.Examples...), + InputModes: append([]string(nil), skill.InputModes...), OutputModes: append([]string(nil), skill.OutputModes...), + }) + } + card.SecuritySchemes = convertSecuritySchemes(in.SecuritySchemes) + card.SecurityRequirements = convertSecurityRequirements(in.Security) + return card, nil +} + +func convertSecuritySchemes(in []SecurityScheme) a2aproto.NamedSecuritySchemes { + if len(in) == 0 { + return nil + } + out := make(a2aproto.NamedSecuritySchemes, len(in)) + for _, scheme := range in { + if scheme.Name == "" { + continue + } + switch scheme.Type { + case SecurityAPIKey: + location := a2aproto.APIKeySecuritySchemeLocation(scheme.In) + if location == "" { + location = a2aproto.APIKeySecuritySchemeLocationHeader + } + out[a2aproto.SecuritySchemeName(scheme.Name)] = a2aproto.APIKeySecurityScheme{ + Description: scheme.Description, Location: location, Name: scheme.ParamName, + } + case SecurityMutualTLS: + out[a2aproto.SecuritySchemeName(scheme.Name)] = a2aproto.MutualTLSSecurityScheme{Description: scheme.Description} + default: + httpScheme := scheme.Scheme + if httpScheme == "" { + httpScheme = "Bearer" + } + out[a2aproto.SecuritySchemeName(scheme.Name)] = a2aproto.HTTPAuthSecurityScheme{ + Description: scheme.Description, Scheme: httpScheme, BearerFormat: scheme.BearerFormat, + } + } + } + return out +} + +func convertSecurityRequirements(in []SecurityRequirement) a2aproto.SecurityRequirementsOptions { + if len(in) == 0 { + return nil + } + out := make(a2aproto.SecurityRequirementsOptions, 0, len(in)) + for _, req := range in { + one := a2aproto.SecurityRequirements{} + for name, scopes := range req.Schemes { + one[a2aproto.SecuritySchemeName(name)] = append(a2aproto.SecuritySchemeScopes(nil), scopes...) + } + if len(one) > 0 { + out = append(out, one) + } + } + return out +} + +func publicSecuritySchemes(in a2aproto.NamedSecuritySchemes) []SecurityScheme { + if len(in) == 0 { + return nil + } + names := make([]string, 0, len(in)) + for name := range in { + names = append(names, string(name)) + } + sort.Strings(names) + + out := make([]SecurityScheme, 0, len(names)) + for _, name := range names { + scheme := in[a2aproto.SecuritySchemeName(name)] + pub := SecurityScheme{Name: name} + switch s := scheme.(type) { + case a2aproto.APIKeySecurityScheme: + pub.Type = SecurityAPIKey + pub.Description = s.Description + pub.In = string(s.Location) + pub.ParamName = s.Name + case a2aproto.HTTPAuthSecurityScheme: + pub.Type = SecurityHTTP + pub.Description = s.Description + pub.Scheme = s.Scheme + pub.BearerFormat = s.BearerFormat + case a2aproto.MutualTLSSecurityScheme: + pub.Type = SecurityMutualTLS + pub.Description = s.Description + default: + continue + } + out = append(out, pub) + } + return out +} + +func publicSecurityRequirements(in a2aproto.SecurityRequirementsOptions) []SecurityRequirement { + if len(in) == 0 { + return nil + } + out := make([]SecurityRequirement, 0, len(in)) + for _, req := range in { + pub := SecurityRequirement{Schemes: map[string][]string{}} + for name, scopes := range req { + pub.Schemes[string(name)] = append([]string(nil), scopes...) + } + if len(pub.Schemes) > 0 { + out = append(out, pub) + } + } + return out +} + +func defaultModes(modes []string) []string { + if len(modes) == 0 { + return []string{"text/plain"} + } + return append([]string(nil), modes...) +} + +func (m CapabilityMode) enabledByDefault(fallback bool) bool { + switch m { + case CapabilityEnabled: + return true + case CapabilityDisabled: + return false + default: + return fallback + } +} diff --git a/pkg/bridges/a2a/convert.go b/pkg/bridges/a2a/convert.go new file mode 100644 index 0000000..89ab17c --- /dev/null +++ b/pkg/bridges/a2a/convert.go @@ -0,0 +1,254 @@ +package a2a + +import ( + "encoding/json" + "fmt" + "regexp" + "strings" + + a2aproto "github.com/a2aproject/a2a-go/v2/a2a" +) + +const redactedMarker = "[REDACTED]" + +var ( + inlineSecretPatterns = []*regexp.Regexp{ + regexp.MustCompile(`(?i)("?(?:authorization|proxy-authorization|x-api-key|api[-_]?key|access_token|refresh_token|id_token|client_secret|secret|password|passwd|cookie|set-cookie)"?\s*[:=]\s*"?)(?:(?:bearer|basic)\s+[^"\s,;]+|[^"\s,;]+)("?)`), + regexp.MustCompile(`(?i)\b(bearer|basic)\s+[A-Za-z0-9._~+/=-]+`), + } +) + +func inboundRequest(execCtx taskInfo) InboundRequest { + msg := convertMessage(execCtx.Message()) + return InboundRequest{ + TaskID: execCtx.TaskIDString(), ContextID: execCtx.ContextIDString(), + Message: msg, Metadata: cloneMap(execCtx.MetadataMap()), + } +} + +type taskInfo interface { + TaskIDString() string + ContextIDString() string + Message() *a2aproto.Message + MetadataMap() map[string]any +} + +func convertMessage(msg *a2aproto.Message) Message { + if msg == nil { + return Message{} + } + out := Message{ + ID: msg.ID, Role: string(msg.Role), TaskID: string(msg.TaskID), ContextID: msg.ContextID, + Extensions: append([]string(nil), msg.Extensions...), Metadata: cloneMap(msg.Metadata), + } + for _, id := range msg.ReferenceTasks { + out.ReferenceTasks = append(out.ReferenceTasks, string(id)) + } + for _, part := range msg.Parts { + out.Parts = append(out.Parts, convertPart(part)) + } + return out +} + +func convertPart(p *a2aproto.Part) Part { + if p == nil { + return Part{} + } + out := Part{MediaType: p.MediaType, Filename: p.Filename, Metadata: cloneMap(p.Metadata)} + switch v := p.Content.(type) { + case a2aproto.Text: + out.Kind = PartText + out.Text = string(v) + case a2aproto.Raw: + out.Kind = PartRaw + out.Raw = append([]byte(nil), []byte(v)...) + case a2aproto.Data: + out.Kind = PartData + out.Data = v.Value + case a2aproto.URL: + out.Kind = PartURL + out.URL = string(v) + } + return out +} + +func textPart(text string) *a2aproto.Part { + return a2aproto.NewTextPart(text) +} + +func dataPart(data any) *a2aproto.Part { + return a2aproto.NewDataPart(data) +} + +func agentMessage(info a2aproto.TaskInfoProvider, text string) *a2aproto.Message { + if text == "" { + return nil + } + return a2aproto.NewMessageForTask(a2aproto.MessageRoleAgent, info, textPart(text)) +} + +func failureMessage(info a2aproto.TaskInfoProvider, msg string, details map[string]any) *a2aproto.Message { + if msg == "" { + msg = "agent run failed" + } + part := textPart(msg) + if len(details) > 0 { + part.Metadata = map[string]any{"agentadaptor.failure": details} + } + return a2aproto.NewMessageForTask(a2aproto.MessageRoleAgent, info, part) +} + +func rawMap(v any) map[string]any { + raw, err := json.Marshal(v) + if err != nil { + return map[string]any{"marshal_error": err.Error()} + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return map[string]any{"unmarshal_error": err.Error()} + } + return out +} + +func cloneMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func stringMapAny(in map[string]string) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} + +func mustJSON(v any) string { + raw, err := json.Marshal(v) + if err != nil { + return fmt.Sprintf("%v", v) + } + return string(raw) +} + +func sanitizeRemoteMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + sanitized, ok := sanitizeRemoteValue(in).(map[string]any) + if !ok { + return nil + } + return sanitized +} + +func sanitizeRemoteValue(v any) any { + normalized, ok := normalizeJSONValue(v) + if !ok { + return redactRemoteValue("", v) + } + return redactRemoteValue("", normalized) +} + +func normalizeJSONValue(v any) (any, bool) { + if v == nil { + return nil, true + } + raw, err := json.Marshal(v) + if err != nil { + return nil, false + } + var out any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, false + } + return out, true +} + +func redactRemoteValue(key string, v any) any { + switch typed := v.(type) { + case map[string]any: + out := make(map[string]any, len(typed)) + for k, child := range typed { + if isSensitiveKey(k) { + out[k] = redactedMarker + continue + } + out[k] = redactRemoteValue(k, child) + } + return out + case []any: + out := make([]any, len(typed)) + for i, child := range typed { + out[i] = redactRemoteValue(key, child) + } + return out + case string: + if isSensitiveKey(key) { + return redactedMarker + } + return redactInlineSecrets(typed) + default: + return v + } +} + +func redactInlineSecrets(s string) string { + out := s + for i, pattern := range inlineSecretPatterns { + switch i { + case 0: + out = pattern.ReplaceAllString(out, "${1}"+redactedMarker+"$2") + default: + out = pattern.ReplaceAllStringFunc(out, func(match string) string { + parts := strings.Fields(match) + if len(parts) == 0 { + return redactedMarker + } + return parts[0] + " " + redactedMarker + }) + } + } + return out +} + +func isSensitiveKey(key string) bool { + switch normalizeSensitiveKey(key) { + case "authorization", + "proxyauthorization", + "apikey", + "xapikey", + "token", + "accesstoken", + "refreshtoken", + "idtoken", + "clientsecret", + "secret", + "password", + "passwd", + "cookie", + "setcookie", + "bearer", + "privatekey", + "credential", + "credentials": + return true + default: + return false + } +} + +func normalizeSensitiveKey(key string) string { + key = strings.ToLower(strings.TrimSpace(key)) + replacer := strings.NewReplacer("-", "", "_", "", " ", "") + return replacer.Replace(key) +} diff --git a/pkg/bridges/a2a/doc.go b/pkg/bridges/a2a/doc.go new file mode 100644 index 0000000..2a2419a --- /dev/null +++ b/pkg/bridges/a2a/doc.go @@ -0,0 +1,17 @@ +// Package a2a exposes an agentadaptor.Runner as an A2A-compatible agent. +// +// Scope: +// - converts a host-supplied Agent Card to the official A2A model +// - exposes HTTP handlers that hosts can mount wherever they serve A2A +// - maps SendMessage and SendStreamingMessage onto Runner.Start +// - maps StreamEvents, Wait, and Cancel onto Task status/artifact updates +// +// The bridge is intentionally adapter-agnostic: it depends only on the core +// Runner / RunHandle contracts and never imports concrete adapters. It uses +// github.com/a2aproject/a2a-go/v2/a2asrv for protocol handling; hosts remain +// responsible for HTTP routing, auth middleware, TLS, tenancy, and durability. +// +// Inbound prompt extraction defaults to the last non-empty text part. Hosts can +// provide a PromptBuilder to support domain-specific message, file, or data-part +// projection without changing the SDK core. +package a2a diff --git a/pkg/bridges/a2a/import_boundary_test.go b/pkg/bridges/a2a/import_boundary_test.go new file mode 100644 index 0000000..f39c6bb --- /dev/null +++ b/pkg/bridges/a2a/import_boundary_test.go @@ -0,0 +1,50 @@ +package a2a_test + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestBridgePackageDoesNotImportConcreteAdaptersOrA2AClient(t *testing.T) { + t.Parallel() + + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + root := filepath.Dir(thisFile) + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("readdir: %v", err) + } + forbidden := []string{ + "github.com/agent-dance/agent-adaptor/claude", + "github.com/agent-dance/agent-adaptor/codex", + "github.com/agent-dance/agent-adaptor/cursor", + "github.com/agent-dance/agent-adaptor/pkg/clients/a2a", + } + fset := token.NewFileSet() + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + path := filepath.Join(root, entry.Name()) + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + for _, imp := range file.Imports { + pathValue := strings.Trim(imp.Path.Value, `"`) + for _, forbiddenImport := range forbidden { + if pathValue == forbiddenImport { + t.Fatalf("%s imports forbidden dependency %s", path, forbiddenImport) + } + } + } + } +} diff --git a/pkg/bridges/a2a/mapping.go b/pkg/bridges/a2a/mapping.go new file mode 100644 index 0000000..106d45c --- /dev/null +++ b/pkg/bridges/a2a/mapping.go @@ -0,0 +1,286 @@ +package a2a + +import ( + "sort" + + a2aproto "github.com/a2aproject/a2a-go/v2/a2a" + + agentadaptor "github.com/agent-dance/agent-adaptor" +) + +type streamTranslator struct { + info a2aproto.TaskInfoProvider + exposure ExposurePolicy + started map[a2aproto.ArtifactID]bool +} + +func newStreamTranslator(info a2aproto.TaskInfoProvider, exposure ExposurePolicy) *streamTranslator { + return &streamTranslator{ + info: info, exposure: exposure, started: map[a2aproto.ArtifactID]bool{}, + } +} + +func (t *streamTranslator) Translate(p agentadaptor.StreamPayload) []a2aproto.Event { + switch p.Kind { + case agentadaptor.StreamTextContent: + if p.Delta == "" { + return nil + } + return []a2aproto.Event{t.artifact(ArtifactAssistantOutput, ArtifactAssistantOutput, p.Delta, true)} + case agentadaptor.StreamTextEnd: + return t.closeArtifact(ArtifactAssistantOutput, ArtifactAssistantOutput) + case agentadaptor.StreamToolCallStart: + if !t.exposure.IncludeToolCalls { + return nil + } + data := map[string]any{"kind": "tool_call.start", "id": p.ToolCallID, "name": p.Name} + if len(p.Args) > 0 { + data["args"] = sanitizeRemoteValue(p.Args) + } + id := a2aproto.ArtifactID("tool-call-" + defaultString(p.ToolCallID, p.Name)) + return []a2aproto.Event{t.dataArtifact(id, string(id), data, true)} + case agentadaptor.StreamToolCallArgs: + if !t.exposure.IncludeToolCalls || p.Delta == "" { + return nil + } + id := a2aproto.ArtifactID("tool-call-" + defaultString(p.ToolCallID, "args")) + return []a2aproto.Event{t.artifact(id, string(id), p.Delta, true)} + case agentadaptor.StreamToolCallEnd: + if !t.exposure.IncludeToolCalls { + return nil + } + id := a2aproto.ArtifactID("tool-call-" + defaultString(p.ToolCallID, "args")) + return t.closeArtifact(id, string(id)) + case agentadaptor.StreamToolCallResult: + if !t.exposure.IncludeToolCalls { + return nil + } + id := a2aproto.ArtifactID("tool-call-" + defaultString(p.ToolCallID, "result")) + return []a2aproto.Event{t.dataArtifact(id, string(id), map[string]any{ + "kind": "tool_call.result", "id": p.ToolCallID, "result": sanitizeRemoteValue(p.Result), + }, true)} + case agentadaptor.StreamReasoningContent: + if !t.exposure.IncludeReasoning || p.Delta == "" { + return nil + } + ev := t.artifact("reasoning", "reasoning", p.Delta, true) + ev.Artifact.Name = "reasoning" + return []a2aproto.Event{ev} + case agentadaptor.StreamReasoningEnd: + if !t.exposure.IncludeReasoning { + return nil + } + return t.closeArtifact("reasoning", "reasoning") + case agentadaptor.StreamHITLRequested: + if !t.exposure.IncludeHITL { + return nil + } + return []a2aproto.Event{t.dataArtifact("human-decision-request", "human-decision-request", hitlRequestedArtifact(p, t.exposure), true)} + case agentadaptor.StreamHITLResolved: + if !t.exposure.IncludeHITL { + return nil + } + return []a2aproto.Event{t.dataArtifact("human-decision-result", "human-decision-result", hitlResolvedArtifact(p, t.exposure), true)} + case agentadaptor.StreamRunError: + msg := "stream run error" + if p.Error != nil && p.Error.Message != "" { + msg = p.Error.Message + } + return []a2aproto.Event{a2aproto.NewStatusUpdateEvent(t.info, a2aproto.TaskStateFailed, failureMessage(t.info, msg, streamFailureDetails(p.Error, t.exposure)))} + case agentadaptor.StreamDropped: + if !t.exposure.hasStreamingDiagnostics() { + return nil + } + data := map[string]any{"kind": string(p.Kind)} + if dropped, ok := p.Raw["dropped_count"]; ok { + data["dropped_count"] = dropped + } + return []a2aproto.Event{t.dataArtifact("stream-dropped", "stream-dropped", data, true)} + default: + return nil + } +} + +func (t *streamTranslator) artifact(id a2aproto.ArtifactID, name, text string, appendChunk bool) *a2aproto.TaskArtifactUpdateEvent { + var ev *a2aproto.TaskArtifactUpdateEvent + if t.started[id] { + ev = a2aproto.NewArtifactUpdateEvent(t.info, id, textPart(text)) + } else { + ev = a2aproto.NewArtifactUpdateEvent(t.info, id, textPart(text)) + ev.Append = false + t.started[id] = true + } + ev.Artifact.ID = id + ev.Artifact.Name = name + ev.Append = appendChunk && ev.Append + return ev +} + +func (t *streamTranslator) closeArtifact(id a2aproto.ArtifactID, name string) []a2aproto.Event { + if !t.started[id] { + return nil + } + ev := a2aproto.NewArtifactUpdateEvent(t.info, id, textPart("")) + ev.Artifact.ID = id + ev.Artifact.Name = name + ev.LastChunk = true + t.started[id] = false + return []a2aproto.Event{ev} +} + +func (t *streamTranslator) CloseOpen() []a2aproto.Event { + if len(t.started) == 0 { + return nil + } + ids := make([]string, 0, len(t.started)) + for id, started := range t.started { + if started { + ids = append(ids, string(id)) + } + } + sort.Strings(ids) + out := make([]a2aproto.Event, 0, len(ids)) + for _, id := range ids { + out = append(out, t.closeArtifact(a2aproto.ArtifactID(id), id)...) + } + return out +} + +func (t *streamTranslator) dataArtifact(id a2aproto.ArtifactID, name string, data map[string]any, lastChunk bool) *a2aproto.TaskArtifactUpdateEvent { + ev := a2aproto.NewArtifactUpdateEvent(t.info, id, dataPart(data)) + if !t.started[id] { + ev.Append = false + t.started[id] = true + } + ev.Artifact.ID = id + ev.Artifact.Name = name + ev.LastChunk = lastChunk + if lastChunk { + t.started[id] = false + } + return ev +} + +func terminalArtifacts(info a2aproto.TaskInfoProvider, result agentadaptor.RunResult, exposure ExposurePolicy) []a2aproto.Event { + var out []a2aproto.Event + details := map[string]any{ + "summary": result.Summary, + } + if exposure.Diagnostics.IncludeMetadata { + if metadata := sanitizeRemoteMap(stringMapAny(result.Metadata)); len(metadata) > 0 { + details["metadata"] = metadata + } + } + if exposure.Diagnostics.IncludeProviderResult && result.Result != nil { + details["result"] = sanitizeRemoteValue(result.Result) + } + if exposure.Diagnostics.IncludeUsage && result.Usage != nil { + details["usage"] = sanitizeRemoteValue(result.Usage) + } + if exposure.Diagnostics.IncludeTranscript && len(result.Transcript) > 0 { + details["transcript"] = sanitizeRemoteValue(result.Transcript) + } + if exposure.Diagnostics.IncludeRawStreams && result.RawStreams != nil { + details["raw_streams"] = map[string]any{ + "stdout": redactInlineSecrets(result.RawStreams.Stdout), + "stderr": redactInlineSecrets(result.RawStreams.Stderr), + } + } + ev := a2aproto.NewArtifactUpdateEvent(info, ArtifactAgentAdaptorResult, dataPart(details)) + ev.Append = false + ev.LastChunk = true + ev.Artifact.ID = ArtifactAgentAdaptorResult + ev.Artifact.Name = ArtifactAgentAdaptorResult + out = append(out, ev) + return out +} + +func failureDetails(f *agentadaptor.RunFailure, exposure ExposurePolicy) map[string]any { + if f == nil { + return nil + } + out := map[string]any{"code": string(f.Code)} + if exposure.Diagnostics.IncludeMetadata { + if metadata := sanitizeRemoteMap(f.Metadata); len(metadata) > 0 { + out["metadata"] = metadata + } + } + if f.HumanDecision != nil { + human := map[string]any{ + "kind": string(f.HumanDecision.Kind), + "source": f.HumanDecision.Source, + "decision": string(f.HumanDecision.Decision), + "attempts": f.HumanDecision.Attempts, + } + if exposure.IncludeHITL && exposure.Diagnostics.IncludeHITLPayloads && f.HumanDecision.Request != nil { + human["request"] = sanitizeRemoteValue(f.HumanDecision.Request) + } + out["human_decision"] = human + } + return out +} + +func streamFailureDetails(f *agentadaptor.RunFailure, exposure ExposurePolicy) map[string]any { + if f == nil { + return nil + } + return failureDetails(f, exposure) +} + +func defaultString(v, fallback string) string { + if v == "" { + return fallback + } + return v +} + +func (p ExposurePolicy) hasStreamingDiagnostics() bool { + return p.IncludeReasoning || + p.IncludeToolCalls || + p.IncludeHITL || + p.Diagnostics.IncludeMetadata || + p.Diagnostics.IncludeUsage || + p.Diagnostics.IncludeProviderResult || + p.Diagnostics.IncludeTranscript || + p.Diagnostics.IncludeRawStreams +} + +func hitlRequestedArtifact(p agentadaptor.StreamPayload, exposure ExposurePolicy) map[string]any { + out := map[string]any{"kind": string(p.Kind)} + if p.HITLRequested == nil { + return out + } + out["request_id"] = p.HITLRequested.RequestID + out["decision_kind"] = string(p.HITLRequested.Kind) + out["source"] = p.HITLRequested.Source + out["retry_attempt"] = p.HITLRequested.RetryAttempt + if !p.HITLRequested.Deadline.IsZero() { + out["deadline"] = p.HITLRequested.Deadline.UTC().Format("2006-01-02T15:04:05Z") + } + if exposure.Diagnostics.IncludeHITLPayloads { + out["request"] = sanitizeRemoteValue(p.HITLRequested) + } + if exposure.Diagnostics.IncludeHITLRaw && len(p.Raw) > 0 { + out["raw"] = sanitizeRemoteValue(p.Raw) + } + return out +} + +func hitlResolvedArtifact(p agentadaptor.StreamPayload, exposure ExposurePolicy) map[string]any { + out := map[string]any{"kind": string(p.Kind)} + if p.HITLResolved == nil { + return out + } + out["request_id"] = p.HITLResolved.RequestID + out["decision_kind"] = string(p.HITLResolved.Kind) + out["source"] = p.HITLResolved.Source + out["retry_attempt"] = p.HITLResolved.RetryAttempt + out["result"] = string(p.HITLResolved.Result) + if exposure.Diagnostics.IncludeHITLPayloads { + out["response"] = sanitizeRemoteValue(p.HITLResolved) + } + if exposure.Diagnostics.IncludeHITLRaw && len(p.Raw) > 0 { + out["raw"] = sanitizeRemoteValue(p.Raw) + } + return out +} diff --git a/pkg/bridges/a2a/server.go b/pkg/bridges/a2a/server.go new file mode 100644 index 0000000..4881c6c --- /dev/null +++ b/pkg/bridges/a2a/server.go @@ -0,0 +1,438 @@ +package a2a + +import ( + "context" + "errors" + "fmt" + "iter" + "net/http" + "strings" + "sync" + "time" + + a2aproto "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + + agentadaptor "github.com/agent-dance/agent-adaptor" +) + +type Server struct { + runner agentadaptor.Runner + card *a2aproto.AgentCard + + handler a2asrv.RequestHandler + jsonHandler http.Handler + cardHandler http.Handler +} + +func NewServer(runner agentadaptor.Runner, opts ServerOptions) *Server { + if runner == nil { + panic("a2a bridge: nil runner") + } + card, err := buildAgentCard(opts.AgentCard) + if err != nil { + panic(err) + } + taskStore, err := newConfiguredTaskStore(opts.TaskLifecycle) + if err != nil { + panic(err) + } + if opts.Session == nil { + opts.Session = Stateless() + } + if opts.Prompt == nil { + opts.Prompt = PromptBuilderFunc(defaultPrompt) + } + + exec := &executor{ + runner: runner, session: opts.Session, prompt: opts.Prompt, + runOptions: append([]agentadaptor.RunOption(nil), opts.RunOptions...), + exposure: opts.Exposure, + active: map[a2aproto.TaskID]agentadaptor.RunHandle{}, + pending: map[a2aproto.TaskID]context.CancelFunc{}, + } + handlerOpts := []a2asrv.RequestHandlerOption{a2asrv.WithTaskStore(taskStore)} + capabilityOpts, err := requestHandlerCapabilityOptions(card, opts) + if err != nil { + panic(err) + } + handlerOpts = append(handlerOpts, capabilityOpts...) + handlerOpts = append(handlerOpts, a2asrv.WithCapabilityChecks(&card.Capabilities)) + + requestHandler := a2asrv.NewHandler(exec, handlerOpts...) + return &Server{ + runner: runner, + card: card, + handler: requestHandler, + jsonHandler: a2asrv.NewJSONRPCHandler(requestHandler), + cardHandler: a2asrv.NewStaticAgentCardHandler(card), + } +} + +func (s *Server) Handler() http.Handler { + return s.jsonHandler +} + +func (s *Server) AgentCardHandler() http.Handler { + return s.cardHandler +} + +func (s *Server) AgentCard() AgentCard { + return publicCard(s.card) +} + +type executor struct { + runner agentadaptor.Runner + session SessionMapper + prompt PromptBuilder + runOptions []agentadaptor.RunOption + exposure ExposurePolicy + + mu sync.Mutex + active map[a2aproto.TaskID]agentadaptor.RunHandle + pending map[a2aproto.TaskID]context.CancelFunc +} + +func (e *executor) Execute(ctx context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2aproto.Event, error] { + return func(yield func(a2aproto.Event, error) bool) { + req := inboundFromExecCtx(execCtx) + prompt, promptOpts, err := e.prompt.BuildPrompt(ctx, req) + if err != nil { + yield(nil, fmt.Errorf("%w: %v", a2aproto.ErrInvalidParams, err)) + return + } + if strings.TrimSpace(prompt) == "" { + yield(nil, fmt.Errorf("%w: prompt is empty", a2aproto.ErrInvalidParams)) + return + } + sessionOpts, err := e.session.RunOptions(ctx, req) + if err != nil { + yield(nil, fmt.Errorf("%w: %v", a2aproto.ErrInvalidParams, err)) + return + } + runOpts := append([]agentadaptor.RunOption(nil), e.runOptions...) + runOpts = append(runOpts, sessionOpts...) + runOpts = append(runOpts, promptOpts...) + runOpts = append(runOpts, agentadaptor.WithStreaming()) + + runCtx, cancelRunCtx := context.WithCancel(ctx) + e.storePending(execCtx.TaskID, cancelRunCtx) + defer func() { + e.deletePending(execCtx.TaskID) + cancelRunCtx() + }() + + if execCtx.StoredTask == nil && execCtx.Message != nil { + if !yield(a2aproto.NewSubmittedTask(execCtx, execCtx.Message), nil) { + return + } + } + if !yield(a2aproto.NewStatusUpdateEvent(execCtx, a2aproto.TaskStateWorking, nil), nil) { + return + } + if runCtx.Err() != nil { + yield(canceledStatus(execCtx, runCtx.Err()), nil) + return + } + + handle, err := e.runner.Start(runCtx, prompt, runOpts...) + if err != nil { + if runCtx.Err() != nil { + yield(canceledStatus(execCtx, runCtx.Err()), nil) + return + } + msg := failureMessage(execCtx, err.Error(), map[string]any{"layer": "start"}) + yield(a2aproto.NewStatusUpdateEvent(execCtx, a2aproto.TaskStateFailed, msg), nil) + return + } + if runCtx.Err() != nil { + _ = cancelRunHandle(runCtx, handle) + yield(canceledStatus(execCtx, runCtx.Err()), nil) + return + } + e.store(execCtx.TaskID, handle) + defer e.delete(execCtx.TaskID) + + translator := newStreamTranslator(execCtx, e.exposure) + waitCh := make(chan waitResult, 1) + go func() { + result, err := handle.Wait(runCtx) + waitCh <- waitResult{result: result, err: err} + }() + + for { + select { + case <-runCtx.Done(): + _ = cancelRunHandle(runCtx, handle) + yield(canceledStatus(execCtx, runCtx.Err()), nil) + return + case p, ok := <-handle.StreamEvents(): + if !ok { + goto drained + } + for _, ev := range translator.Translate(p) { + if !yield(ev, nil) { + _ = cancelRunHandle(runCtx, handle) + return + } + } + } + } + + drained: + out := <-waitCh + for _, ev := range translator.CloseOpen() { + if !yield(ev, nil) { + return + } + } + if out.err != nil { + state := a2aproto.TaskStateFailed + if errors.Is(out.err, context.Canceled) { + state = a2aproto.TaskStateCanceled + } + msg := failureMessage(execCtx, out.err.Error(), map[string]any{"layer": "wait"}) + yield(a2aproto.NewStatusUpdateEvent(execCtx, state, msg), nil) + return + } + for _, ev := range terminalArtifacts(execCtx, out.result, e.exposure) { + if !yield(ev, nil) { + return + } + } + if out.result.Failure != nil { + msg := failureMessage(execCtx, out.result.Failure.Message, failureDetails(out.result.Failure, e.exposure)) + yield(a2aproto.NewStatusUpdateEvent(execCtx, a2aproto.TaskStateFailed, msg), nil) + return + } + yield(a2aproto.NewStatusUpdateEvent(execCtx, a2aproto.TaskStateCompleted, agentMessage(execCtx, out.result.Output)), nil) + } +} + +func (e *executor) Cancel(ctx context.Context, execCtx *a2asrv.ExecutorContext) iter.Seq2[a2aproto.Event, error] { + return func(yield func(a2aproto.Event, error) bool) { + e.cancelPending(execCtx.TaskID) + handle := e.load(execCtx.TaskID) + if handle != nil { + _ = cancelRunHandle(ctx, handle) + } + msg := agentMessage(execCtx, "task cancelled") + yield(a2aproto.NewStatusUpdateEvent(execCtx, a2aproto.TaskStateCanceled, msg), nil) + } +} + +const cancelRunTimeout = 5 * time.Second + +func cancelRunHandle(ctx context.Context, handle agentadaptor.RunHandle) error { + if handle == nil { + return nil + } + if ctx == nil { + ctx = context.Background() + } + cancelCtx, cancel := context.WithTimeout(context.WithoutCancel(ctx), cancelRunTimeout) + defer cancel() + return handle.Cancel(cancelCtx) +} + +type waitResult struct { + result agentadaptor.RunResult + err error +} + +func (e *executor) store(id a2aproto.TaskID, handle agentadaptor.RunHandle) { + e.mu.Lock() + defer e.mu.Unlock() + e.active[id] = handle +} + +func (e *executor) load(id a2aproto.TaskID) agentadaptor.RunHandle { + e.mu.Lock() + defer e.mu.Unlock() + return e.active[id] +} + +func (e *executor) delete(id a2aproto.TaskID) { + e.mu.Lock() + defer e.mu.Unlock() + delete(e.active, id) +} + +func (e *executor) storePending(id a2aproto.TaskID, cancel context.CancelFunc) { + e.mu.Lock() + defer e.mu.Unlock() + e.pending[id] = cancel +} + +func (e *executor) cancelPending(id a2aproto.TaskID) { + e.mu.Lock() + cancel := e.pending[id] + e.mu.Unlock() + if cancel != nil { + cancel() + } +} + +func (e *executor) deletePending(id a2aproto.TaskID) { + e.mu.Lock() + defer e.mu.Unlock() + delete(e.pending, id) +} + +func canceledStatus(info a2aproto.TaskInfoProvider, cause error) *a2aproto.TaskStatusUpdateEvent { + msg := "task cancelled" + if cause != nil { + msg = cause.Error() + } + return a2aproto.NewStatusUpdateEvent(info, a2aproto.TaskStateCanceled, failureMessage(info, msg, map[string]any{"code": string(agentadaptor.FailureCancelled)})) +} + +type execCtxAdapter struct { + *a2asrv.ExecutorContext +} + +func inboundFromExecCtx(ctx *a2asrv.ExecutorContext) InboundRequest { + if ctx == nil { + return InboundRequest{} + } + return inboundRequest(execCtxAdapter{ctx}) +} + +func (e execCtxAdapter) TaskIDString() string { + return string(e.TaskID) +} + +func (e execCtxAdapter) ContextIDString() string { + return e.ContextID +} + +func (e execCtxAdapter) Message() *a2aproto.Message { + return e.ExecutorContext.Message +} + +func (e execCtxAdapter) MetadataMap() map[string]any { + return e.Metadata +} + +func defaultPrompt(_ context.Context, req InboundRequest) (string, []agentadaptor.RunOption, error) { + for i := len(req.Message.Parts) - 1; i >= 0; i-- { + part := req.Message.Parts[i] + if part.Kind == PartText && strings.TrimSpace(part.Text) != "" { + return part.Text, nil, nil + } + } + return "", nil, fmt.Errorf("no user text part in A2A message") +} + +func publicCard(card *a2aproto.AgentCard) AgentCard { + if card == nil { + return AgentCard{} + } + out := AgentCard{ + Name: card.Name, Description: card.Description, Version: card.Version, + DocumentationURL: card.DocumentationURL, IconURL: card.IconURL, + DefaultInputModes: append([]string(nil), card.DefaultInputModes...), + DefaultOutputModes: append([]string(nil), card.DefaultOutputModes...), + Capabilities: Capabilities{ + Streaming: capabilityMode(card.Capabilities.Streaming), + PushNotifications: card.Capabilities.PushNotifications, + ExtendedAgentCard: card.Capabilities.ExtendedAgentCard, + }, + SecuritySchemes: publicSecuritySchemes(card.SecuritySchemes), + Security: publicSecurityRequirements(card.SecurityRequirements), + } + if card.Provider != nil { + out.Provider = &Provider{Organization: card.Provider.Org, URL: card.Provider.URL} + } + for _, ext := range card.Capabilities.Extensions { + out.Capabilities.Extensions = append(out.Capabilities.Extensions, Extension{ + URI: ext.URI, Description: ext.Description, Required: ext.Required, Params: cloneMap(ext.Params), + }) + } + for _, iface := range card.SupportedInterfaces { + if iface == nil { + continue + } + if out.URL == "" { + out.URL = iface.URL + } + out.Interfaces = append(out.Interfaces, AgentInterface{ + URL: iface.URL, ProtocolBinding: string(iface.ProtocolBinding), Tenant: iface.Tenant, + ProtocolVersion: string(iface.ProtocolVersion), + }) + } + for _, skill := range card.Skills { + out.Skills = append(out.Skills, Skill{ + ID: skill.ID, Name: skill.Name, Description: skill.Description, + Tags: append([]string(nil), skill.Tags...), Examples: append([]string(nil), skill.Examples...), + InputModes: append([]string(nil), skill.InputModes...), OutputModes: append([]string(nil), skill.OutputModes...), + }) + } + return out +} + +func requestHandlerCapabilityOptions(card *a2aproto.AgentCard, opts ServerOptions) ([]a2asrv.RequestHandlerOption, error) { + var out []a2asrv.RequestHandlerOption + if card.Capabilities.PushNotifications { + if opts.PushNotifications == nil { + return nil, fmt.Errorf("a2a bridge: push notifications capability requires explicit PushNotifications support") + } + if opts.PushNotifications.Store == nil || opts.PushNotifications.Sender == nil { + return nil, fmt.Errorf("a2a bridge: push notification support requires both Store and Sender") + } + out = append(out, a2asrv.WithPushNotifications(opts.PushNotifications.Store, opts.PushNotifications.Sender)) + } else if opts.PushNotifications != nil { + return nil, fmt.Errorf("a2a bridge: push notifications support requires AgentCard.Capabilities.PushNotifications=true") + } + + if card.Capabilities.ExtendedAgentCard { + if opts.ExtendedAgentCard == nil { + return nil, fmt.Errorf("a2a bridge: extended agent card capability requires explicit ExtendedAgentCard support") + } + extendedOpt, err := extendedAgentCardOption(opts.ExtendedAgentCard) + if err != nil { + return nil, err + } + out = append(out, extendedOpt) + } else if opts.ExtendedAgentCard != nil { + return nil, fmt.Errorf("a2a bridge: extended agent card support requires AgentCard.Capabilities.ExtendedAgentCard=true") + } + return out, nil +} + +func extendedAgentCardOption(support *ExtendedAgentCardSupport) (a2asrv.RequestHandlerOption, error) { + if support == nil { + return nil, fmt.Errorf("a2a bridge: extended agent card support is nil") + } + if support.Static != nil && support.Provider != nil { + return nil, fmt.Errorf("a2a bridge: extended agent card support accepts either Static or Provider, not both") + } + if support.Static != nil { + card, err := buildAgentCard(*support.Static) + if err != nil { + return nil, err + } + return a2asrv.WithExtendedAgentCard(card), nil + } + if support.Provider == nil { + return nil, fmt.Errorf("a2a bridge: extended agent card support requires Static or Provider") + } + return a2asrv.WithExtendedAgentCardProducer(a2asrv.ExtendedAgentCardProducerFn(func(ctx context.Context, req *a2aproto.GetExtendedAgentCardRequest) (*a2aproto.AgentCard, error) { + var tenant string + if req != nil { + tenant = req.Tenant + } + card, err := support.Provider.ExtendedCard(ctx, ExtendedAgentCardRequest{Tenant: tenant}) + if err != nil { + return nil, err + } + return buildAgentCard(card) + })), nil +} + +func capabilityMode(v bool) CapabilityMode { + if v { + return CapabilityEnabled + } + return CapabilityDisabled +} diff --git a/pkg/bridges/a2a/server_test.go b/pkg/bridges/a2a/server_test.go new file mode 100644 index 0000000..3ca9c96 --- /dev/null +++ b/pkg/bridges/a2a/server_test.go @@ -0,0 +1,839 @@ +package a2a_test + +import ( + "bufio" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "strings" + "sync" + "sync/atomic" + "testing" + "time" + + a2aproto "github.com/a2aproject/a2a-go/v2/a2a" + a2ataskstore "github.com/a2aproject/a2a-go/v2/a2asrv/taskstore" + agentadaptor "github.com/agent-dance/agent-adaptor" + "github.com/agent-dance/agent-adaptor/pkg/bridges/a2a" +) + +func TestAgentCardHandlerReturnsConfiguredCard(t *testing.T) { + t.Parallel() + + server := a2a.NewServer(fakeRunner{}, a2a.ServerOptions{ + AgentCard: a2a.AgentCard{ + Name: "Bridge Agent", Description: "test", Version: "1.0.0", URL: "https://example.com/a2a", + Skills: []a2a.Skill{{ID: "chat", Name: "Chat", Description: "chat"}}, + }, + }) + + rec := httptest.NewRecorder() + server.AgentCardHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/agent-card.json", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var card struct { + Name string `json:"name"` + Capabilities struct { + Streaming bool `json:"streaming"` + } `json:"capabilities"` + SupportedInterfaces []struct { + URL string `json:"url"` + ProtocolBinding string `json:"protocolBinding"` + ProtocolVersion string `json:"protocolVersion"` + } `json:"supportedInterfaces"` + } + if err := json.NewDecoder(rec.Body).Decode(&card); err != nil { + t.Fatalf("decode card: %v", err) + } + if card.Name != "Bridge Agent" || !card.Capabilities.Streaming { + t.Fatalf("unexpected card: %+v", card) + } + if len(card.SupportedInterfaces) != 1 || card.SupportedInterfaces[0].ProtocolVersion != "1.0" { + t.Fatalf("interfaces = %+v", card.SupportedInterfaces) + } +} + +func TestAgentCardIntrospectionPreservesConfiguredDetails(t *testing.T) { + t.Parallel() + + server := a2a.NewServer(fakeRunner{}, a2a.ServerOptions{ + AgentCard: a2a.AgentCard{ + Name: "Bridge Agent", + Description: "test", + Version: "1.0.0", + URL: "https://example.com/a2a", + Provider: &a2a.Provider{Organization: "Agent Dance", URL: "https://example.com"}, + Capabilities: a2a.Capabilities{ + Streaming: a2a.CapabilityEnabled, + Extensions: []a2a.Extension{{ + URI: "https://example.com/ext", Description: "extension", Required: true, + Params: map[string]any{"mode": "strict"}, + }}, + }, + Skills: []a2a.Skill{{ + ID: "chat", Name: "Chat", Description: "chat", + Tags: []string{"coding"}, Examples: []string{"review this"}, + InputModes: []string{"text/plain"}, OutputModes: []string{"text/plain"}, + }}, + SecuritySchemes: []a2a.SecurityScheme{{ + Name: "bearer", Type: a2a.SecurityHTTP, Scheme: "Bearer", BearerFormat: "JWT", + }}, + Security: []a2a.SecurityRequirement{{Schemes: map[string][]string{"bearer": {"task.write"}}}}, + }, + }) + + card := server.AgentCard() + if card.Provider == nil || card.Provider.Organization != "Agent Dance" { + t.Fatalf("provider not preserved: %+v", card.Provider) + } + if card.Capabilities.Streaming != a2a.CapabilityEnabled || len(card.Capabilities.Extensions) != 1 { + t.Fatalf("capabilities not preserved: %+v", card.Capabilities) + } + if len(card.Skills) != 1 || len(card.Skills[0].Tags) != 1 || len(card.Skills[0].Examples) != 1 || len(card.Skills[0].InputModes) != 1 { + t.Fatalf("skill details not preserved: %+v", card.Skills) + } + if len(card.SecuritySchemes) != 1 || card.SecuritySchemes[0].Name != "bearer" || card.SecuritySchemes[0].BearerFormat != "JWT" { + t.Fatalf("security schemes not preserved: %+v", card.SecuritySchemes) + } + if len(card.Security) != 1 || fmt.Sprint(card.Security[0].Schemes["bearer"]) != "[task.write]" { + t.Fatalf("security requirements not preserved: %+v", card.Security) + } +} + +func TestAgentCardHandlerSupportsStreamingFalse(t *testing.T) { + t.Parallel() + + server := a2a.NewServer(fakeRunner{}, a2a.ServerOptions{ + AgentCard: a2a.AgentCard{ + Name: "Bridge Agent", Description: "test", Version: "1.0.0", URL: "https://example.com/a2a", + Capabilities: a2a.Capabilities{Streaming: a2a.CapabilityDisabled}, + Skills: []a2a.Skill{{ID: "chat", Name: "Chat", Description: "chat"}}, + }, + }) + + rec := httptest.NewRecorder() + server.AgentCardHandler().ServeHTTP(rec, httptest.NewRequest(http.MethodGet, "/.well-known/agent-card.json", nil)) + if rec.Code != http.StatusOK { + t.Fatalf("status = %d", rec.Code) + } + var card struct { + Capabilities struct { + Streaming bool `json:"streaming"` + } `json:"capabilities"` + } + if err := json.NewDecoder(rec.Body).Decode(&card); err != nil { + t.Fatalf("decode card: %v", err) + } + if card.Capabilities.Streaming { + t.Fatalf("streaming capability = true, want false") + } +} + +func TestNewServerRejectsMisconfiguredCapabilities(t *testing.T) { + t.Parallel() + + baseCard := a2a.AgentCard{ + Name: "Bridge Agent", Description: "test", Version: "1.0.0", URL: "https://example.com/a2a", + Skills: []a2a.Skill{{ID: "chat", Name: "Chat", Description: "chat"}}, + } + + tests := []struct { + name string + opts a2a.ServerOptions + want string + }{ + { + name: "push capability without collaborators", + opts: a2a.ServerOptions{ + AgentCard: func() a2a.AgentCard { + card := baseCard + card.Capabilities.PushNotifications = true + return card + }(), + }, + want: "push notifications capability requires explicit PushNotifications support", + }, + { + name: "extended capability without collaborators", + opts: a2a.ServerOptions{ + AgentCard: func() a2a.AgentCard { + card := baseCard + card.Capabilities.ExtendedAgentCard = true + return card + }(), + }, + want: "extended agent card capability requires explicit ExtendedAgentCard support", + }, + { + name: "push collaborators without capability", + opts: a2a.ServerOptions{ + AgentCard: baseCard, + PushNotifications: &a2a.PushNotificationSupport{ + Store: new(noopPushConfigStore), + Sender: noopPushSender{}, + }, + }, + want: "push notifications support requires AgentCard.Capabilities.PushNotifications=true", + }, + { + name: "extended collaborators without capability", + opts: a2a.ServerOptions{ + AgentCard: baseCard, + ExtendedAgentCard: &a2a.ExtendedAgentCardSupport{ + Static: &baseCard, + }, + }, + want: "extended agent card support requires AgentCard.Capabilities.ExtendedAgentCard=true", + }, + } + + for _, tc := range tests { + t.Run(tc.name, func(t *testing.T) { + defer func() { + r := recover() + if r == nil { + t.Fatalf("expected panic containing %q", tc.want) + } + if !strings.Contains(fmt.Sprint(r), tc.want) { + t.Fatalf("panic = %v, want substring %q", r, tc.want) + } + }() + _ = a2a.NewServer(fakeRunner{}, tc.opts) + }) + } +} + +func TestSendMessageMapsRunnerToA2ATask(t *testing.T) { + t.Parallel() + + var starts atomic.Int32 + server := a2a.NewServer(scriptedRunner{start: func(ctx context.Context, prompt string, opts ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) { + starts.Add(1) + if prompt != "hello bridge" { + t.Fatalf("prompt = %q", prompt) + } + h := newScriptedHandle("run-1") + go func() { + h.emit(agentadaptor.StreamPayload{Kind: agentadaptor.StreamTextContent, Delta: "hello"}) + h.finish(agentadaptor.RunResult{ + RunID: "run-1", DriverType: "fake", Output: "hello", Summary: "done", + Result: map[string]any{"ok": true}, + }, nil) + }() + return h, nil + }}, testOptions()) + + resp := postRPC(t, server.Handler(), `{"jsonrpc":"2.0","id":"1","method":"SendMessage","params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hello bridge"}]}}}`) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var envelope struct { + Result struct { + Task *struct { + ID string `json:"id"` + Status struct { + State string `json:"state"` + } `json:"status"` + Artifacts []taskArtifact `json:"artifacts"` + } `json:"task"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + if starts.Load() != 1 { + t.Fatalf("Start calls = %d", starts.Load()) + } + if envelope.Result.Task == nil || envelope.Result.Task.Status.State != "TASK_STATE_COMPLETED" { + t.Fatalf("task = %+v", envelope.Result.Task) + } + if len(envelope.Result.Task.Artifacts) == 0 { + t.Fatalf("expected artifacts: %+v", envelope.Result.Task) + } +} + +func TestInvalidPromptRequestDoesNotStartRunner(t *testing.T) { + t.Parallel() + + var starts atomic.Int32 + server := a2a.NewServer(scriptedRunner{start: func(context.Context, string, ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) { + starts.Add(1) + return nil, errors.New("runner should not start") + }}, testOptions()) + + resp := postRPC(t, server.Handler(), `{"jsonrpc":"2.0","id":"bad","method":"SendMessage","params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"data":{"x":1}}]}}}`) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var envelope struct { + Error *struct { + Message string `json:"message"` + } `json:"error"` + } + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + if starts.Load() != 0 { + t.Fatalf("Start calls = %d, want 0", starts.Load()) + } + if envelope.Error == nil || !strings.Contains(envelope.Error.Message, "no user text part") { + t.Fatalf("unexpected error envelope: %+v", envelope.Error) + } +} + +func TestSendMessageDefaultExposureOmitsDiagnostics(t *testing.T) { + t.Parallel() + + server := a2a.NewServer(scriptedRunner{start: func(ctx context.Context, prompt string, opts ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) { + h := newScriptedHandle("run-privacy") + go func() { + h.finish(agentadaptor.RunResult{ + RunID: "run-privacy", DriverType: "fake", Output: "hello", Summary: "safe summary", + Metadata: map[string]string{"authorization": "Bearer super-secret"}, + Usage: &agentadaptor.Usage{InputTokens: 12, OutputTokens: 34}, + Result: map[string]any{"token": "secret-token"}, + Transcript: []agentadaptor.TranscriptItem{{ + Kind: agentadaptor.TranscriptAssistant, Text: "hidden transcript", + }}, + RawStreams: &agentadaptor.RawStreams{Stdout: "Authorization: Bearer hidden"}, + }, nil) + }() + return h, nil + }}, testOptions()) + + resp := postRPC(t, server.Handler(), `{"jsonrpc":"2.0","id":"1","method":"SendMessage","params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hello bridge"}]}}}`) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var envelope struct { + Result struct { + Task *struct { + Artifacts []taskArtifact `json:"artifacts"` + } `json:"task"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + artifact := findTaskArtifact(t, envelope.Result.Task.Artifacts, a2a.ArtifactAgentAdaptorResult) + data := artifact.Parts[0].Data + if got := data["summary"]; got != "safe summary" { + t.Fatalf("summary = %#v", got) + } + for _, forbidden := range []string{"metadata", "usage", "result", "transcript", "raw_streams", "run_id", "driver_type"} { + if _, ok := data[forbidden]; ok { + t.Fatalf("unexpected diagnostic field %q in %+v", forbidden, data) + } + } +} + +func TestSendMessageExposurePolicyRedactsDiagnostics(t *testing.T) { + t.Parallel() + + server := a2a.NewServer(scriptedRunner{start: func(ctx context.Context, prompt string, opts ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) { + h := newScriptedHandle("run-redacted") + go func() { + h.finish(agentadaptor.RunResult{ + Output: "hello", + Summary: "safe summary", + Metadata: map[string]string{ + "authorization": "Bearer super-secret", + "trace_id": "trace-123", + }, + Usage: &agentadaptor.Usage{InputTokens: 12, OutputTokens: 34}, + Result: map[string]any{ + "headers": map[string]any{ + "Authorization": "Bearer sk-live-123", + "X-Trace": "trace-456", + }, + "access_token": "opaque-token", + }, + Transcript: []agentadaptor.TranscriptItem{{ + Kind: agentadaptor.TranscriptToolResult, + Text: `Authorization: Bearer sk-protocol-456`, + Data: map[string]any{"refresh_token": "refresh-secret"}, + }}, + RawStreams: &agentadaptor.RawStreams{ + Stdout: `{"authorization":"Bearer stdout-secret"}`, + Stderr: "Bearer stderr-secret", + }, + }, nil) + }() + return h, nil + }}, a2a.ServerOptions{ + AgentCard: testOptions().AgentCard, + Exposure: a2a.ExposurePolicy{ + Diagnostics: a2a.DiagnosticsPolicy{ + IncludeMetadata: true, + IncludeUsage: true, + IncludeProviderResult: true, + IncludeTranscript: true, + IncludeRawStreams: true, + }, + }, + }) + + resp := postRPC(t, server.Handler(), `{"jsonrpc":"2.0","id":"1","method":"SendMessage","params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hello bridge"}]}}}`) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + var envelope struct { + Result struct { + Task *struct { + Artifacts []taskArtifact `json:"artifacts"` + } `json:"task"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&envelope); err != nil { + t.Fatalf("decode response: %v", err) + } + artifact := findTaskArtifact(t, envelope.Result.Task.Artifacts, a2a.ArtifactAgentAdaptorResult) + data := artifact.Parts[0].Data + + metadata := nestedMap(t, data["metadata"], "metadata") + if metadata["authorization"] != "[REDACTED]" { + t.Fatalf("metadata.authorization = %#v", metadata["authorization"]) + } + if metadata["trace_id"] != "trace-123" { + t.Fatalf("metadata.trace_id = %#v", metadata["trace_id"]) + } + + result := nestedMap(t, data["result"], "result") + if result["access_token"] != "[REDACTED]" { + t.Fatalf("result.access_token = %#v", result["access_token"]) + } + headers := nestedMap(t, result["headers"], "result.headers") + if headers["Authorization"] != "[REDACTED]" { + t.Fatalf("result.headers.Authorization = %#v", headers["Authorization"]) + } + if headers["X-Trace"] != "trace-456" { + t.Fatalf("result.headers.X-Trace = %#v", headers["X-Trace"]) + } + + rawStreams := nestedMap(t, data["raw_streams"], "raw_streams") + if rawStreams["stdout"] != `{"authorization":"[REDACTED]"}` { + t.Fatalf("raw_streams.stdout = %#v", rawStreams["stdout"]) + } + if rawStreams["stderr"] != "Bearer [REDACTED]" { + t.Fatalf("raw_streams.stderr = %#v", rawStreams["stderr"]) + } + + transcript, ok := data["transcript"].([]any) + if !ok || len(transcript) != 1 { + t.Fatalf("transcript = %#v", data["transcript"]) + } + item := nestedMap(t, transcript[0], "transcript[0]") + if item["Text"] != "Authorization: [REDACTED]" { + t.Fatalf("transcript[0].Text = %#v", item["Text"]) + } + itemData := nestedMap(t, item["Data"], "transcript[0].Data") + if itemData["refresh_token"] != "[REDACTED]" { + t.Fatalf("transcript[0].Data.refresh_token = %#v", itemData["refresh_token"]) + } +} + +func TestSendStreamingMessageEmitsOrderedUpdates(t *testing.T) { + t.Parallel() + + server := a2a.NewServer(scriptedRunner{start: func(ctx context.Context, prompt string, opts ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) { + h := newScriptedHandle("run-stream") + go func() { + time.Sleep(20 * time.Millisecond) + h.emit(agentadaptor.StreamPayload{Kind: agentadaptor.StreamTextContent, Delta: "hel"}) + h.emit(agentadaptor.StreamPayload{Kind: agentadaptor.StreamTextContent, Delta: "lo"}) + h.emit(agentadaptor.StreamPayload{Kind: agentadaptor.StreamTextEnd}) + h.finish(agentadaptor.RunResult{RunID: "run-stream", Output: "hello", Summary: "done"}, nil) + }() + return h, nil + }}, testOptions()) + + resp := postRPC(t, server.Handler(), `{"jsonrpc":"2.0","id":"stream","method":"SendStreamingMessage","params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"stream"}]}}}`) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + frames := readSSEFrames(t, resp.Body, 2*time.Second) + if len(frames) < 6 { + t.Fatalf("frames = %d, want at least 6: %+v", len(frames), frames) + } + if frames[0].Result.Task == nil { + t.Fatalf("first frame should be task: %+v", frames[0]) + } + if frames[1].Result.StatusUpdate == nil || frames[1].Result.StatusUpdate.Status.State != "TASK_STATE_WORKING" { + t.Fatalf("second frame should be working: %+v", frames[1]) + } + if frames[2].Result.ArtifactUpdate == nil || frames[2].Result.ArtifactUpdate.Artifact.Name != a2a.ArtifactAssistantOutput || frames[2].Result.ArtifactUpdate.Artifact.Parts[0].Text != "hel" || frames[2].Result.ArtifactUpdate.LastChunk { + t.Fatalf("third frame should be artifact hel: %+v", frames[2]) + } + if frames[3].Result.ArtifactUpdate == nil || frames[3].Result.ArtifactUpdate.Artifact.Name != a2a.ArtifactAssistantOutput || frames[3].Result.ArtifactUpdate.Artifact.Parts[0].Text != "lo" || frames[3].Result.ArtifactUpdate.LastChunk { + t.Fatalf("fourth frame should be artifact lo: %+v", frames[3]) + } + if frames[4].Result.ArtifactUpdate == nil || frames[4].Result.ArtifactUpdate.Artifact.Name != a2a.ArtifactAssistantOutput || !frames[4].Result.ArtifactUpdate.LastChunk { + t.Fatalf("fifth frame should close assistant-output: %+v", frames[4]) + } + if frames[5].Result.ArtifactUpdate == nil || frames[5].Result.ArtifactUpdate.Artifact.Name != a2a.ArtifactAgentAdaptorResult || !frames[5].Result.ArtifactUpdate.LastChunk { + t.Fatalf("sixth frame should close agent-adaptor-result: %+v", frames[5]) + } + if frames[len(frames)-1].Result.StatusUpdate == nil || frames[len(frames)-1].Result.StatusUpdate.Status.State != "TASK_STATE_COMPLETED" { + t.Fatalf("last frame should be completed: %+v", frames[len(frames)-1]) + } +} + +func TestCancelTaskCancelsUnderlyingRun(t *testing.T) { + t.Parallel() + + handle := newScriptedHandle("run-cancel") + runStarted := make(chan struct{}) + server := a2a.NewServer(scriptedRunner{start: func(ctx context.Context, prompt string, opts ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) { + close(runStarted) + return handle, nil + }}, testOptions()) + + resp := postRPC(t, server.Handler(), `{"jsonrpc":"2.0","id":"start","method":"SendMessage","params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"cancel"}]},"configuration":{"returnImmediately":true}}}`) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("send status = %d", resp.StatusCode) + } + var started struct { + Result struct { + Task struct { + ID string `json:"id"` + } `json:"task"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&started); err != nil { + t.Fatalf("decode start: %v", err) + } + if started.Result.Task.ID == "" { + t.Fatal("missing task id") + } + select { + case <-runStarted: + case <-time.After(2 * time.Second): + t.Fatal("runner did not start before cancellation") + } + + cancel := postRPC(t, server.Handler(), fmt.Sprintf(`{"jsonrpc":"2.0","id":"cancel","method":"CancelTask","params":{"id":%q}}`, started.Result.Task.ID)) + defer cancel.Body.Close() + if cancel.StatusCode != http.StatusOK { + t.Fatalf("cancel status = %d", cancel.StatusCode) + } + if handle.cancelled.Load() != 1 { + t.Fatalf("Cancel calls = %d", handle.cancelled.Load()) + } + if handle.cancelHadDeadline.Load() != 1 { + t.Fatal("Cancel context did not carry a deadline") + } + var cancelled struct { + Result struct { + Status struct { + State string `json:"state"` + } `json:"status"` + } `json:"result"` + } + if err := json.NewDecoder(cancel.Body).Decode(&cancelled); err != nil { + t.Fatalf("decode cancel: %v", err) + } + if cancelled.Result.Status.State != "TASK_STATE_CANCELED" { + t.Fatalf("cancelled state = %q", cancelled.Result.Status.State) + } +} + +func TestNewServerUsesInjectedTaskStore(t *testing.T) { + t.Parallel() + + store := &countingTaskStore{Store: a2ataskstore.NewInMemory(nil)} + server := a2a.NewServer(scriptedRunner{start: func(ctx context.Context, prompt string, opts ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) { + h := newScriptedHandle("run-store") + go h.finish(agentadaptor.RunResult{RunID: "run-store", Output: "ok", Summary: "done"}, nil) + return h, nil + }}, a2a.ServerOptions{ + AgentCard: testOptions().AgentCard, + TaskLifecycle: a2a.TaskLifecycleOptions{Store: store}, + }) + + resp := postRPC(t, server.Handler(), `{"jsonrpc":"2.0","id":"1","method":"SendMessage","params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"hello bridge"}]}}}`) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("status = %d", resp.StatusCode) + } + if store.creates.Load() == 0 { + t.Fatal("expected injected task store to receive Create") + } +} + +func TestCancelTaskCancelsStartupContextBeforeRunnerReturns(t *testing.T) { + t.Parallel() + + startEntered := make(chan struct{}) + startCancelled := make(chan struct{}) + server := a2a.NewServer(scriptedRunner{start: func(ctx context.Context, prompt string, opts ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) { + close(startEntered) + <-ctx.Done() + close(startCancelled) + return nil, ctx.Err() + }}, testOptions()) + + resp := postRPC(t, server.Handler(), `{"jsonrpc":"2.0","id":"start","method":"SendMessage","params":{"message":{"messageId":"m1","role":"ROLE_USER","parts":[{"text":"cancel startup"}]},"configuration":{"returnImmediately":true}}}`) + defer resp.Body.Close() + if resp.StatusCode != http.StatusOK { + t.Fatalf("send status = %d", resp.StatusCode) + } + var started struct { + Result struct { + Task struct { + ID string `json:"id"` + } `json:"task"` + } `json:"result"` + } + if err := json.NewDecoder(resp.Body).Decode(&started); err != nil { + t.Fatalf("decode start: %v", err) + } + if started.Result.Task.ID == "" { + t.Fatal("missing task id") + } + select { + case <-startEntered: + case <-time.After(2 * time.Second): + t.Fatal("runner Start was not invoked") + } + + cancel := postRPC(t, server.Handler(), fmt.Sprintf(`{"jsonrpc":"2.0","id":"cancel","method":"CancelTask","params":{"id":%q}}`, started.Result.Task.ID)) + defer cancel.Body.Close() + if cancel.StatusCode != http.StatusOK { + t.Fatalf("cancel status = %d", cancel.StatusCode) + } + select { + case <-startCancelled: + case <-time.After(2 * time.Second): + t.Fatal("runner Start context was not cancelled") + } +} + +func testOptions() a2a.ServerOptions { + return a2a.ServerOptions{AgentCard: a2a.AgentCard{ + Name: "Test Agent", Description: "test", Version: "1.0.0", URL: "https://example.com/a2a", + Skills: []a2a.Skill{{ID: "chat", Name: "Chat", Description: "chat"}}, + }} +} + +type scriptedRunner struct { + start func(context.Context, string, ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) +} + +func (r scriptedRunner) Run(ctx context.Context, prompt string, opts ...agentadaptor.RunOption) (agentadaptor.RunResult, error) { + handle, err := r.Start(ctx, prompt, opts...) + if err != nil { + return agentadaptor.RunResult{}, err + } + return handle.Wait(ctx) +} + +func (r scriptedRunner) Start(ctx context.Context, prompt string, opts ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) { + if r.start == nil { + return nil, errors.New("unexpected start") + } + return r.start(ctx, prompt, opts...) +} + +type fakeRunner struct{} + +func (fakeRunner) Run(context.Context, string, ...agentadaptor.RunOption) (agentadaptor.RunResult, error) { + return agentadaptor.RunResult{}, nil +} + +func (fakeRunner) Start(context.Context, string, ...agentadaptor.RunOption) (agentadaptor.RunHandle, error) { + return nil, errors.New("not implemented") +} + +type scriptedHandle struct { + runID string + stream chan agentadaptor.StreamPayload + done chan waitResult + once sync.Once + cancelled atomic.Int32 + cancelHadDeadline atomic.Int32 +} + +type waitResult struct { + result agentadaptor.RunResult + err error +} + +func newScriptedHandle(runID string) *scriptedHandle { + return &scriptedHandle{runID: runID, stream: make(chan agentadaptor.StreamPayload, 16), done: make(chan waitResult, 1)} +} + +func (h *scriptedHandle) Events() <-chan agentadaptor.RunEvent { + ch := make(chan agentadaptor.RunEvent) + close(ch) + return ch +} +func (h *scriptedHandle) StreamEvents() <-chan agentadaptor.StreamPayload { return h.stream } +func (h *scriptedHandle) RunID() string { return h.runID } +func (h *scriptedHandle) DecisionRequests() <-chan agentadaptor.DecisionRequest { + ch := make(chan agentadaptor.DecisionRequest) + close(ch) + return ch +} +func (h *scriptedHandle) ResolveDecision(string, agentadaptor.DecisionResponse) error { return nil } +func (h *scriptedHandle) Wait(ctx context.Context) (agentadaptor.RunResult, error) { + select { + case <-ctx.Done(): + return agentadaptor.RunResult{}, ctx.Err() + case r := <-h.done: + return r.result, r.err + } +} +func (h *scriptedHandle) Cancel(ctx context.Context) error { + h.once.Do(func() { + h.cancelled.Add(1) + if _, ok := ctx.Deadline(); ok { + h.cancelHadDeadline.Store(1) + } + close(h.stream) + h.done <- waitResult{err: context.Canceled} + }) + return nil +} +func (h *scriptedHandle) emit(p agentadaptor.StreamPayload) { h.stream <- p } +func (h *scriptedHandle) finish(result agentadaptor.RunResult, err error) { + h.once.Do(func() { + close(h.stream) + h.done <- waitResult{result: result, err: err} + }) +} + +type countingTaskStore struct { + a2ataskstore.Store + creates atomic.Int32 +} + +func (s *countingTaskStore) Create(ctx context.Context, task *a2aproto.Task) (a2ataskstore.TaskVersion, error) { + s.creates.Add(1) + return s.Store.Create(ctx, task) +} + +type noopPushConfigStore struct{} + +func (*noopPushConfigStore) Save(context.Context, a2aproto.TaskID, *a2aproto.PushConfig) (*a2aproto.PushConfig, error) { + return nil, nil +} +func (*noopPushConfigStore) Get(context.Context, a2aproto.TaskID, string) (*a2aproto.PushConfig, error) { + return nil, nil +} +func (*noopPushConfigStore) List(context.Context, a2aproto.TaskID) ([]*a2aproto.PushConfig, error) { + return nil, nil +} +func (*noopPushConfigStore) Delete(context.Context, a2aproto.TaskID, string) error { return nil } +func (*noopPushConfigStore) DeleteAll(context.Context, a2aproto.TaskID) error { return nil } + +type noopPushSender struct{} + +func (noopPushSender) SendPush(context.Context, *a2aproto.PushConfig, a2aproto.Event) error { + return nil +} + +func postRPC(t *testing.T, handler http.Handler, body string) *http.Response { + t.Helper() + srv := httptest.NewServer(handler) + t.Cleanup(srv.Close) + resp, err := http.Post(srv.URL, "application/json", strings.NewReader(body)) + if err != nil { + t.Fatalf("post RPC: %v", err) + } + return resp +} + +type sseFrame struct { + Result struct { + Task *struct { + ID string `json:"id"` + } `json:"task"` + StatusUpdate *struct { + Status struct { + State string `json:"state"` + } `json:"status"` + } `json:"statusUpdate"` + ArtifactUpdate *struct { + Append bool `json:"append"` + LastChunk bool `json:"lastChunk"` + Artifact struct { + ID string `json:"artifactId"` + Name string `json:"name"` + Parts []struct { + Text string `json:"text"` + Data map[string]any `json:"data"` + } `json:"parts"` + } `json:"artifact"` + } `json:"artifactUpdate"` + } `json:"result"` +} + +type taskArtifact struct { + Name string `json:"name"` + Parts []taskArtifactPart `json:"parts"` +} + +type taskArtifactPart struct { + Text string `json:"text"` + Data map[string]any `json:"data"` +} + +func readSSEFrames(t *testing.T, body io.Reader, timeout time.Duration) []sseFrame { + t.Helper() + done := make(chan []sseFrame, 1) + go func() { + scanner := bufio.NewScanner(body) + scanner.Buffer(make([]byte, 0, 64*1024), 1024*1024) + var frames []sseFrame + for scanner.Scan() { + line := scanner.Text() + if !strings.HasPrefix(line, "data: ") { + continue + } + var frame sseFrame + if err := json.Unmarshal([]byte(strings.TrimPrefix(line, "data: ")), &frame); err == nil { + frames = append(frames, frame) + } + } + done <- frames + }() + select { + case frames := <-done: + return frames + case <-time.After(timeout): + t.Fatal("timed out reading SSE") + return nil + } +} + +func findTaskArtifact(t *testing.T, artifacts []taskArtifact, name string) taskArtifact { + t.Helper() + for _, artifact := range artifacts { + if artifact.Name == name { + return artifact + } + } + t.Fatalf("artifact %q not found in %+v", name, artifacts) + var zero taskArtifact + return zero +} + +func nestedMap(t *testing.T, value any, path string) map[string]any { + t.Helper() + out, ok := value.(map[string]any) + if !ok { + t.Fatalf("%s = %#v", path, value) + } + return out +} diff --git a/pkg/bridges/a2a/taskstore.go b/pkg/bridges/a2a/taskstore.go new file mode 100644 index 0000000..3e4706a --- /dev/null +++ b/pkg/bridges/a2a/taskstore.go @@ -0,0 +1,444 @@ +package a2a + +import ( + "context" + "encoding/base64" + "encoding/json" + "fmt" + "sort" + "strings" + "sync" + "time" + + a2aproto "github.com/a2aproject/a2a-go/v2/a2a" + "github.com/a2aproject/a2a-go/v2/a2asrv" + a2ataskstore "github.com/a2aproject/a2a-go/v2/a2asrv/taskstore" +) + +type ephemeralTaskStoreConfig struct { + maxTasks int + ttl time.Duration + auth a2ataskstore.Authenticator + now func() time.Time +} + +type ephemeralTaskRecord struct { + task *a2aproto.Task + version a2ataskstore.TaskVersion + user string + lastUpdated time.Time +} + +type ephemeralTaskStore struct { + mu sync.RWMutex + tasks map[a2aproto.TaskID]*ephemeralTaskRecord + config ephemeralTaskStoreConfig +} + +var _ a2ataskstore.Store = (*ephemeralTaskStore)(nil) + +func newConfiguredTaskStore(opts TaskLifecycleOptions) (a2ataskstore.Store, error) { + if opts.Store != nil { + return opts.Store, nil + } + cfg := EphemeralTaskStoreOptions{ + MaxTasks: DefaultEphemeralTaskLimit, + TTL: DefaultEphemeralTaskTTL, + } + if opts.Ephemeral != nil { + if opts.Ephemeral.MaxTasks < 0 { + return nil, fmt.Errorf("a2a bridge: ephemeral task store max tasks must be >= 0") + } + if opts.Ephemeral.TTL < 0 { + return nil, fmt.Errorf("a2a bridge: ephemeral task store TTL must be >= 0") + } + if opts.Ephemeral.MaxTasks > 0 { + cfg.MaxTasks = opts.Ephemeral.MaxTasks + } + if opts.Ephemeral.TTL > 0 { + cfg.TTL = opts.Ephemeral.TTL + } + } + return newEphemeralTaskStore(ephemeralTaskStoreConfig{ + maxTasks: cfg.MaxTasks, + ttl: cfg.TTL, + auth: a2asrv.NewTaskStoreAuthenticator(), + }) +} + +func newEphemeralTaskStore(cfg ephemeralTaskStoreConfig) (*ephemeralTaskStore, error) { + if cfg.maxTasks <= 0 { + return nil, fmt.Errorf("a2a bridge: ephemeral task store max tasks must be > 0") + } + if cfg.ttl <= 0 { + return nil, fmt.Errorf("a2a bridge: ephemeral task store TTL must be > 0") + } + if cfg.auth == nil { + cfg.auth = func(context.Context) (string, error) { return "", nil } + } + if cfg.now == nil { + cfg.now = time.Now + } + return &ephemeralTaskStore{ + tasks: make(map[a2aproto.TaskID]*ephemeralTaskRecord), + config: cfg, + }, nil +} + +func (s *ephemeralTaskStore) Create(ctx context.Context, task *a2aproto.Task) (a2ataskstore.TaskVersion, error) { + if err := validateStoredTask(task); err != nil { + return a2ataskstore.TaskVersionMissing, err + } + user, err := s.config.auth(ctx) + if err != nil { + return a2ataskstore.TaskVersionMissing, fmt.Errorf("taskstore auth failed: %w", err) + } + copy, err := cloneTask(task) + if err != nil { + return a2ataskstore.TaskVersionMissing, err + } + + s.mu.Lock() + defer s.mu.Unlock() + s.pruneExpiredLocked(s.config.now()) + if s.tasks[task.ID] != nil { + return a2ataskstore.TaskVersionMissing, a2ataskstore.ErrTaskAlreadyExists + } + version := a2ataskstore.TaskVersion(1) + s.tasks[task.ID] = &ephemeralTaskRecord{ + task: copy, + version: version, + user: user, + lastUpdated: s.config.now(), + } + s.evictOverflowLocked() + return version, nil +} + +func (s *ephemeralTaskStore) Update(ctx context.Context, req *a2ataskstore.UpdateRequest) (a2ataskstore.TaskVersion, error) { + if req == nil { + return a2ataskstore.TaskVersionMissing, fmt.Errorf("missing update request: %w", a2aproto.ErrInvalidParams) + } + if err := validateStoredTask(req.Task); err != nil { + return a2ataskstore.TaskVersionMissing, err + } + copy, err := cloneTask(req.Task) + if err != nil { + return a2ataskstore.TaskVersionMissing, err + } + + s.mu.Lock() + defer s.mu.Unlock() + s.pruneExpiredLocked(s.config.now()) + stored := s.tasks[req.Task.ID] + if stored == nil { + return a2ataskstore.TaskVersionMissing, a2aproto.ErrTaskNotFound + } + if req.PrevVersion != a2ataskstore.TaskVersionMissing && stored.version != req.PrevVersion { + return a2ataskstore.TaskVersionMissing, a2ataskstore.ErrConcurrentModification + } + version := stored.version + 1 + s.tasks[req.Task.ID] = &ephemeralTaskRecord{ + task: copy, + version: version, + user: stored.user, + lastUpdated: s.config.now(), + } + return version, nil +} + +func (s *ephemeralTaskStore) Get(ctx context.Context, taskID a2aproto.TaskID) (*a2ataskstore.StoredTask, error) { + s.mu.Lock() + defer s.mu.Unlock() + s.pruneExpiredLocked(s.config.now()) + stored := s.tasks[taskID] + if stored == nil { + return nil, a2aproto.ErrTaskNotFound + } + copy, err := cloneTask(stored.task) + if err != nil { + return nil, fmt.Errorf("task copy failed: %w", err) + } + return &a2ataskstore.StoredTask{Task: copy, Version: stored.version}, nil +} + +func (s *ephemeralTaskStore) List(ctx context.Context, req *a2aproto.ListTasksRequest) (*a2aproto.ListTasksResponse, error) { + if req == nil { + req = &a2aproto.ListTasksRequest{} + } + const defaultPageSize = 50 + + user, err := s.config.auth(ctx) + if user == "" || err != nil { + return nil, a2aproto.ErrUnauthenticated + } + + pageSize := req.PageSize + if pageSize == 0 { + pageSize = defaultPageSize + } else if pageSize < 1 || pageSize > 100 { + return nil, fmt.Errorf("page size must be between 1 and 100 inclusive, got %d: %w", pageSize, a2aproto.ErrInvalidRequest) + } + + s.mu.Lock() + s.pruneExpiredLocked(s.config.now()) + filtered := s.filterLocked(user, req) + s.mu.Unlock() + + sort.Slice(filtered, func(i, j int) bool { + if cmp := filtered[j].lastUpdated.Compare(filtered[i].lastUpdated); cmp != 0 { + return cmp < 0 + } + return strings.Compare(string(filtered[j].task.ID), string(filtered[i].task.ID)) < 0 + }) + + page, nextToken, err := paginateTaskRecords(filtered, pageSize, req) + if err != nil { + return nil, err + } + tasks, err := listTaskCopies(page, req) + if err != nil { + return nil, err + } + + return &a2aproto.ListTasksResponse{ + Tasks: tasks, + TotalSize: len(filtered), + PageSize: pageSize, + NextPageToken: nextToken, + }, nil +} + +func (s *ephemeralTaskStore) filterLocked(user string, req *a2aproto.ListTasksRequest) []*ephemeralTaskRecord { + filtered := make([]*ephemeralTaskRecord, 0, len(s.tasks)) + for _, stored := range s.tasks { + if stored.user != user { + continue + } + if req.ContextID != "" && stored.task.ContextID != req.ContextID { + continue + } + if req.Status != a2aproto.TaskStateUnspecified && stored.task.Status.State != req.Status { + continue + } + if req.StatusTimestampAfter != nil && stored.task.Status.Timestamp != nil && stored.task.Status.Timestamp.Before(*req.StatusTimestampAfter) { + continue + } + filtered = append(filtered, stored) + } + return filtered +} + +func (s *ephemeralTaskStore) pruneExpiredLocked(now time.Time) { + for id, stored := range s.tasks { + if now.Sub(stored.lastUpdated) >= s.config.ttl { + delete(s.tasks, id) + } + } +} + +func (s *ephemeralTaskStore) evictOverflowLocked() { + if len(s.tasks) <= s.config.maxTasks { + return + } + records := make([]*ephemeralTaskRecord, 0, len(s.tasks)) + for _, stored := range s.tasks { + records = append(records, stored) + } + sort.Slice(records, func(i, j int) bool { + if cmp := records[i].lastUpdated.Compare(records[j].lastUpdated); cmp != 0 { + return cmp < 0 + } + return strings.Compare(string(records[i].task.ID), string(records[j].task.ID)) < 0 + }) + toEvict := len(records) - s.config.maxTasks + for i := 0; i < toEvict; i++ { + delete(s.tasks, records[i].task.ID) + } +} + +func paginateTaskRecords(records []*ephemeralTaskRecord, pageSize int, req *a2aproto.ListTasksRequest) ([]*ephemeralTaskRecord, string, error) { + var page []*ephemeralTaskRecord + if req.PageToken != "" { + cursorTime, cursorTaskID, err := decodeTaskPageToken(req.PageToken) + if err != nil { + return nil, "", err + } + start := sort.Search(len(records), func(i int) bool { + record := records[i] + timeCmp := record.lastUpdated.Compare(cursorTime) + if timeCmp < 0 { + return true + } + if timeCmp > 0 { + return false + } + return strings.Compare(string(record.task.ID), string(cursorTaskID)) < 0 + }) + page = records[start:] + } else { + page = records + } + + var nextToken string + if pageSize >= len(page) { + pageSize = len(page) + } else { + last := page[pageSize-1] + nextToken = encodeTaskPageToken(last.lastUpdated, last.task.ID) + } + return page[:pageSize], nextToken, nil +} + +func listTaskCopies(records []*ephemeralTaskRecord, req *a2aproto.ListTasksRequest) ([]*a2aproto.Task, error) { + const defaultHistoryLength = 100 + out := make([]*a2aproto.Task, 0, len(records)) + for _, stored := range records { + copy, err := cloneTask(stored.task) + if err != nil { + return nil, err + } + historyLength := defaultHistoryLength + if req.HistoryLength != nil { + historyLength = *req.HistoryLength + } + if historyLength == 0 { + copy.History = []*a2aproto.Message{} + } else if historyLength > 0 && len(copy.History) > historyLength { + copy.History = copy.History[len(copy.History)-historyLength:] + } + if !req.IncludeArtifacts { + copy.Artifacts = nil + } + out = append(out, copy) + } + return out, nil +} + +func cloneTask(task *a2aproto.Task) (*a2aproto.Task, error) { + if task == nil { + return nil, nil + } + raw, err := json.Marshal(task) + if err != nil { + return nil, err + } + var copy a2aproto.Task + if err := json.Unmarshal(raw, ©); err != nil { + return nil, err + } + return ©, nil +} + +func encodeTaskPageToken(updated time.Time, taskID a2aproto.TaskID) string { + token := fmt.Sprintf("%s_%s", updated.Format(time.RFC3339Nano), taskID) + return base64.URLEncoding.EncodeToString([]byte(token)) +} + +func decodeTaskPageToken(token string) (time.Time, a2aproto.TaskID, error) { + raw, err := base64.URLEncoding.DecodeString(token) + if err != nil { + return time.Time{}, "", a2aproto.ErrParseError + } + parts := strings.Split(string(raw), "_") + if len(parts) != 2 { + return time.Time{}, "", a2aproto.ErrParseError + } + updated, err := time.Parse(time.RFC3339Nano, parts[0]) + if err != nil { + return time.Time{}, "", a2aproto.ErrParseError + } + return updated, a2aproto.TaskID(parts[1]), nil +} + +func validateStoredTask(task *a2aproto.Task) error { + if task == nil { + return nil + } + if err := validateStoredMessage(task.Status.Message); err != nil { + return err + } + for _, msg := range task.History { + if err := validateStoredMessage(msg); err != nil { + return err + } + } + for _, artifact := range task.Artifacts { + if err := validateStoredArtifact(artifact); err != nil { + return err + } + } + return validateMetadata(task.Metadata) +} + +func validateStoredArtifact(artifact *a2aproto.Artifact) error { + if artifact == nil { + return nil + } + if err := validateParts(artifact.Parts); err != nil { + return err + } + return validateMetadata(artifact.Metadata) +} + +func validateStoredMessage(msg *a2aproto.Message) error { + if msg == nil { + return nil + } + if err := validateParts(msg.Parts); err != nil { + return err + } + return validateMetadata(msg.Metadata) +} + +func validateParts(parts a2aproto.ContentParts) error { + for _, part := range parts { + if part == nil { + continue + } + if err := validateMetadata(part.Metadata); err != nil { + return err + } + } + return nil +} + +func validateMetadata(meta map[string]any) error { + return validateMetadataValue(meta, map[string]struct{}{}) +} + +func validateMetadataValue(value any, seen map[string]struct{}) error { + if value == nil { + return nil + } + + switch value.(type) { + case bool, int, int8, int16, int32, int64, float32, float64, string: + return nil + } + + key := fmt.Sprintf("%p", value) + if _, ok := seen[key]; ok { + return fmt.Errorf("circular reference in Metadata") + } + seen[key] = struct{}{} + defer delete(seen, key) + + if items, ok := value.([]any); ok { + for _, item := range items { + if err := validateMetadataValue(item, seen); err != nil { + return err + } + } + return nil + } + if fields, ok := value.(map[string]any); ok { + for _, item := range fields { + if err := validateMetadataValue(item, seen); err != nil { + return err + } + } + return nil + } + return fmt.Errorf("%T is not permitted in Metadata, must be one of nil, bool, int, float, string, []any, map[string]any", value) +} diff --git a/pkg/bridges/a2a/types.go b/pkg/bridges/a2a/types.go new file mode 100644 index 0000000..2658d56 --- /dev/null +++ b/pkg/bridges/a2a/types.go @@ -0,0 +1,269 @@ +// Package a2a exposes an agent-adaptor Runner as an A2A-compatible agent. +// +// The bridge is deliberately protocol-local: it uses the official +// github.com/a2aproject/a2a-go/v2 SDK for A2A wire behavior, but it does not +// add A2A concepts to the agent-adaptor core SDK and it never imports concrete +// provider adapters such as codex, claude, or cursor. +package a2a + +import ( + "context" + "time" + + "github.com/a2aproject/a2a-go/v2/a2asrv/push" + "github.com/a2aproject/a2a-go/v2/a2asrv/taskstore" + agentadaptor "github.com/agent-dance/agent-adaptor" +) + +type AgentCard struct { + Name string + Description string + URL string + Version string + DocumentationURL string + IconURL string + Provider *Provider + Capabilities Capabilities + DefaultInputModes []string + DefaultOutputModes []string + Skills []Skill + Interfaces []AgentInterface + SecuritySchemes []SecurityScheme + Security []SecurityRequirement +} + +type Provider struct { + Organization string + URL string +} + +type CapabilityMode uint8 + +const ( + CapabilityDefault CapabilityMode = iota + CapabilityEnabled + CapabilityDisabled +) + +type Capabilities struct { + Streaming CapabilityMode + PushNotifications bool + ExtendedAgentCard bool + Extensions []Extension +} + +type Extension struct { + URI string + Description string + Required bool + Params map[string]any +} + +type AgentInterface struct { + URL string + ProtocolBinding string + Tenant string + ProtocolVersion string +} + +type Skill struct { + ID string + Name string + Description string + Tags []string + Examples []string + InputModes []string + OutputModes []string +} + +type SecurityScheme struct { + Name string + Type SecuritySchemeType + Description string + Scheme string + BearerFormat string + In string + ParamName string +} + +type SecuritySchemeType string + +const ( + SecurityHTTP SecuritySchemeType = "http" + SecurityAPIKey SecuritySchemeType = "apiKey" + SecurityMutualTLS SecuritySchemeType = "mutualTLS" +) + +type SecurityRequirement struct { + Schemes map[string][]string +} + +const ( + DefaultEphemeralTaskLimit = 256 + DefaultEphemeralTaskTTL = time.Hour +) + +const ( + // ArtifactAssistantOutput is the bridge-owned A2A artifact name used for + // streamed assistant-facing text deltas. + ArtifactAssistantOutput = "assistant-output" + // ArtifactAgentAdaptorResult is the bridge-owned A2A artifact name used + // for the terminal structured SDK result summary and opt-in diagnostics. + ArtifactAgentAdaptorResult = "agent-adaptor-result" +) + +type EphemeralTaskStoreOptions struct { + MaxTasks int + TTL time.Duration +} + +type TaskLifecycleOptions struct { + Store taskstore.Store + Ephemeral *EphemeralTaskStoreOptions +} + +type PushNotificationSupport struct { + Store push.ConfigStore + Sender push.Sender +} + +type ExtendedAgentCardRequest struct { + Tenant string +} + +type ExtendedAgentCardProvider interface { + ExtendedCard(ctx context.Context, req ExtendedAgentCardRequest) (AgentCard, error) +} + +type ExtendedAgentCardProviderFunc func(context.Context, ExtendedAgentCardRequest) (AgentCard, error) + +func (fn ExtendedAgentCardProviderFunc) ExtendedCard(ctx context.Context, req ExtendedAgentCardRequest) (AgentCard, error) { + return fn(ctx, req) +} + +type ExtendedAgentCardSupport struct { + Static *AgentCard + Provider ExtendedAgentCardProvider +} + +type ServerOptions struct { + AgentCard AgentCard + Session SessionMapper + Prompt PromptBuilder + + RunOptions []agentadaptor.RunOption + TaskLifecycle TaskLifecycleOptions + PushNotifications *PushNotificationSupport + ExtendedAgentCard *ExtendedAgentCardSupport + Exposure ExposurePolicy +} + +// ExposurePolicy controls which non-user-facing bridge artifacts are exposed +// to remote A2A callers. +// +// The zero value is intentionally conservative: +// - assistant-facing Output still flows through the task status message +// - terminal summary still flows through the agent-adaptor-result artifact +// - reasoning, tool-call internals, HITL events, and diagnostics stay hidden +type ExposurePolicy struct { + IncludeReasoning bool + IncludeToolCalls bool + IncludeHITL bool + + Diagnostics DiagnosticsPolicy +} + +// DiagnosticsPolicy controls opt-in exposure of internal execution details. +// +// All enabled fields are sanitized before they leave the bridge. +type DiagnosticsPolicy struct { + IncludeMetadata bool + IncludeUsage bool + IncludeProviderResult bool + IncludeTranscript bool + IncludeRawStreams bool + IncludeHITLPayloads bool + IncludeHITLRaw bool +} + +type InboundRequest struct { + TaskID string + ContextID string + Message Message + Metadata map[string]any +} + +type Message struct { + ID string + Role string + TaskID string + ContextID string + Parts []Part + ReferenceTasks []string + Extensions []string + Metadata map[string]any +} + +type Part struct { + Kind PartKind + Text string + Raw []byte + Data any + URL string + MediaType string + Filename string + Metadata map[string]any +} + +type PartKind string + +const ( + PartText PartKind = "text" + PartRaw PartKind = "raw" + PartData PartKind = "data" + PartURL PartKind = "url" +) + +type SessionMapper interface { + RunOptions(ctx context.Context, req InboundRequest) ([]agentadaptor.RunOption, error) +} + +type PromptBuilder interface { + BuildPrompt(ctx context.Context, req InboundRequest) (prompt string, opts []agentadaptor.RunOption, err error) +} + +type SessionMapperFunc func(context.Context, InboundRequest) ([]agentadaptor.RunOption, error) + +func (fn SessionMapperFunc) RunOptions(ctx context.Context, req InboundRequest) ([]agentadaptor.RunOption, error) { + return fn(ctx, req) +} + +type PromptBuilderFunc func(context.Context, InboundRequest) (string, []agentadaptor.RunOption, error) + +func (fn PromptBuilderFunc) BuildPrompt(ctx context.Context, req InboundRequest) (string, []agentadaptor.RunOption, error) { + return fn(ctx, req) +} + +func Stateless() SessionMapper { + return SessionMapperFunc(func(context.Context, InboundRequest) ([]agentadaptor.RunOption, error) { + return nil, nil + }) +} + +func SessionByContextID(namespace string) SessionMapper { + return SessionMapperFunc(func(_ context.Context, req InboundRequest) ([]agentadaptor.RunOption, error) { + if req.ContextID == "" { + return nil, nil + } + return []agentadaptor.RunOption{agentadaptor.WithSessionKey(namespace, req.ContextID)}, nil + }) +} + +func SessionByTaskID(namespace string) SessionMapper { + return SessionMapperFunc(func(_ context.Context, req InboundRequest) ([]agentadaptor.RunOption, error) { + if req.TaskID == "" { + return nil, nil + } + return []agentadaptor.RunOption{agentadaptor.WithSessionKey(namespace, req.TaskID)}, nil + }) +} diff --git a/pkg/clients/a2a/auth.go b/pkg/clients/a2a/auth.go new file mode 100644 index 0000000..8571b63 --- /dev/null +++ b/pkg/clients/a2a/auth.go @@ -0,0 +1,181 @@ +package a2a + +import ( + "errors" + "net" + "net/http" + "net/url" + "os" + "strconv" + "strings" +) + +// Auth attaches client-owned credentials to discovery and protocol requests. +type Auth interface { + Wrap(base http.RoundTripper) http.RoundTripper + Headers() map[string]string +} + +type bearerToken string + +func BearerToken(token string) Auth { + return bearerToken(token) +} + +func BearerTokenFromEnv(name string) Auth { + return bearerToken(os.Getenv(name)) +} + +func (b bearerToken) Wrap(base http.RoundTripper) http.RoundTripper { + if base == nil { + base = http.DefaultTransport + } + return roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if b != "" { + req = req.Clone(req.Context()) + req.Header.Set("Authorization", "Bearer "+string(b)) + } + return base.RoundTrip(req) + }) +} + +func (b bearerToken) Headers() map[string]string { + if b == "" { + return nil + } + return map[string]string{"Authorization": "Bearer " + string(b)} +} + +type roundTripperFunc func(*http.Request) (*http.Response, error) + +func (fn roundTripperFunc) RoundTrip(req *http.Request) (*http.Response, error) { + return fn(req) +} + +type trustedOriginSet map[string]struct{} + +func newTrustedOriginSet(agentCardURL string, extra []string) (trustedOriginSet, error) { + baseOrigin, err := canonicalOrigin(agentCardURL) + if err != nil { + return nil, err + } + out := trustedOriginSet{baseOrigin: {}} + for _, raw := range extra { + origin, err := canonicalOrigin(raw) + if err != nil { + return nil, err + } + out[origin] = struct{}{} + } + return out, nil +} + +func (s trustedOriginSet) AllowsURL(u *url.URL) bool { + if len(s) == 0 || u == nil { + return false + } + origin, err := canonicalOrigin(u.String()) + if err != nil { + return false + } + _, ok := s[origin] + return ok +} + +func (s trustedOriginSet) Allows(raw string) bool { + origin, err := canonicalOrigin(raw) + if err != nil { + return false + } + _, ok := s[origin] + return ok +} + +func canonicalOrigin(raw string) (string, error) { + u, err := url.Parse(raw) + if err != nil { + return "", err + } + if u.Scheme == "" || u.Host == "" { + return "", errors.New("origin must be absolute") + } + scheme := strings.ToLower(u.Scheme) + host := strings.ToLower(u.Hostname()) + if host == "" { + return "", errors.New("origin host is required") + } + port := u.Port() + if port == "" { + port = defaultPort(scheme) + } + if port != "" { + return scheme + "://" + net.JoinHostPort(host, port), nil + } + return scheme + "://" + host, nil +} + +func defaultPort(scheme string) string { + switch scheme { + case "http": + return strconv.Itoa(80) + case "https": + return strconv.Itoa(443) + default: + return "" + } +} + +func cloneHTTPClient(base *http.Client) *http.Client { + if base == nil { + base = http.DefaultClient + } + copy := *base + return © +} + +func httpClientWithAuth(base *http.Client, auth Auth, trusted trustedOriginSet) *http.Client { + copy := cloneHTTPClient(base) + transport := copy.Transport + if transport == nil { + transport = http.DefaultTransport + } + transport = stripAuthOnUntrustedTransport(transport, trusted) + if auth != nil { + transport = auth.Wrap(transport) + } + copy.Transport = transport + copy.CheckRedirect = secureRedirectPolicy(copy.CheckRedirect, trusted) + return copy +} + +func secureRedirectPolicy(next func(*http.Request, []*http.Request) error, trusted trustedOriginSet) func(*http.Request, []*http.Request) error { + return func(req *http.Request, via []*http.Request) error { + if len(via) >= 10 { + return errors.New("stopped after 10 redirects") + } + if !trusted.AllowsURL(req.URL) { + req.Header.Del("Authorization") + req.Header.Del("Proxy-Authorization") + } + if next != nil { + return next(req, via) + } + return nil + } +} + +func stripAuthOnUntrustedTransport(base http.RoundTripper, trusted trustedOriginSet) http.RoundTripper { + return roundTripperFunc(func(req *http.Request) (*http.Response, error) { + if trusted.AllowsURL(req.URL) { + return base.RoundTrip(req) + } + if req.Header.Get("Authorization") == "" && req.Header.Get("Proxy-Authorization") == "" { + return base.RoundTrip(req) + } + req = req.Clone(req.Context()) + req.Header = req.Header.Clone() + req.Header.Del("Authorization") + req.Header.Del("Proxy-Authorization") + return base.RoundTrip(req) + }) +} diff --git a/pkg/clients/a2a/client.go b/pkg/clients/a2a/client.go new file mode 100644 index 0000000..4df5d1a --- /dev/null +++ b/pkg/clients/a2a/client.go @@ -0,0 +1,445 @@ +package a2a + +import ( + "context" + "errors" + "fmt" + "io" + "net/http" + "net/url" + "sync" + + a2aproto "github.com/a2aproject/a2a-go/v2/a2a" + upclient "github.com/a2aproject/a2a-go/v2/a2aclient" + "github.com/a2aproject/a2a-go/v2/a2aclient/agentcard" +) + +type Options struct { + AgentCardURL string + AgentCardPath string + Auth Auth + HTTPClient *http.Client + TrustedAuthOrigins []string + AcceptedOutputModes []string + PreferredTransports []TransportProtocol +} + +type Client struct { + opts Options + httpClient *http.Client + + mu sync.Mutex + card *a2aproto.AgentCard + publicCard AgentCard + upstream *upclient.Client +} + +func New(opts Options) *Client { + return &Client{opts: opts, httpClient: cloneHTTPClient(opts.HTTPClient)} +} + +func (c *Client) AgentCard(ctx context.Context) (AgentCard, error) { + if err := c.ensure(ctx); err != nil { + return AgentCard{}, err + } + c.mu.Lock() + defer c.mu.Unlock() + return c.publicCard, nil +} + +func (c *Client) Send(ctx context.Context, req SendRequest) (Task, error) { + up, err := c.ensureClient(ctx) + if err != nil { + return Task{}, err + } + result, err := up.SendMessage(ctx, upstreamSendRequest(req)) + if err != nil { + return Task{}, classifyError("SendMessage", err) + } + switch v := result.(type) { + case *a2aproto.Task: + return convertTask(v), nil + case *a2aproto.Message: + msg := convertMessage(v) + return Task{ + ID: msg.TaskID, ContextID: msg.ContextID, + Status: TaskStatus{State: TaskStateCompleted, Message: &msg}, + Messages: []Message{msg}, + Raw: map[string]any{"message": msg.Raw}, + }, nil + default: + return Task{}, &ProtocolError{Op: "SendMessage", Reason: fmt.Sprintf("unexpected result %T", result), Cause: ErrProtocol} + } +} + +func (c *Client) SendStream(ctx context.Context, req SendRequest) (*Stream, error) { + up, err := c.ensureClient(ctx) + if err != nil { + return nil, err + } + taskID := req.TaskID + streamCtx, cancel := context.WithCancel(ctx) + seq := up.SendStreamingMessage(streamCtx, upstreamSendRequest(req)) + return c.startStream(streamCtx, cancel, taskID, seq), nil +} + +func (c *Client) Subscribe(ctx context.Context, req SubscribeRequest) (*Stream, error) { + if req.TaskID == "" { + return nil, fmt.Errorf("%w: task id is required", ErrProtocol) + } + if req.Since != "" { + return nil, fmt.Errorf("%w: SubscribeRequest.Since is not supported by A2A 1.0 SubscribeToTask", ErrUnsupported) + } + up, err := c.ensureClient(ctx) + if err != nil { + return nil, err + } + streamCtx, cancel := context.WithCancel(ctx) + streamReq := &a2aproto.SubscribeToTaskRequest{ID: a2aproto.TaskID(req.TaskID), Tenant: req.Tenant} + seq := up.SubscribeToTask(streamCtx, streamReq) + return c.startStream(streamCtx, cancel, req.TaskID, seq), nil +} + +func (c *Client) GetTask(ctx context.Context, req GetTaskRequest) (Task, error) { + if req.TaskID == "" { + return Task{}, fmt.Errorf("%w: task id is required", ErrProtocol) + } + up, err := c.ensureClient(ctx) + if err != nil { + return Task{}, err + } + task, err := up.GetTask(ctx, &a2aproto.GetTaskRequest{ + ID: a2aproto.TaskID(req.TaskID), + Tenant: req.Tenant, + HistoryLength: req.HistoryLength, + }) + if err != nil { + return Task{}, classifyError("GetTask", err) + } + return convertTask(task), nil +} + +func (c *Client) CancelTask(ctx context.Context, req CancelTaskRequest) (Task, error) { + if req.TaskID == "" { + return Task{}, fmt.Errorf("%w: task id is required", ErrProtocol) + } + up, err := c.ensureClient(ctx) + if err != nil { + return Task{}, err + } + task, err := up.CancelTask(ctx, &a2aproto.CancelTaskRequest{ + ID: a2aproto.TaskID(req.TaskID), + Tenant: req.Tenant, + Metadata: cloneMap(req.Metadata), + }) + if err != nil { + return Task{}, classifyError("CancelTask", err) + } + return convertTask(task), nil +} + +func (c *Client) Close() error { + c.mu.Lock() + defer c.mu.Unlock() + if c.upstream == nil { + return nil + } + return c.upstream.Destroy() +} + +func (c *Client) ensureClient(ctx context.Context) (*upclient.Client, error) { + if err := c.ensure(ctx); err != nil { + return nil, err + } + c.mu.Lock() + defer c.mu.Unlock() + return c.upstream, nil +} + +func (c *Client) ensure(ctx context.Context) error { + c.mu.Lock() + if c.upstream != nil { + c.mu.Unlock() + return nil + } + c.mu.Unlock() + + baseURL, path, err := splitAgentCardURL(c.opts.AgentCardURL, c.opts.AgentCardPath) + if err != nil { + return err + } + trustedOrigins, err := newTrustedOriginSet(baseURL, c.opts.TrustedAuthOrigins) + if err != nil { + return fmt.Errorf("%w: invalid trusted auth origin: %v", ErrProtocol, err) + } + httpClient := httpClientWithAuth(c.httpClient, c.opts.Auth, trustedOrigins) + card, public, err := c.fetchCard(ctx, baseURL, path, httpClient) + if err != nil { + return err + } + if err := validateAuthInterfaceOrigins(card, c.opts.Auth, trustedOrigins); err != nil { + return err + } + cfg := upclient.Config{AcceptedOutputModes: append([]string(nil), c.opts.AcceptedOutputModes...)} + for _, tr := range c.opts.PreferredTransports { + cfg.PreferredTransports = append(cfg.PreferredTransports, a2aproto.TransportProtocol(tr)) + } + up, err := upclient.NewFromCard(ctx, card, upclient.WithJSONRPCTransport(httpClient), upclient.WithRESTTransport(httpClient), upclient.WithConfig(cfg)) + if err != nil { + return classifyError("NewFromCard", err) + } + + c.mu.Lock() + defer c.mu.Unlock() + if c.upstream != nil { + _ = up.Destroy() + return nil + } + c.card = card + c.publicCard = public + c.upstream = up + return nil +} + +func (c *Client) fetchCard(ctx context.Context, base, path string, httpClient *http.Client) (*a2aproto.AgentCard, AgentCard, error) { + resolver := agentcard.NewResolver(httpClient) + card, err := resolver.Resolve(ctx, base, agentCardResolveOptions(path)...) + if err != nil { + return nil, AgentCard{}, classifyError("AgentCard", err) + } + if err := validateAgentCard(card); err != nil { + return nil, AgentCard{}, err + } + public, err := convertAgentCard(card) + if err != nil { + return nil, AgentCard{}, err + } + return card, public, nil +} + +func agentCardResolveOptions(path string) []agentcard.ResolveOption { + if path == "" { + return nil + } + return []agentcard.ResolveOption{agentcard.WithPath(path)} +} + +func upstreamSendRequest(req SendRequest) *a2aproto.SendMessageRequest { + msg := upstreamMessage(req.Message) + if req.ContextID != "" { + msg.ContextID = req.ContextID + } + if req.TaskID != "" { + msg.TaskID = a2aproto.TaskID(req.TaskID) + } + return &a2aproto.SendMessageRequest{ + Tenant: req.Tenant, + Message: msg, + Metadata: cloneMap(req.Metadata), + Config: &a2aproto.SendMessageConfig{ + AcceptedOutputModes: append([]string(nil), req.AcceptedOutputModes...), + ReturnImmediately: req.ReturnImmediately, + HistoryLength: req.HistoryLength, + }, + } +} + +func splitAgentCardURL(raw, overridePath string) (base, path string, err error) { + if raw == "" { + return "", "", fmt.Errorf("%w: AgentCardURL is required", ErrInvalidAgentCard) + } + u, err := url.Parse(raw) + if err != nil { + return "", "", fmt.Errorf("%w: invalid AgentCardURL: %v", ErrInvalidAgentCard, err) + } + if u.Scheme == "" || u.Host == "" { + return "", "", fmt.Errorf("%w: AgentCardURL must be absolute", ErrInvalidAgentCard) + } + path = overridePath + if path == "" && u.Path != "" && u.Path != "/" { + path = u.Path + } + u.Path, u.RawPath, u.RawQuery, u.Fragment = "", "", "", "" + return u.String(), path, nil +} + +func classifyError(op string, err error) error { + if err == nil { + return nil + } + raw := protocolErrorRaw(err) + switch { + case errors.Is(err, a2aproto.ErrTaskNotFound): + return &ProtocolError{Op: op, Reason: err.Error(), Cause: ErrNotFound, Raw: raw} + case errors.Is(err, a2aproto.ErrUnauthenticated), errors.Is(err, a2aproto.ErrUnauthorized): + return &ProtocolError{Op: op, Reason: err.Error(), Cause: ErrUnauthorized, Raw: raw} + case errors.Is(err, a2aproto.ErrUnsupportedOperation), errors.Is(err, a2aproto.ErrUnsupportedContentType): + return &ProtocolError{Op: op, Reason: err.Error(), Cause: ErrUnsupported, Raw: raw} + default: + return &ProtocolError{Op: op, Reason: err.Error(), Cause: err, Raw: raw} + } +} + +func protocolErrorRaw(err error) map[string]any { + var a2aErr *a2aproto.Error + if !errors.As(err, &a2aErr) { + return nil + } + raw := map[string]any{} + if a2aErr.Message != "" { + raw["message"] = a2aErr.Message + } + if a2aErr.Err != nil { + raw["cause"] = a2aErr.Err.Error() + raw["reason"] = a2aproto.ErrorReason(a2aErr.Err) + } + if len(a2aErr.Details) > 0 { + raw["details"] = cloneMap(a2aErr.Details) + } + if len(a2aErr.TypedDetails) > 0 { + typed := make([]map[string]any, 0, len(a2aErr.TypedDetails)) + for _, detail := range a2aErr.TypedDetails { + if detail == nil { + continue + } + value := map[string]any{} + for k, v := range detail.Value { + value[k] = v + } + value["@type"] = detail.TypeURL + typed = append(typed, value) + } + if len(typed) > 0 { + raw["typed_details"] = typed + } + } + if len(raw) == 0 { + return nil + } + return raw +} + +func validateAuthInterfaceOrigins(card *a2aproto.AgentCard, auth Auth, trusted trustedOriginSet) error { + if auth == nil { + return nil + } + for _, iface := range card.SupportedInterfaces { + if iface == nil { + continue + } + origin, err := canonicalOrigin(iface.URL) + if err != nil { + return fmt.Errorf("%w: interface URL %q cannot be authorized: %v", ErrUntrustedOrigin, iface.URL, err) + } + if trusted.Allows(origin) { + continue + } + return fmt.Errorf("%w: refusing to send credentials to interface origin %q without explicit opt-in via Options.TrustedAuthOrigins", ErrUntrustedOrigin, origin) + } + return nil +} + +type Stream struct { + cancel context.CancelFunc + events <-chan streamItem +} + +type streamItem struct { + event Event + err error +} + +func (s *Stream) Recv() (Event, error) { + item, ok := <-s.events + if !ok { + return Event{}, io.EOF + } + return item.event, item.err +} + +func (s *Stream) Close() error { + if s.cancel != nil { + s.cancel() + } + for range s.events { + } + return nil +} + +func (c *Client) startStream(streamCtx context.Context, cancel context.CancelFunc, taskID string, seq func(func(a2aproto.Event, error) bool)) *Stream { + out := make(chan streamItem, 32) + go func() { + defer close(out) + lastTaskID := taskID + state := streamState{} + seq(func(ev a2aproto.Event, err error) bool { + if err != nil { + if state.canRecover() { + if recovered, ok := c.tryRecover(streamCtx, lastTaskID); ok { + out <- streamItem{event: recovered} + return false + } + } + if state.shouldIgnoreLateEvent() { + return false + } + out <- streamItem{err: &StreamRecoveryError{TaskID: lastTaskID, Cause: err}} + return false + } + event, convErr := eventFromUpstream(ev) + if convErr != nil { + out <- streamItem{err: convErr} + return false + } + if event.TaskID != "" { + lastTaskID = event.TaskID + } + emit, keepReading := state.accept(event) + if !emit { + return keepReading + } + select { + case <-streamCtx.Done(): + out <- streamItem{err: streamCtx.Err()} + return false + case out <- streamItem{event: event}: + return keepReading + } + }) + }() + return &Stream{cancel: cancel, events: out} +} + +func (c *Client) tryRecover(ctx context.Context, taskID string) (Event, bool) { + if taskID == "" { + return Event{}, false + } + task, err := c.GetTask(ctx, GetTaskRequest{TaskID: taskID}) + if err != nil || !executionFinalTask(task) { + return Event{}, false + } + return Event{Kind: EventTerminal, Task: &task, TaskID: task.ID, ContextID: task.ContextID, RecoveredState: true, Raw: task.Raw}, true +} + +type streamState struct { + finalSeen bool +} + +func (s *streamState) accept(event Event) (emit bool, keepReading bool) { + if s.finalSeen { + return false, false + } + if executionFinalEvent(event) { + s.finalSeen = true + return true, false + } + return true, true +} + +func (s *streamState) canRecover() bool { + return !s.finalSeen +} + +func (s *streamState) shouldIgnoreLateEvent() bool { + return s.finalSeen +} diff --git a/pkg/clients/a2a/client_test.go b/pkg/clients/a2a/client_test.go new file mode 100644 index 0000000..785aac0 --- /dev/null +++ b/pkg/clients/a2a/client_test.go @@ -0,0 +1,886 @@ +package a2a + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "fmt" + "io" + "net/http" + "net/http/httptest" + "testing" + "time" +) + +func TestAgentCardFetchValidateAndCache(t *testing.T) { + t.Parallel() + + var hits int + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + hits++ + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Reviewer", + "description":"reviews code", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{"streaming":true}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"review","name":"Review","description":"review code","tags":["code"]}] + }`, srv.URL+"/a2a") + case "/a2a": + t.Fatalf("unexpected protocol request while fetching card") + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := New(Options{AgentCardURL: srv.URL}) + first, err := client.AgentCard(context.Background()) + if err != nil { + t.Fatalf("AgentCard() error = %v", err) + } + second, err := client.AgentCard(context.Background()) + if err != nil { + t.Fatalf("AgentCard() second error = %v", err) + } + if hits != 1 { + t.Fatalf("card fetch count = %d, want 1", hits) + } + if first.Name != "Remote Reviewer" || second.Fingerprint == "" { + t.Fatalf("unexpected card: first=%+v second=%+v", first, second) + } + if len(first.SupportedInterfaces) != 1 || first.SupportedInterfaces[0].URL != srv.URL+"/a2a" { + t.Fatalf("supported interfaces lost: %+v", first.SupportedInterfaces) + } +} + +func TestSendGetCancelAndStreamPreserveStructuredTask(t *testing.T) { + t.Parallel() + + var methods []string + var gotGetParams map[string]any + var gotCancelParams map[string]any + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{"streaming":true}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, srv.URL+"/a2a") + case "/a2a": + method, params := readRPCRequest(t, r) + methods = append(methods, method) + switch method { + case "SendMessage": + writeRPCResult(t, w, `{"task":`+taskJSON("TASK_STATE_COMPLETED")+`}`) + case "GetTask": + gotGetParams = params + writeRPCResult(t, w, taskJSON("TASK_STATE_WORKING")) + case "CancelTask": + gotCancelParams = params + writeRPCResult(t, w, taskJSON("TASK_STATE_CANCELED")) + case "SendStreamingMessage": + writeSSE(t, w, + rpcResult(`{"statusUpdate":{"taskId":"task-1","contextId":"ctx-1","status":{"state":"TASK_STATE_WORKING"}}}`), + rpcResult(`{"artifactUpdate":{"taskId":"task-1","contextId":"ctx-1","artifact":{"artifactId":"artifact-1","name":"report","parts":[{"text":"chunk"}]},"lastChunk":true}}`), + rpcResult(`{"task":`+taskJSON("TASK_STATE_COMPLETED")+`}`), + ) + default: + t.Fatalf("unexpected method %q", method) + } + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := New(Options{AgentCardURL: srv.URL}) + ctx := context.Background() + task, err := client.Send(ctx, SendRequest{Message: UserText("review this")}) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if task.ID != "task-1" || task.ContextID != "ctx-1" || task.Status.State != TaskStateCompleted { + t.Fatalf("Send() task = %+v", task) + } + if len(task.Artifacts) != 1 || task.Artifacts[0].ID != "artifact-1" { + t.Fatalf("Send() artifacts = %+v", task.Artifacts) + } + + historyLength := 3 + got, err := client.GetTask(ctx, GetTaskRequest{TaskID: "task-1", Tenant: "tenant-a", HistoryLength: &historyLength}) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if got.Status.State != TaskStateWorking { + t.Fatalf("GetTask() state = %q", got.Status.State) + } + if gotGetParams["id"] != "task-1" || gotGetParams["tenant"] != "tenant-a" || gotGetParams["historyLength"] != float64(3) { + t.Fatalf("GetTask params = %+v", gotGetParams) + } + + cancelled, err := client.CancelTask(ctx, CancelTaskRequest{ + TaskID: "task-1", + Tenant: "tenant-a", + Metadata: map[string]any{ + "reason": "parent_cancelled", + }, + }) + if err != nil { + t.Fatalf("CancelTask() error = %v", err) + } + if cancelled.Status.State != TaskStateCanceled { + t.Fatalf("CancelTask() state = %q", cancelled.Status.State) + } + cancelMetadata, _ := gotCancelParams["metadata"].(map[string]any) + if gotCancelParams["id"] != "task-1" || gotCancelParams["tenant"] != "tenant-a" || cancelMetadata["reason"] != "parent_cancelled" { + t.Fatalf("CancelTask params = %+v", gotCancelParams) + } + + stream, err := client.SendStream(ctx, SendRequest{Message: UserText("stream")}) + if err != nil { + t.Fatalf("SendStream() error = %v", err) + } + defer stream.Close() + var events []Event + for { + ev, err := stream.Recv() + if errors.Is(err, io.EOF) { + break + } + if err != nil { + t.Fatalf("Recv() error = %v", err) + } + events = append(events, ev) + } + if gotKinds := []EventKind{events[0].Kind, events[1].Kind, events[2].Kind}; fmt.Sprint(gotKinds) != "[status artifact terminal]" { + t.Fatalf("stream kinds = %v", gotKinds) + } + if events[1].Artifact == nil || events[1].Artifact.ID != "artifact-1" { + t.Fatalf("artifact event = %+v", events[1]) + } + if len(methods) < 4 { + t.Fatalf("expected protocol methods, got %v", methods) + } +} + +func TestSendReturnImmediatelySupportsGetTaskPollingFallback(t *testing.T) { + t.Parallel() + + var sawReturnImmediately bool + var sawGetTenant bool + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{ + "name":"Legacy Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{"streaming":false}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, srv.URL+"/a2a") + case "/a2a": + method, params := readRPCRequest(t, r) + switch method { + case "SendMessage": + config, _ := params["configuration"].(map[string]any) + sawReturnImmediately = config["returnImmediately"] == true + writeRPCResult(t, w, `{"task":`+taskJSON("TASK_STATE_WORKING")+`}`) + case "GetTask": + sawGetTenant = params["tenant"] == "tenant-a" + writeRPCResult(t, w, taskJSON("TASK_STATE_COMPLETED")) + default: + t.Fatalf("unexpected method %q", method) + } + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := New(Options{AgentCardURL: srv.URL}) + started, err := client.Send(context.Background(), SendRequest{ + Message: UserText("poll me"), + Tenant: "tenant-a", + ReturnImmediately: true, + }) + if err != nil { + t.Fatalf("Send() error = %v", err) + } + if started.Status.State != TaskStateWorking { + t.Fatalf("Send() state = %q", started.Status.State) + } + if !sawReturnImmediately { + t.Fatalf("SendMessage params did not include returnImmediately=true") + } + + completed, err := client.GetTask(context.Background(), GetTaskRequest{TaskID: started.ID, Tenant: "tenant-a"}) + if err != nil { + t.Fatalf("GetTask() error = %v", err) + } + if completed.Status.State != TaskStateCompleted { + t.Fatalf("GetTask() state = %q", completed.Status.State) + } + if !sawGetTenant { + t.Fatalf("GetTask params did not preserve tenant") + } +} + +func TestClientClassifiesProtocolErrors(t *testing.T) { + t.Parallel() + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, srv.URL+"/a2a") + case "/a2a": + _ = readRPCMethod(t, r) + w.Header().Set("Content-Type", "application/json") + _, _ = w.Write([]byte(`{"jsonrpc":"2.0","id":"1","error":{"code":-32001,"message":"missing task","data":[{"@type":"type.googleapis.com/google.protobuf.Struct","reason":"expired","scope":"task.read"}]}}`)) + } + })) + defer srv.Close() + + client := New(Options{AgentCardURL: srv.URL}) + _, err := client.GetTask(context.Background(), GetTaskRequest{TaskID: "missing"}) + if err == nil { + t.Fatal("GetTask() error = nil") + } + if !errors.Is(err, ErrNotFound) { + t.Fatalf("errors.Is(err, ErrNotFound) = false; err=%v", err) + } + var protoErr *ProtocolError + if !errors.As(err, &protoErr) { + t.Fatalf("errors.As(%T, *ProtocolError) = false", err) + } + details, ok := protoErr.Raw["details"].(map[string]any) + if !ok { + t.Fatalf("ProtocolError.Raw details missing: %+v", protoErr.Raw) + } + if details["reason"] != "expired" || details["scope"] != "task.read" { + t.Fatalf("ProtocolError.Raw details = %+v", details) + } + typed, ok := protoErr.Raw["typed_details"].([]map[string]any) + if !ok || len(typed) != 1 || typed[0]["@type"] != "type.googleapis.com/google.protobuf.Struct" { + t.Fatalf("ProtocolError.Raw typed_details = %+v", protoErr.Raw["typed_details"]) + } +} + +func TestStreamCloseCancelsUpstreamRequest(t *testing.T) { + t.Parallel() + + requestStarted := make(chan struct{}) + requestDone := make(chan struct{}) + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{"streaming":true}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, srv.URL+"/a2a") + case "/a2a": + if method := readRPCMethod(t, r); method != "SendStreamingMessage" { + t.Fatalf("method = %q, want SendStreamingMessage", method) + } + w.Header().Set("Content-Type", "text/event-stream") + if flusher, ok := w.(http.Flusher); ok { + flusher.Flush() + } + close(requestStarted) + <-r.Context().Done() + close(requestDone) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := New(Options{AgentCardURL: srv.URL}) + stream, err := client.SendStream(context.Background(), SendRequest{Message: UserText("stream")}) + if err != nil { + t.Fatalf("SendStream() error = %v", err) + } + select { + case <-requestStarted: + case <-time.After(2 * time.Second): + t.Fatal("stream request did not start") + } + if err := stream.Close(); err != nil { + t.Fatalf("Close() error = %v", err) + } + + select { + case <-requestDone: + case <-time.After(2 * time.Second): + t.Fatal("stream Close did not cancel upstream request") + } +} + +func TestSendStreamTreatsMessageAsExecutionFinal(t *testing.T) { + t.Parallel() + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{"streaming":true}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, srv.URL+"/a2a") + case "/a2a": + if method := readRPCMethod(t, r); method != "SendStreamingMessage" { + t.Fatalf("method = %q, want SendStreamingMessage", method) + } + writeSSE(t, w, + rpcResult(`{"message":`+messageJSON("final answer")+`}`), + rpcResult(statusUpdateJSON("TASK_STATE_WORKING")), + rpcResult(`{"task":`+taskJSON("TASK_STATE_COMPLETED")+`}`), + ) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := New(Options{AgentCardURL: srv.URL}) + stream, err := client.SendStream(context.Background(), SendRequest{Message: UserText("stream")}) + if err != nil { + t.Fatalf("SendStream() error = %v", err) + } + defer stream.Close() + + events := collectStreamEvents(t, stream) + if len(events) != 1 { + t.Fatalf("event count = %d, want 1", len(events)) + } + if events[0].Kind != EventTerminal || events[0].Message == nil || events[0].Message.ID != "msg-agent" { + t.Fatalf("message final event = %+v", events[0]) + } +} + +func TestSendStreamTreatsInputRequiredAsExecutionFinal(t *testing.T) { + t.Parallel() + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{"streaming":true}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, srv.URL+"/a2a") + case "/a2a": + if method := readRPCMethod(t, r); method != "SendStreamingMessage" { + t.Fatalf("method = %q, want SendStreamingMessage", method) + } + writeSSE(t, w, + rpcResult(statusUpdateJSON("TASK_STATE_WORKING")), + rpcResult(statusUpdateJSON("TASK_STATE_INPUT_REQUIRED")), + rpcResult(`{"task":`+taskJSON("TASK_STATE_COMPLETED")+`}`), + ) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := New(Options{AgentCardURL: srv.URL}) + stream, err := client.SendStream(context.Background(), SendRequest{Message: UserText("stream")}) + if err != nil { + t.Fatalf("SendStream() error = %v", err) + } + defer stream.Close() + + events := collectStreamEvents(t, stream) + if len(events) != 2 { + t.Fatalf("event count = %d, want 2", len(events)) + } + if gotKinds := []EventKind{events[0].Kind, events[1].Kind}; fmt.Sprint(gotKinds) != "[status terminal]" { + t.Fatalf("stream kinds = %v", gotKinds) + } + if events[1].Status == nil || events[1].Status.State != TaskStateInputRequired { + t.Fatalf("input-required final event = %+v", events[1]) + } +} + +func TestSendStreamIgnoresLateTerminalAfterFirstFinal(t *testing.T) { + t.Parallel() + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{"streaming":true}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, srv.URL+"/a2a") + case "/a2a": + if method := readRPCMethod(t, r); method != "SendStreamingMessage" { + t.Fatalf("method = %q, want SendStreamingMessage", method) + } + writeSSE(t, w, + rpcResult(`{"task":`+taskJSON("TASK_STATE_COMPLETED")+`}`), + rpcResult(statusUpdateJSON("TASK_STATE_FAILED")), + ) + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := New(Options{AgentCardURL: srv.URL}) + stream, err := client.SendStream(context.Background(), SendRequest{Message: UserText("stream")}) + if err != nil { + t.Fatalf("SendStream() error = %v", err) + } + defer stream.Close() + + events := collectStreamEvents(t, stream) + if len(events) != 1 { + t.Fatalf("event count = %d, want 1", len(events)) + } + if events[0].Kind != EventTerminal || events[0].Task == nil || events[0].Task.Status.State != TaskStateCompleted { + t.Fatalf("terminal event = %+v", events[0]) + } +} + +func TestSendStreamRecoversExecutionFinalTask(t *testing.T) { + t.Parallel() + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{"streaming":true}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, srv.URL+"/a2a") + case "/a2a": + switch method := readRPCMethod(t, r); method { + case "SendStreamingMessage": + writeBrokenSSE(t, w, + rpcResult(statusUpdateJSON("TASK_STATE_WORKING")), + `{"jsonrpc":"2.0","id":"1","result":`, + ) + case "GetTask": + writeRPCResult(t, w, taskJSON("TASK_STATE_INPUT_REQUIRED")) + default: + t.Fatalf("unexpected method %q", method) + } + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := New(Options{AgentCardURL: srv.URL}) + stream, err := client.SendStream(context.Background(), SendRequest{Message: UserText("stream")}) + if err != nil { + t.Fatalf("SendStream() error = %v", err) + } + defer stream.Close() + + events := collectStreamEvents(t, stream) + if len(events) != 2 { + t.Fatalf("event count = %d, want 2", len(events)) + } + if events[1].Kind != EventTerminal || events[1].Task == nil || !events[1].RecoveredState { + t.Fatalf("recovered terminal event = %+v", events[1]) + } + if events[1].Task.Status.State != TaskStateInputRequired { + t.Fatalf("recovered state = %q, want %q", events[1].Task.Status.State, TaskStateInputRequired) + } +} + +func TestSendStreamRecoveryFailsForNonFinalTask(t *testing.T) { + t.Parallel() + + var srv *httptest.Server + srv = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{"streaming":true}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, srv.URL+"/a2a") + case "/a2a": + switch method := readRPCMethod(t, r); method { + case "SendStreamingMessage": + writeBrokenSSE(t, w, + rpcResult(statusUpdateJSON("TASK_STATE_WORKING")), + `{"jsonrpc":"2.0","id":"1","result":`, + ) + case "GetTask": + writeRPCResult(t, w, taskJSON("TASK_STATE_WORKING")) + default: + t.Fatalf("unexpected method %q", method) + } + default: + http.NotFound(w, r) + } + })) + defer srv.Close() + + client := New(Options{AgentCardURL: srv.URL}) + stream, err := client.SendStream(context.Background(), SendRequest{Message: UserText("stream")}) + if err != nil { + t.Fatalf("SendStream() error = %v", err) + } + defer stream.Close() + + first, err := stream.Recv() + if err != nil { + t.Fatalf("first Recv() error = %v", err) + } + if first.Kind != EventStatus || first.Status == nil || first.Status.State != TaskStateWorking { + t.Fatalf("first event = %+v", first) + } + _, err = stream.Recv() + var recoveryErr *StreamRecoveryError + if !errors.As(err, &recoveryErr) { + t.Fatalf("Recv() error = %v, want *StreamRecoveryError", err) + } +} + +func TestSubscribeRejectsUnsupportedSinceCursor(t *testing.T) { + t.Parallel() + + client := New(Options{AgentCardURL: "https://remote.example/.well-known/agent-card.json"}) + _, err := client.Subscribe(context.Background(), SubscribeRequest{TaskID: "task-1", Since: "cursor-1"}) + if !errors.Is(err, ErrUnsupported) { + t.Fatalf("Subscribe() error = %v, want ErrUnsupported", err) + } +} + +func TestClientRejectsCrossOriginBearerByDefault(t *testing.T) { + t.Parallel() + + type hit struct { + auth string + } + hits := make(chan hit, 1) + + var protocol *httptest.Server + protocol = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + hits <- hit{auth: r.Header.Get("Authorization")} + t.Fatalf("unexpected request to untrusted protocol endpoint") + })) + defer protocol.Close() + + var card *httptest.Server + card = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, protocol.URL+"/a2a") + default: + http.NotFound(w, r) + } + })) + defer card.Close() + + client := New(Options{AgentCardURL: card.URL, Auth: BearerToken("secret")}) + _, err := client.Send(context.Background(), SendRequest{Message: UserText("review this")}) + if !errors.Is(err, ErrUntrustedOrigin) { + t.Fatalf("Send() error = %v, want ErrUntrustedOrigin", err) + } + select { + case got := <-hits: + t.Fatalf("unexpected request to untrusted endpoint with auth %q", got.auth) + default: + } +} + +func TestClientAllowsTrustedCrossOriginBearerWithOptIn(t *testing.T) { + t.Parallel() + + authHeaders := make(chan string, 1) + + var protocol *httptest.Server + protocol = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + authHeaders <- r.Header.Get("Authorization") + if method := readRPCMethod(t, r); method != "SendMessage" { + t.Fatalf("method = %q, want SendMessage", method) + } + writeRPCResult(t, w, `{"task":`+taskJSON("TASK_STATE_COMPLETED")+`}`) + })) + defer protocol.Close() + + var card *httptest.Server + card = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, protocol.URL+"/a2a") + default: + http.NotFound(w, r) + } + })) + defer card.Close() + + client := New(Options{ + AgentCardURL: card.URL, + Auth: BearerToken("secret"), + TrustedAuthOrigins: []string{protocol.URL}, + }) + if _, err := client.Send(context.Background(), SendRequest{Message: UserText("review this")}); err != nil { + t.Fatalf("Send() error = %v", err) + } + select { + case got := <-authHeaders: + if got != "Bearer secret" { + t.Fatalf("Authorization = %q, want %q", got, "Bearer secret") + } + case <-time.After(2 * time.Second): + t.Fatal("trusted protocol request did not arrive") + } +} + +func TestClientDoesNotLeakBearerOnCrossOriginRedirect(t *testing.T) { + t.Parallel() + + redirectedAuth := make(chan string, 1) + + var redirected *httptest.Server + redirected = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + redirectedAuth <- r.Header.Get("Authorization") + if method := readRPCMethod(t, r); method != "SendMessage" { + t.Fatalf("redirected method = %q, want SendMessage", method) + } + writeRPCResult(t, w, `{"task":`+taskJSON("TASK_STATE_COMPLETED")+`}`) + })) + defer redirected.Close() + + var card *httptest.Server + card = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + switch r.URL.Path { + case "/.well-known/agent-card.json": + _, _ = fmt.Fprintf(w, `{ + "name":"Remote Agent", + "description":"test", + "version":"1.0.0", + "supportedInterfaces":[{"url":%q,"protocolBinding":"JSONRPC","protocolVersion":"1.0"}], + "capabilities":{}, + "defaultInputModes":["text/plain"], + "defaultOutputModes":["text/plain"], + "skills":[{"id":"chat","name":"Chat","description":"chat","tags":["chat"]}] + }`, card.URL+"/a2a") + case "/a2a": + http.Redirect(w, r, redirected.URL+"/a2a", http.StatusTemporaryRedirect) + default: + http.NotFound(w, r) + } + })) + defer card.Close() + + client := New(Options{AgentCardURL: card.URL, Auth: BearerToken("secret")}) + if _, err := client.Send(context.Background(), SendRequest{Message: UserText("review this")}); err != nil { + t.Fatalf("Send() error = %v", err) + } + select { + case got := <-redirectedAuth: + if got != "" { + t.Fatalf("redirected Authorization = %q, want empty", got) + } + case <-time.After(2 * time.Second): + t.Fatal("redirected protocol request did not arrive") + } +} + +func UserText(text string) Message { + return Message{Role: "user", Parts: []Part{{Kind: PartText, Text: text, MediaType: "text/plain"}}} +} + +func collectStreamEvents(t *testing.T, stream *Stream) []Event { + t.Helper() + var events []Event + for { + ev, err := stream.Recv() + if errors.Is(err, io.EOF) { + return events + } + if err != nil { + t.Fatalf("Recv() error = %v", err) + } + events = append(events, ev) + } +} + +func taskJSON(state string) string { + return `{ + "id":"task-1", + "contextId":"ctx-1", + "status":{ + "state":"` + state + `", + "message":{"messageId":"msg-agent","role":"ROLE_AGENT","taskId":"task-1","contextId":"ctx-1","parts":[{"text":"done"}]} + }, + "history":[{"messageId":"msg-user","role":"ROLE_USER","taskId":"task-1","contextId":"ctx-1","parts":[{"text":"hi"}]}], + "artifacts":[{"artifactId":"artifact-1","name":"report","parts":[{"text":"body"}]}], + "metadata":{"remote":"yes"} + }` +} + +func messageJSON(text string) string { + return `{ + "messageId":"msg-agent", + "role":"ROLE_AGENT", + "taskId":"task-1", + "contextId":"ctx-1", + "parts":[{"text":"` + text + `"}] + }` +} + +func statusUpdateJSON(state string) string { + return `{ + "statusUpdate":{ + "taskId":"task-1", + "contextId":"ctx-1", + "status":{ + "state":"` + state + `", + "message":{"messageId":"msg-agent","role":"ROLE_AGENT","taskId":"task-1","contextId":"ctx-1","parts":[{"text":"state"}]} + } + } + }` +} + +func readRPCMethod(t *testing.T, r *http.Request) string { + t.Helper() + method, _ := readRPCRequest(t, r) + return method +} + +func readRPCRequest(t *testing.T, r *http.Request) (string, map[string]any) { + t.Helper() + var req struct { + Method string `json:"method"` + Params map[string]any `json:"params"` + } + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + t.Fatalf("decode RPC request: %v", err) + } + return req.Method, req.Params +} + +func writeRPCResult(t *testing.T, w http.ResponseWriter, result string) { + t.Helper() + w.Header().Set("Content-Type", "application/json") + _, _ = fmt.Fprintf(w, `{"jsonrpc":"2.0","id":"1","result":%s}`, result) +} + +func rpcResult(result string) string { + return fmt.Sprintf(`{"jsonrpc":"2.0","id":"1","result":%s}`, result) +} + +func writeSSE(t *testing.T, w http.ResponseWriter, payloads ...string) { + t.Helper() + w.Header().Set("Content-Type", "text/event-stream") + for _, payload := range payloads { + var compact bytes.Buffer + if err := json.Compact(&compact, []byte(payload)); err != nil { + t.Fatalf("compact SSE payload: %v\n%s", err, payload) + } + _, _ = fmt.Fprintf(w, "data: %s\n\n", compact.String()) + } +} + +func writeBrokenSSE(t *testing.T, w http.ResponseWriter, payloads ...string) { + t.Helper() + w.Header().Set("Content-Type", "text/event-stream") + if flusher, ok := w.(http.Flusher); ok { + defer flusher.Flush() + } + for i, payload := range payloads { + if i == len(payloads)-1 { + _, _ = fmt.Fprintf(w, "data: %s\n\n", payload) + return + } + var compact bytes.Buffer + if err := json.Compact(&compact, []byte(payload)); err != nil { + t.Fatalf("compact broken SSE payload: %v\n%s", err, payload) + } + _, _ = fmt.Fprintf(w, "data: %s\n\n", compact.String()) + } +} diff --git a/pkg/clients/a2a/convert.go b/pkg/clients/a2a/convert.go new file mode 100644 index 0000000..8160656 --- /dev/null +++ b/pkg/clients/a2a/convert.go @@ -0,0 +1,294 @@ +package a2a + +import ( + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + + a2aproto "github.com/a2aproject/a2a-go/v2/a2a" +) + +func convertAgentCard(card *a2aproto.AgentCard) (AgentCard, error) { + if card == nil { + return AgentCard{}, fmt.Errorf("%w: nil card", ErrInvalidAgentCard) + } + raw, _ := rawMap(card) + fingerprint, err := fingerprint(card) + if err != nil { + return AgentCard{}, err + } + out := AgentCard{ + Name: card.Name, + Description: card.Description, + Version: card.Version, + DocumentationURL: card.DocumentationURL, + IconURL: card.IconURL, + DefaultInputModes: append([]string(nil), card.DefaultInputModes...), + DefaultOutputModes: append([]string(nil), card.DefaultOutputModes...), + Fingerprint: fingerprint, + Raw: raw, + Capabilities: Capabilities{ + Streaming: card.Capabilities.Streaming, + PushNotifications: card.Capabilities.PushNotifications, + ExtendedAgentCard: card.Capabilities.ExtendedAgentCard, + }, + } + if card.Provider != nil { + out.Provider = &Provider{Organization: card.Provider.Org, URL: card.Provider.URL} + } + for _, ext := range card.Capabilities.Extensions { + out.Capabilities.Extensions = append(out.Capabilities.Extensions, Extension{ + URI: ext.URI, Description: ext.Description, Required: ext.Required, Params: cloneMap(ext.Params), + }) + } + for i, iface := range card.SupportedInterfaces { + if iface == nil { + continue + } + if i == 0 { + out.URL = iface.URL + } + out.SupportedInterfaces = append(out.SupportedInterfaces, AgentInterface{ + URL: iface.URL, ProtocolBinding: TransportProtocol(iface.ProtocolBinding), Tenant: iface.Tenant, + ProtocolVersion: string(iface.ProtocolVersion), + }) + } + for _, skill := range card.Skills { + out.Skills = append(out.Skills, Skill{ + ID: skill.ID, Name: skill.Name, Description: skill.Description, + Tags: append([]string(nil), skill.Tags...), Examples: append([]string(nil), skill.Examples...), + InputModes: append([]string(nil), skill.InputModes...), OutputModes: append([]string(nil), skill.OutputModes...), + }) + } + return out, nil +} + +func validateAgentCard(card *a2aproto.AgentCard) error { + if card == nil { + return fmt.Errorf("%w: nil card", ErrInvalidAgentCard) + } + if card.Name == "" { + return fmt.Errorf("%w: missing name", ErrInvalidAgentCard) + } + if card.Version == "" { + return fmt.Errorf("%w: missing version", ErrInvalidAgentCard) + } + if len(card.SupportedInterfaces) == 0 { + return fmt.Errorf("%w: missing supportedInterfaces", ErrInvalidAgentCard) + } + seenSkill := map[string]struct{}{} + for _, iface := range card.SupportedInterfaces { + if iface == nil || iface.URL == "" { + return fmt.Errorf("%w: interface url is required", ErrInvalidAgentCard) + } + if iface.ProtocolBinding == "" { + return fmt.Errorf("%w: interface protocolBinding is required", ErrInvalidAgentCard) + } + if iface.ProtocolVersion != "" && iface.ProtocolVersion != a2aproto.Version { + return fmt.Errorf("%w: unsupported protocol version %q", ErrInvalidAgentCard, iface.ProtocolVersion) + } + } + for _, skill := range card.Skills { + if skill.ID == "" { + return fmt.Errorf("%w: skill id is required", ErrInvalidAgentCard) + } + if _, exists := seenSkill[skill.ID]; exists { + return fmt.Errorf("%w: duplicate skill %q", ErrInvalidAgentCard, skill.ID) + } + seenSkill[skill.ID] = struct{}{} + } + return nil +} + +func upstreamMessage(in Message) *a2aproto.Message { + role := a2aproto.MessageRoleUser + if in.Role == "agent" || in.Role == string(a2aproto.MessageRoleAgent) { + role = a2aproto.MessageRoleAgent + } + msg := a2aproto.NewMessage(role, upstreamParts(in.Parts)...) + if in.ID != "" { + msg.ID = in.ID + } + msg.ContextID = in.ContextID + msg.TaskID = a2aproto.TaskID(in.TaskID) + msg.Metadata = cloneMap(in.Metadata) + msg.Extensions = append([]string(nil), in.Extensions...) + for _, id := range in.ReferenceTasks { + msg.ReferenceTasks = append(msg.ReferenceTasks, a2aproto.TaskID(id)) + } + return msg +} + +func upstreamParts(parts []Part) []*a2aproto.Part { + if len(parts) == 0 { + return []*a2aproto.Part{a2aproto.NewTextPart("")} + } + out := make([]*a2aproto.Part, 0, len(parts)) + for _, p := range parts { + var up *a2aproto.Part + switch p.Kind { + case PartRaw: + up = a2aproto.NewRawPart(p.Raw) + case PartData: + up = a2aproto.NewDataPart(p.Data) + case PartURL: + up = a2aproto.NewFileURLPart(a2aproto.URL(p.URL), p.MediaType) + default: + up = a2aproto.NewTextPart(p.Text) + } + up.MediaType = p.MediaType + up.Filename = p.Filename + up.Metadata = cloneMap(p.Metadata) + out = append(out, up) + } + return out +} + +func convertTask(task *a2aproto.Task) Task { + if task == nil { + return Task{} + } + raw, _ := rawMap(task) + out := Task{ + ID: string(task.ID), ContextID: task.ContextID, Status: convertStatus(task.Status), + Metadata: cloneMap(task.Metadata), Raw: raw, + } + for _, msg := range task.History { + out.Messages = append(out.Messages, convertMessage(msg)) + } + for _, artifact := range task.Artifacts { + out.Artifacts = append(out.Artifacts, convertArtifact(artifact)) + } + return out +} + +func convertMessage(msg *a2aproto.Message) Message { + if msg == nil { + return Message{} + } + raw, _ := rawMap(msg) + out := Message{ + ID: msg.ID, Role: string(msg.Role), TaskID: string(msg.TaskID), ContextID: msg.ContextID, + Extensions: append([]string(nil), msg.Extensions...), Metadata: cloneMap(msg.Metadata), Raw: raw, + } + for _, id := range msg.ReferenceTasks { + out.ReferenceTasks = append(out.ReferenceTasks, string(id)) + } + out.Parts = convertParts(msg.Parts) + return out +} + +func convertStatus(status a2aproto.TaskStatus) TaskStatus { + out := TaskStatus{State: TaskState(status.State), Timestamp: status.Timestamp} + if status.Message != nil { + msg := convertMessage(status.Message) + out.Message = &msg + } + return out +} + +func convertArtifact(a *a2aproto.Artifact) Artifact { + if a == nil { + return Artifact{} + } + raw, _ := rawMap(a) + return Artifact{ + ID: string(a.ID), Name: a.Name, Description: a.Description, + Extensions: append([]string(nil), a.Extensions...), Metadata: cloneMap(a.Metadata), + Parts: convertParts(a.Parts), Raw: raw, + } +} + +func convertParts(parts []*a2aproto.Part) []Part { + out := make([]Part, 0, len(parts)) + for _, p := range parts { + if p == nil { + continue + } + part := Part{MediaType: p.MediaType, Filename: p.Filename, Metadata: cloneMap(p.Metadata)} + switch v := p.Content.(type) { + case a2aproto.Text: + part.Kind = PartText + part.Text = string(v) + case a2aproto.Raw: + part.Kind = PartRaw + part.Raw = append([]byte(nil), []byte(v)...) + case a2aproto.Data: + part.Kind = PartData + part.Data = v.Value + case a2aproto.URL: + part.Kind = PartURL + part.URL = string(v) + default: + part.Kind = PartData + part.Data = v + } + out = append(out, part) + } + return out +} + +func eventFromUpstream(ev a2aproto.Event) (Event, error) { + switch e := ev.(type) { + case *a2aproto.Task: + t := convertTask(e) + kind := EventTask + if executionFinalTask(t) { + kind = EventTerminal + } + return Event{Kind: kind, Task: &t, TaskID: t.ID, ContextID: t.ContextID, Raw: t.Raw}, nil + case *a2aproto.Message: + m := convertMessage(e) + return Event{Kind: EventTerminal, Message: &m, TaskID: m.TaskID, ContextID: m.ContextID, Raw: m.Raw}, nil + case *a2aproto.TaskStatusUpdateEvent: + status := convertStatus(e.Status) + kind := EventStatus + if executionFinalState(status.State) { + kind = EventTerminal + } + raw, _ := rawMap(e) + return Event{Kind: kind, Status: &status, TaskID: string(e.TaskID), ContextID: e.ContextID, Raw: raw}, nil + case *a2aproto.TaskArtifactUpdateEvent: + artifact := convertArtifact(e.Artifact) + raw, _ := rawMap(e) + return Event{ + Kind: EventArtifact, Artifact: &artifact, TaskID: string(e.TaskID), ContextID: e.ContextID, + Append: e.Append, LastChunk: e.LastChunk, Raw: raw, + }, nil + default: + return Event{}, fmt.Errorf("%w: unknown upstream event %T", ErrProtocol, ev) + } +} + +func fingerprint(v any) (string, error) { + raw, err := json.Marshal(v) + if err != nil { + return "", err + } + sum := sha256.Sum256(raw) + return hex.EncodeToString(sum[:]), nil +} + +func rawMap(v any) (map[string]any, error) { + raw, err := json.Marshal(v) + if err != nil { + return nil, err + } + var out map[string]any + if err := json.Unmarshal(raw, &out); err != nil { + return nil, err + } + return out, nil +} + +func cloneMap(in map[string]any) map[string]any { + if len(in) == 0 { + return nil + } + out := make(map[string]any, len(in)) + for k, v := range in { + out[k] = v + } + return out +} diff --git a/pkg/clients/a2a/doc.go b/pkg/clients/a2a/doc.go new file mode 100644 index 0000000..3797f96 --- /dev/null +++ b/pkg/clients/a2a/doc.go @@ -0,0 +1,13 @@ +// Package a2a provides thin, host-oriented client primitives for remote A2A +// agents. +// +// The package deliberately stays protocol-shaped: +// - it fetches, validates, and caches Agent Cards +// - it exposes Send, SendStream, Subscribe, GetTask, and CancelTask +// - it preserves task IDs, context IDs, status, artifacts, raw protocol +// payloads, and protocol errors in stable agent-adaptor-owned DTOs +// - it surfaces typed protocol errors without introducing SDK RunResult semantics +// +// The implementation delegates discovery and wire transports to the official +// github.com/a2aproject/a2a-go/v2 SDK. +package a2a diff --git a/pkg/clients/a2a/errors.go b/pkg/clients/a2a/errors.go new file mode 100644 index 0000000..64a1511 --- /dev/null +++ b/pkg/clients/a2a/errors.go @@ -0,0 +1,70 @@ +package a2a + +import ( + "errors" + "fmt" +) + +var ( + ErrInvalidAgentCard = errors.New("a2a client: invalid agent card") + ErrProtocol = errors.New("a2a client: protocol error") + ErrUnauthorized = errors.New("a2a client: unauthorized") + ErrNotFound = errors.New("a2a client: task not found") + ErrUnsupported = errors.New("a2a client: unsupported operation") + ErrUntrustedOrigin = errors.New("a2a client: untrusted origin") +) + +type ProtocolError struct { + Op string + Reason string + Cause error + Raw map[string]any +} + +func (e *ProtocolError) Error() string { + if e == nil { + return "" + } + if e.Reason != "" && e.Op != "" { + return fmt.Sprintf("%s: %s", e.Op, e.Reason) + } + if e.Reason != "" { + return e.Reason + } + if e.Cause != nil { + return e.Cause.Error() + } + return ErrProtocol.Error() +} + +func (e *ProtocolError) Unwrap() error { + if e == nil { + return nil + } + if e.Cause != nil { + return e.Cause + } + return ErrProtocol +} + +type StreamRecoveryError struct { + TaskID string + Cause error +} + +func (e *StreamRecoveryError) Error() string { + if e == nil { + return "" + } + if e.TaskID != "" { + return fmt.Sprintf("a2a stream disconnected before terminal state for task %s: %v", e.TaskID, e.Cause) + } + return fmt.Sprintf("a2a stream disconnected before terminal state: %v", e.Cause) +} + +func (e *StreamRecoveryError) Unwrap() error { + if e == nil { + return nil + } + return e.Cause +} diff --git a/pkg/clients/a2a/execution_state.go b/pkg/clients/a2a/execution_state.go new file mode 100644 index 0000000..b045847 --- /dev/null +++ b/pkg/clients/a2a/execution_state.go @@ -0,0 +1,24 @@ +package a2a + +func executionFinalState(state TaskState) bool { + return state.Terminal() || state == TaskStateInputRequired +} + +func executionFinalTask(task Task) bool { + return executionFinalState(task.Status.State) +} + +func executionFinalEvent(event Event) bool { + switch event.Kind { + case EventMessage: + return event.Message != nil + case EventTerminal: + return true + case EventTask: + return event.Task != nil && executionFinalTask(*event.Task) + case EventStatus: + return event.Status != nil && executionFinalState(event.Status.State) + default: + return false + } +} diff --git a/pkg/clients/a2a/import_boundary_test.go b/pkg/clients/a2a/import_boundary_test.go new file mode 100644 index 0000000..e221236 --- /dev/null +++ b/pkg/clients/a2a/import_boundary_test.go @@ -0,0 +1,114 @@ +package a2a_test + +import ( + "go/parser" + "go/token" + "os" + "path/filepath" + "runtime" + "strings" + "testing" +) + +func TestA2AImportsStayLocalized(t *testing.T) { + t.Parallel() + + repoRoot := repositoryRoot(t) + allowed := []string{ + filepath.Join("pkg", "clients", "a2a"), + filepath.Join("pkg", "bridges", "a2a"), + } + fset := token.NewFileSet() + err := filepath.WalkDir(repoRoot, func(path string, entry os.DirEntry, err error) error { + if err != nil { + return err + } + if entry.IsDir() { + switch entry.Name() { + case ".git", ".omx": + return filepath.SkipDir + default: + return nil + } + } + if !strings.HasSuffix(entry.Name(), ".go") { + return nil + } + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + return err + } + for _, imp := range file.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + if !strings.HasPrefix(importPath, "github.com/a2aproject/a2a-go") { + continue + } + rel, err := filepath.Rel(repoRoot, path) + if err != nil { + return err + } + if !underAny(rel, allowed) { + t.Fatalf("A2A SDK import escaped localized packages: %s imports %s", rel, importPath) + } + } + return nil + }) + if err != nil { + t.Fatalf("walk repo imports: %v", err) + } +} + +func TestClientPackageDoesNotImportCoreOrConcreteAdapters(t *testing.T) { + t.Parallel() + + root := filepath.Join(repositoryRoot(t), "pkg", "clients", "a2a") + forbidden := []string{ + "github.com/agent-dance/agent-adaptor", + "github.com/agent-dance/agent-adaptor/claude", + "github.com/agent-dance/agent-adaptor/codex", + "github.com/agent-dance/agent-adaptor/cursor", + } + fset := token.NewFileSet() + entries, err := os.ReadDir(root) + if err != nil { + t.Fatalf("readdir: %v", err) + } + for _, entry := range entries { + if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".go") || strings.HasSuffix(entry.Name(), "_test.go") { + continue + } + path := filepath.Join(root, entry.Name()) + file, err := parser.ParseFile(fset, path, nil, parser.ImportsOnly) + if err != nil { + t.Fatalf("parse %s: %v", path, err) + } + for _, imp := range file.Imports { + importPath := strings.Trim(imp.Path.Value, `"`) + for _, forbiddenImport := range forbidden { + if importPath == forbiddenImport || strings.HasPrefix(importPath, forbiddenImport+"/") { + t.Fatalf("%s imports forbidden dependency %s", path, forbiddenImport) + } + } + } + } +} + +func repositoryRoot(t *testing.T) string { + t.Helper() + _, thisFile, _, ok := runtime.Caller(0) + if !ok { + t.Fatal("runtime.Caller failed") + } + return filepath.Clean(filepath.Join(filepath.Dir(thisFile), "..", "..", "..")) +} + +func underAny(path string, roots []string) bool { + clean := filepath.Clean(path) + for _, root := range roots { + root = filepath.Clean(root) + if clean == root || strings.HasPrefix(clean, root+string(os.PathSeparator)) { + return true + } + } + return false +} diff --git a/pkg/clients/a2a/types.go b/pkg/clients/a2a/types.go new file mode 100644 index 0000000..c479aaf --- /dev/null +++ b/pkg/clients/a2a/types.go @@ -0,0 +1,206 @@ +// Package a2a contains thin client primitives for consuming remote A2A agents. +// +// The package intentionally exposes agent-adaptor-owned DTOs instead of +// re-exporting github.com/a2aproject/a2a-go/v2/a2a types. Upstream protocol +// types are used at the package edge for wire behavior, while callers get a +// stable narrow API that does not pretend remote A2A traffic has local CLI +// stdout/stderr semantics. +package a2a + +import "time" + +// TransportProtocol names an A2A transport binding. +type TransportProtocol string + +const ( + TransportJSONRPC TransportProtocol = "JSONRPC" + TransportHTTPJSON TransportProtocol = "HTTP+JSON" +) + +// AgentCard is the validated discovery document returned by AgentCard. +type AgentCard struct { + Name string + Description string + URL string + Version string + DocumentationURL string + IconURL string + Provider *Provider + Capabilities Capabilities + DefaultInputModes []string + DefaultOutputModes []string + Skills []Skill + SupportedInterfaces []AgentInterface + Fingerprint string + Raw map[string]any +} + +type Provider struct { + Organization string + URL string +} + +type Capabilities struct { + Streaming bool + PushNotifications bool + ExtendedAgentCard bool + Extensions []Extension +} + +type Extension struct { + URI string + Description string + Required bool + Params map[string]any +} + +type AgentInterface struct { + URL string + ProtocolBinding TransportProtocol + Tenant string + ProtocolVersion string +} + +type Skill struct { + ID string + Name string + Description string + Tags []string + Examples []string + InputModes []string + OutputModes []string +} + +// Message is an A2A message projected into a stable local shape. +type Message struct { + ID string + Role string + TaskID string + ContextID string + Parts []Part + ReferenceTasks []string + Extensions []string + Metadata map[string]any + Raw map[string]any +} + +type Part struct { + Kind PartKind + Text string + Raw []byte + Data any + URL string + MediaType string + Filename string + Metadata map[string]any +} + +type PartKind string + +const ( + PartText PartKind = "text" + PartRaw PartKind = "raw" + PartData PartKind = "data" + PartURL PartKind = "url" +) + +type Task struct { + ID string + ContextID string + Status TaskStatus + Messages []Message + Artifacts []Artifact + Metadata map[string]any + Raw map[string]any +} + +type TaskStatus struct { + State TaskState + Message *Message + Timestamp *time.Time +} + +type TaskState string + +const ( + TaskStateUnspecified TaskState = "" + 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" + TaskStateInputRequired TaskState = "TASK_STATE_INPUT_REQUIRED" + TaskStateRejected TaskState = "TASK_STATE_REJECTED" + TaskStateAuthRequired TaskState = "TASK_STATE_AUTH_REQUIRED" +) + +func (s TaskState) Terminal() bool { + return s == TaskStateCompleted || s == TaskStateFailed || s == TaskStateCanceled || s == TaskStateRejected +} + +type Artifact struct { + ID string + Name string + Description string + Parts []Part + Extensions []string + Metadata map[string]any + Raw map[string]any +} + +// Event is one ordered A2A stream/subscription update. +type Event struct { + Kind EventKind + Task *Task + Message *Message + Status *TaskStatus + Artifact *Artifact + TaskID string + ContextID string + Append bool + LastChunk bool + RecoveredState bool + Raw map[string]any +} + +type EventKind string + +const ( + EventTask EventKind = "task" + EventMessage EventKind = "message" + EventStatus EventKind = "status" + EventArtifact EventKind = "artifact" + EventTerminal EventKind = "terminal" +) + +type SendRequest struct { + Message Message + ContextID string + TaskID string + Tenant string + AcceptedOutputModes []string + ReturnImmediately bool + HistoryLength *int + Metadata map[string]any +} + +type SubscribeRequest struct { + TaskID string + Tenant string + // Since is rejected when set. A2A 1.0 SubscribeToTask has no cursor replay + // field, and the client refuses to pretend host-side replay cursors are + // supported by the remote protocol. + Since string +} + +type GetTaskRequest struct { + TaskID string + Tenant string + HistoryLength *int +} + +type CancelTaskRequest struct { + TaskID string + Tenant string + Metadata map[string]any +}