# A2A Protocol Support Plan for Swival

Reference: https://a2a-protocol.org/latest/ (v1.0, Linux Foundation)

## Overview

The Agent-to-Agent (A2A) protocol is an open standard for communication between AI agents. Supporting it in Swival means two things:

1. **Client** -- Swival can call remote A2A agents as tools during its agent loop
2. **Server** -- Swival can expose itself as an A2A endpoint so other agents can call it

Both sides follow the proven pattern already established by MCP integration.

---

## Part 1: A2A Client (remote agents as tools)

### 1.1 New module: `swival/a2a_client.py`

An `A2aManager` class, modeled after `McpManager`:

```
class A2aManager:
    __init__(server_configs: dict, verbose: bool)
    start()                              # fetch Agent Cards, validate capabilities
    list_tools() -> list[dict]           # OpenAI function-calling schemas
    get_tool_info() -> dict              # for system prompt section
    call_tool(name, args) -> (str, bool) # result text + is_error
    close()
```

**Lifecycle:**
1. On `start()`, fetch each configured agent's Agent Card from
   `<url>/.well-known/agent-card.json` (RFC 8615) or an explicit `card_url` override
2. Parse skills from the card. Skills carry capability metadata (id, name,
   description, inputModes, outputModes, examples) but **not** a machine-readable
   argument schema. Therefore every remote agent is exposed as a generic
   message-oriented tool (see 1.1.1)
3. Namespace as `a2a__<agent-name>__<skill-id>` (same pattern as `mcp__`).
   If the card declares no skills, expose a single `a2a__<agent-name>__ask` tool

#### 1.1.1 Generic tool shape

Because A2A skills have no parameter schema, every generated tool uses the same
fixed OpenAI function-calling shape:

```json
{
  "type": "function",
  "function": {
    "name": "a2a__research-agent__web-search",
    "description": "Skill 'web-search' on remote agent 'research-agent': <skill description from card>",
    "parameters": {
      "type": "object",
      "properties": {
        "message": {
          "type": "string",
          "description": "The message to send to the remote agent"
        },
        "context_id": {
          "type": "string",
          "description": "Optional. Pass a previous contextId to continue a conversation"
        },
        "task_id": {
          "type": "string",
          "description": "Optional. Pass a previous taskId to resume an input-required task"
        }
      },
      "required": ["message"]
    }
  }
}
```

The `task_id` parameter exists specifically for `input-required` resumption. When
a remote agent returns `input-required`, the tool result includes both the
contextId and taskId. The LLM passes both back on the next call so that
`call_tool()` can resume the non-terminal task instead of creating a new one.
For all other multi-turn cases (continuing after a completed task), only
`context_id` is needed.

The LLM composes a natural-language message; the A2aManager wraps it as an A2A
`Message` with a text `Part`. The skill description (plus examples from the card)
in the tool description gives the LLM enough signal to craft useful messages.

### 1.2 Wire protocol constants

The A2A v1.0 JSON-RPC binding uses unnamespaced method names. The Python SDK
models these as request types like `SendMessageRequest`. The spec overview page
lists methods as `SendMessage`, `GetTask`, etc. without an `A2A.` prefix.

Centralize all method names and endpoint paths in `a2a_types.py`:

```python
# JSON-RPC method names (v1.0 spec)
METHOD_SEND_MESSAGE = "SendMessage"
METHOD_SEND_STREAMING_MESSAGE = "SendStreamingMessage"
METHOD_GET_TASK = "GetTask"
METHOD_LIST_TASKS = "ListTasks"
METHOD_CANCEL_TASK = "CancelTask"
METHOD_SUBSCRIBE_TO_TASK = "SubscribeToTask"
METHOD_CREATE_PUSH_CONFIG = "CreateTaskPushNotificationConfig"
METHOD_GET_PUSH_CONFIG = "GetTaskPushNotificationConfig"
METHOD_LIST_PUSH_CONFIGS = "ListTaskPushNotificationConfigs"
METHOD_DELETE_PUSH_CONFIG = "DeleteTaskPushNotificationConfig"
METHOD_GET_EXTENDED_CARD = "GetExtendedAgentCard"

# HTTP/REST paths (v1.0 proto definition)
REST_SEND_MESSAGE = "/message:send"
REST_SEND_STREAMING = "/message:stream"
REST_GET_TASK = "/tasks/{id=*}"
REST_LIST_TASKS = "/tasks"
REST_CANCEL_TASK = "/tasks/{id=*}:cancel"
REST_SUBSCRIBE = "/tasks/{id=*}:subscribe"
REST_EXTENDED_CARD = "/extendedAgentCard"
# Multi-tenant variants prefix /{tenant} to each path

# Agent Card discovery
AGENT_CARD_PATH = "/.well-known/agent-card.json"

# Protocol version
A2A_VERSION = "1.0"
```

Phase 1 targets JSON-RPC only. HTTP/REST constants are defined for completeness
and future use. If the spec stabilizes on different names, only this file changes.

**Calling a remote agent:**
1. `call_tool()` builds a JSON-RPC 2.0 request with method `SendMessage`:
   - `params.message`: a `Message` (role `user`) with a single text Part
   - `params.configuration.acceptedOutputModes = ["text/plain", "application/json"]`
   - `params.configuration.returnImmediately = false` (the default) -- the server
     blocks until the task reaches a terminal or interrupted state before returning
   - If `context_id` is provided, set it on the Message
   - If `task_id` is provided (input-required resumption), set it on the Message
2. POST to the agent's JSON-RPC endpoint. Since `returnImmediately` defaults to
   `false`, the server holds the connection open until the task completes,
   fails, or reaches `input-required`/`auth-required`. This means a single
   `SendMessage` round-trip is the normal case -- no polling loop needed.
3. If the server returns a task in a non-terminal, non-interrupted state anyway
   (e.g. it ignores `returnImmediately` or the client explicitly sets it to
   `true`), fall back to polling with `GetTask` (exponential backoff, capped at
   the configured timeout). This is the fallback, not the primary path.
4. Extract text from the response artifacts and messages, return as
   `(result_text, is_error)` where `is_error = (state in {failed, rejected})`
5. On `input-required` state: return the agent's message as the tool result,
   including the `contextId` and `taskId` so the LLM can pass them back on a
   follow-up call. The tool result text looks like:

   ```
   [input-required] contextId=abc123 taskId=task456
   <agent's message asking for more info>
   ```

6. On `auth-required`: return an error result describing the auth requirement

**Error handling:**
- Per-agent degradation on repeated failures (like MCP)
- Timeout configurable per agent (default 120s)
- Size-guard output the same way MCP does (20KB inline, overflow to file)

### 1.3 Multi-turn with remote agents

A2A multi-turn works through `contextId`, not by reusing old task IDs. The rules:

- A terminal task (completed, failed, canceled, rejected) stays terminal. You
  never reopen it.
- To continue a conversation, the client sends a new `SendMessage` with the same
  `contextId`. The remote server creates a new Task grouped under that context.
- `input-required` is the exception: the client sends a follow-up `SendMessage`
  with both the `taskId` and `contextId` to resume the existing non-terminal task.

Implementation in `A2aManager`:
- **No task ID cache.** The LLM decides whether to continue a conversation by
  passing back the `contextId` it received in a previous tool result.
- The `context_id` parameter on the tool schema makes this explicit: the LLM
  sees a contextId in the tool result and can choose to pass it on the next call.
- On `input-required`, the tool result includes both IDs. The LLM passes both
  back via the `context_id` and `task_id` tool parameters. `call_tool()` detects
  a `task_id` arg and sends the follow-up to the right task.
- On `session.reset()` and REPL `/clear`, there is nothing to clean up in the
  A2aManager itself -- context continuity is driven entirely by the LLM choosing
  to pass (or not pass) previous IDs.

### 1.4 Compaction and orphaned tasks

Compaction can drop tool results that contain contextId/taskId values. For
terminal tasks this is harmless -- the LLM starts a new conversation. But for
non-terminal `input-required` tasks, compaction can strand a live remote task
that is waiting for input.

**Tradeoffs and mitigation:**

This is an inherent tension: compaction exists because context is exhausted, but
dropping an `input-required` result orphans the remote task. We accept this
tradeoff with the following mitigations:

1. **Compaction-aware summarization for `a2a__` tools.** Add a dedicated branch
   in `compact_tool_result()` (agent.py:370) alongside the existing `mcp__`
   branch (agent.py:419). For `a2a__` tool results that contain `[input-required]`, the compact
   summary preserves the contextId/taskId:

   ```
   [a2a__agent__skill: input-required, contextId=abc123 taskId=task456 — compacted]
   ```

   This gives the LLM a chance to resume the task even after compaction. For
   terminal-state results, use the same head-snippet style as MCP:

   ```
   [a2a__agent__skill: 1234 chars — compacted]
   First 300 chars: ...
   ```

2. **Orphaned task acknowledgment.** If compaction drops an `input-required`
   result entirely (aggressive_drop_turns), the remote task is orphaned. This is
   documented as a known limitation. The remote server's TTL cleanup will
   eventually expire it. No client-side `CancelTask` is attempted during
   compaction because it adds complexity for a rare edge case.

3. **Proactive snapshots.** The existing `CompactionState` proactive checkpoints
   (which save context before pressure gets critical) naturally help here --
   they reduce the chance of reaching aggressive compaction while an
   `input-required` task is live.

### 1.5 Configuration

In `swival.toml`:

```toml
[a2a_servers.research-agent]
url = "https://research-agent.example.com"
# Optional overrides:
card_url = "https://research-agent.example.com/.well-known/agent-card.json"
auth_type = "bearer"      # or "api_key", "oauth2"
auth_token = "sk-..."     # or reference env var
timeout = 120
```

In `config.py`:
- Extract `a2a_servers` table alongside `mcp_servers` (before `apply_config_to_args`)
- Add `validate_a2a_server_configs()` (name format, required fields)
- Pass through to `Session` as `a2a_servers` kwarg

### 1.6 CLI bootstrap

MCP has a two-step stashing dance in `agent.py:main()`:

1. Line 1978: `args._mcp_servers_toml = file_config.pop("mcp_servers", None)` --
   stash before `apply_config_to_args()` strips unknown keys
2. Line 2537+: merge TOML servers with `.mcp.json` servers, then create `McpManager`

A2A needs the exact same treatment:

1. Stash: `args._a2a_servers_toml = file_config.pop("a2a_servers", None)` right
   next to the MCP stash
2. Later, after arg validation, merge TOML config with any `--a2a-config` file
3. Create `A2aManager` alongside `McpManager`
4. Append A2A tools to the tools list, add A2A info to system prompt

Without this, `swival.toml` config would work through `Session` but the CLI
path would silently ignore A2A servers.

CLI flags:
- `--a2a-config FILE` (alternative config source, like `--mcp-config`)
- `--no-a2a` (disable all A2A connections)

### 1.7 Integration points

| File | Change |
|------|--------|
| `session.py` | Accept `a2a_servers` kwarg, create `A2aManager` in `_setup()`, append tools |
| `tools.py:dispatch()` | Route `a2a__` prefixed names to `A2aManager.call_tool()` |
| `agent.py` (system prompt) | Include A2A tool info in system prompt section |
| `agent.py:main()` | Stash `a2a_servers` from TOML, merge with `--a2a-config`, create manager |
| `agent.py:build_parser()` | Add `--a2a-config` and `--no-a2a` flags |
| `agent.py:compact_tool_result()` | Add `a2a__` branch preserving input-required IDs |
| `config.py` | Parse `[a2a_servers.*]` tables, validate |

### 1.8 Dependencies

- `httpx` (add via `uv add httpx`) for async HTTP + SSE
- Plain dataclasses for A2A message types (no pydantic -- keep it lightweight)

---

## Part 2: A2A Server (expose Swival as an A2A agent)

**Prerequisite**: Do not start this until the conversation model is validated
through client-side work. The server must correctly maintain `contextId`-keyed
sessions, which is harder than the client side.

### 2.1 Conversation model

The server maps A2A concepts to Swival's `Session`:

- One `Session` instance per **contextId**. The server maintains a
  `dict[str, Session]` keyed by contextId with TTL-based expiry.
- First `SendMessage` (no contextId): create a new Session, generate a contextId,
  call `session.ask(question)`. Return the contextId on the Task.
- Follow-up `SendMessage` (with contextId): look up the existing Session, call
  `session.ask(question)`. This preserves conversation state via `_conv_state`.
- Follow-up on same taskId (input-required resumption): same Session, same
  `ask()` call, same `_conv_state`.
- `session.ask()` is the only entry point. Never `session.run()`, because `run()`
  creates fresh state on every call and cannot maintain context.

This means the server is stateful. Each Session holds its message history,
thinking state, todo state, snapshot state, file tracker, etc.

### 2.2 Task management

```python
@dataclass
class A2aTask:
    id: str                          # server-generated UUID
    context_id: str                  # groups related tasks
    status: str                      # working | completed | failed | ...
    messages: list[dict]             # A2A Message objects
    artifacts: list[dict]            # A2A Artifact objects
    created_at: datetime
    updated_at: datetime
```

- Tasks stored in-memory `dict[str, A2aTask]` (task_id -> task).
- A secondary index `dict[str, list[str]]` maps contextId -> list of task_ids
  for `ListTasks` filtering.
- Each `SendMessage` creates a new A2aTask (or resumes one if input-required),
  dispatches to the Session in a thread, updates status as the loop progresses.
- TTL cleanup: sessions and tasks expire after configurable idle time (default 1h).

### 2.3 New module: `swival/a2a_server.py`

```
class A2aServer:
    __init__(session_kwargs: dict, host: str, port: int)
    serve()  # blocking, runs uvicorn/starlette
```

**Endpoints (JSON-RPC binding):**

| Method | Handler |
|--------|---------|
| `SendMessage` | Create/resume task, call `session.ask(question)`, return result |
| `SendStreamingMessage` | SSE stream of status updates + artifacts during `ask()` |
| `GetTask` | Look up task by ID from in-memory store |
| `ListTasks` | Filter/paginate stored tasks |
| `CancelTask` | Set cancellation flag checked by agent loop |

All method names are unnamespaced per A2A v1.0. Constants imported from
`a2a_types.py` (see 1.2). `GetExtendedAgentCard` is deferred to Phase 5
(the capability is advertised as `false` in the baseline Agent Card).

### 2.4 Agent Card generation

Auto-generate from Session configuration. Wire format uses camelCase per the
A2A v1.0 spec:

```python
def build_agent_card(session_kwargs, host, port) -> dict:
    # version is the *agent's* version, not the protocol version.
    # protocolVersion in supportedInterfaces carries the A2A spec version.
    agent_version = session_kwargs.get("agent_version", "0.1.0")
    return {
        "name": session_kwargs.get("agent_name", "swival"),
        "description": "Swival coding agent",
        "version": agent_version,
        "supportedInterfaces": [{
            "url": f"https://{host}:{port}",
            "protocolBinding": "JSONRPC",
            "protocolVersion": A2A_VERSION
        }],
        "capabilities": {
            "streaming": True,
            "pushNotifications": False,
            "extendedAgentCard": False
        },
        "skills": _build_skills_from_tools(session_kwargs),
        "defaultInputModes": ["text/plain"],
        "defaultOutputModes": ["text/plain"],
        # securitySchemes defines available auth mechanisms;
        # securityRequirements declares which are actually required.
        "securitySchemes": {
            "bearerAuth": {
                "type": "http",
                "scheme": "bearer"
            }
        },
        "securityRequirements": [
            {"bearerAuth": []}
        ],
    }
```

Serve at `/.well-known/agent-card.json` (RFC 8615).

### 2.5 CLI entry point

```
swival serve --port 8080 --host 0.0.0.0
swival serve --agent-name "my-coder" --auth bearer --auth-token sk-...
```

Internally: load config, build Session kwargs, pass to `A2aServer`, call `serve()`.

### 2.6 Streaming implementation

When `SendStreamingMessage` is called:
- Return SSE stream immediately
- Hook into the agent loop via an `event_callback` parameter on
  `run_agent_loop()` (no-op by default). The callback receives typed events:
  - `text_chunk`: incremental assistant text as it is generated
  - `tool_start` / `tool_finish` / `tool_error`: tool call lifecycle events
  - `status_update`: intermediate progress (e.g. "reading files", "running tests")
- Map events to A2A SSE frames:
  - `TaskStatusUpdateEvent` on state transitions (working, completed, failed)
    and periodic heartbeats during idle periods (configurable interval, e.g. 15s)
  - `TaskArtifactUpdateEvent` for incremental text chunks and the final answer
  - `TaskStatusUpdateEvent` with structured metadata for tool lifecycle and
    progress signaling (current tool name, turn count, elapsed time)
- Use a callback/queue pattern: agent loop pushes events, SSE handler consumes

### 2.7 Dependencies

- `starlette` + `uvicorn` for the HTTP server (lightweight, async)
- These are optional dependencies: `uv add --optional serve starlette uvicorn`

---

## Part 3: Implementation order

### Phase 1: Client MVP
1. `a2a_types.py`: dataclasses for AgentCard, Message, Part, Task, TaskStatus,
   Artifact, SendMessageRequest, SendMessageConfiguration (camelCase serialization).
   Centralize all JSON-RPC method name constants and REST paths here.
2. `a2a_client.py`: `A2aManager` with `start()`, `list_tools()`, `call_tool()`, `close()`
3. Generic message-oriented tool shape with `{message, context_id, task_id}` params
4. `call_tool()` uses blocking `SendMessage` (`returnImmediately=false`, the
   spec default) as the primary path; `GetTask` polling as fallback only
5. `input-required` handling: return contextId/taskId in tool result text; accept
   both back via tool parameters on resumption
6. Add `a2a__` branch in `compact_tool_result()` preserving input-required IDs
7. Wire into `dispatch()`, `session.py`, `config.py`
8. CLI bootstrap: stash `a2a_servers` from TOML alongside MCP stash,
   add `--a2a-config` and `--no-a2a` flags, merge and create manager in the
   same block where MCP manager is created
9. Tests (mock HTTP server returning canned A2A JSON-RPC responses):
   - Happy path: blocking `SendMessage` returns completed task in one round-trip
   - Non-compliant server: `SendMessage` returns non-terminal state despite
     `returnImmediately=false`; verify `GetTask` polling fallback activates
   - `input-required` round-trip: first call returns input-required with IDs,
     second call passes contextId + taskId back, receives completed task
   - Compaction preservation: `compact_tool_result()` on an `a2a__` tool result
     containing `[input-required]` retains contextId/taskId in the summary
   - Compaction loss: aggressive compaction drops an input-required turn entirely;
     verify the manager does not crash and the LLM can start fresh
   - Per-agent degradation: repeated failures mark agent as degraded
   - Size guard: large agent response triggers overflow-to-file
10. **Deliverable**: Swival can call a remote A2A agent as a tool, including
    multi-turn via contextId passthrough and input-required resumption via taskId

### Phase 2: Client streaming
1. Add SSE streaming support in `call_tool()` when remote agent advertises
   `capabilities.streaming: true` in its card
2. Parse `TaskStatusUpdateEvent` and `TaskArtifactUpdateEvent` from SSE stream
3. Return partial status updates to stderr (via `fmt`) for user visibility
4. Tests: SSE stream parsing, timeout handling

### Phase 3: Server MVP
1. Design and validate the contextId-keyed Session model (one Session per
   contextId, `ask()`-only, TTL expiry)
2. `a2a_server.py`: `A2aServer` with `SendMessage`, `GetTask`, `ListTasks`
3. Agent Card generation with camelCase wire format
4. Serve card at `/.well-known/agent-card.json`
5. Add `swival serve` CLI subcommand
6. Tests:
   - Single-shot: `SendMessage` creates session, returns completed task
   - Multi-turn: two `SendMessage` calls with same contextId share session state
   - `input-required` resumption: task stays non-terminal, follow-up with taskId
     resumes correctly
   - TTL expiry: idle session is cleaned up, follow-up with expired contextId
     gets a fresh session (not a crash)
   - Concurrent contexts: multiple contextIds run independent sessions
7. **Deliverable**: Other agents can call Swival via A2A with conversation context

### Phase 4: Server streaming + hardening
1. Add `event_callback` to `run_agent_loop()` with typed event kinds:
   - `text_chunk`: incremental assistant text
   - `tool_start` / `tool_finish` / `tool_error`: tool call lifecycle
   - `status_update`: intermediate progress (e.g. "reading files", "running tests")
2. Implement `SendStreamingMessage` with SSE, emitting:
   - `TaskStatusUpdateEvent` on state transitions
   - `TaskArtifactUpdateEvent` for incremental text and final answer
3. Add heartbeat / keepalive events: periodic `TaskStatusUpdateEvent` during
   long-running turns with no visible output (configurable interval, e.g. 15s)
   so hubs and UIs can distinguish "still working" from "stalled"
4. Progress signaling: emit structured progress metadata (current tool name,
   turn count, elapsed time) on status updates so integrators can present
   meaningful progress indicators
5. Implement `CancelTask` with cancellation flag
6. Add auth scheme support (bearer done; API key pending)
7. Task/session store with TTL cleanup (done)
8. Rate limiting, request validation
9. Tests:
   - SSE stream delivers incremental text chunks in order
   - Tool lifecycle events (start/finish/error) are emitted during tool-heavy tasks
   - Heartbeat events arrive during idle periods with no other output
   - `CancelTask` interrupts an in-progress task
   - Long-running task with no tool calls still emits periodic heartbeats

Ref: https://github.com/Swival/swival/issues/5

### Phase 5: Advanced features
1. Push notification support (webhook registration + delivery)
2. Extended Agent Card with auth-gated capabilities
3. Agent Card signatures (JWS)
4. gRPC binding (if there's demand)

---

## Part 4: Design decisions

### Why mirror the MCP pattern?

MCP integration is battle-tested in Swival. The `McpManager` pattern (manager
class + namespaced tools + dispatch routing) is clean and well-understood. A2A
client follows the same shape, so contributors familiar with MCP immediately
understand A2A too.

### Why not merge A2A into MCP?

They solve different problems. MCP connects agents to tools/data sources. A2A
connects agents to other agents. The interaction patterns differ:
- MCP: stateless function calls
- A2A: stateful tasks with lifecycle, multi-turn, streaming

Keeping them separate avoids confusing abstractions.

### Why generic tools instead of typed per-skill schemas?

A2A skills advertise capability metadata (name, description, inputModes,
outputModes, examples) but not a machine-readable JSON argument schema. There is
no reliable way to synthesize typed OpenAI function parameters from this metadata.
A generic `{message, context_id, task_id}` tool shape is honest about what the
protocol provides. The skill description and examples in the tool description give
the LLM enough context to compose useful messages.

If a future A2A extension adds parameter schemas to skills, we can generate typed
tool schemas at that point.

### Why `ask()` not `run()` on the server?

`Session.run()` (session.py:313) creates fresh per-run state on every call -- new
messages list, new thinking/todo/snapshot/compaction state. It is explicitly
single-shot.

`Session.ask()` (session.py:363) preserves `_conv_state` across calls, which is
the only way to maintain context across A2A multi-turn interactions within a
contextId. The server must use `ask()` for any A2A context that involves follow-up
messages, refinements, or input-required resumptions.

### Why contextId-keyed sessions instead of task ID caching?

A2A task lifecycle is strict: terminal tasks stay terminal. You cannot reopen a
completed or failed task. Conversation continuity works through `contextId` --
the client sends a new message with the same contextId, and the server creates
a new Task under that context. Caching task IDs and trying to reuse them would
violate the protocol and break when tasks reach terminal states.

On the client side, we don't cache anything. The LLM receives contextId values
in tool results and passes them back when it wants to continue a conversation.
This is stateless in the manager and degrades gracefully under compaction (if the
LLM loses a contextId, it simply starts a new conversation -- except for
input-required tasks, which get special compaction treatment; see 1.4).

### Wire protocol method names

The A2A v1.0 JSON-RPC binding uses unnamespaced method names: `SendMessage`,
`GetTask`, `CancelTask`, etc. This was verified against both the spec overview
page and the Python SDK's request type naming. There is no `A2A.` prefix.

All constants are centralized in `a2a_types.py` so that if the spec changes
method naming (or if we add HTTP/REST binding support), only one file changes.

### Tool naming

`a2a__<agent-name>__<skill-id>` mirrors `mcp__<server>__<tool>`. The dispatch
router uses prefix matching, so no collision is possible. If a remote agent has
no skills declared, expose a single `a2a__<agent-name>__ask` tool.

### Sync vs async

The A2A client needs HTTP calls. Same solution as MCP: run an asyncio event loop
in a background thread, expose sync methods via `run_coroutine_threadsafe()`.

### Server threading model

`A2aServer` uses async (starlette/uvicorn) for HTTP handling. Each `SendMessage`
dispatches `session.ask()` in a thread pool. The Session objects are not
thread-safe, so each contextId's Session is accessed sequentially (one request
at a time per contextId, enforced by per-context asyncio locks).

---

## Part 5: File layout

```
swival/
  a2a_client.py      # A2aManager (client side)
  a2a_server.py      # A2aServer + Agent Card generation (server side)
  a2a_types.py       # Dataclasses, wire constants, camelCase serialization
tests/
  test_a2a_client.py  # Client tests with mock HTTP
  test_a2a_server.py  # Server tests (including contextId continuity)
  test_a2a_types.py   # Serialization/deserialization tests
```

---

## Part 6: Open questions

1. **Discovery**: Should Swival auto-discover A2A agents from a registry, or only
   use explicitly configured ones? Start with explicit config, consider registry
   later.

2. **Sandboxing**: When Swival acts as an A2A server, should it inherit the same
   sandbox restrictions? Yes -- the Session kwargs control this regardless of
   entry point.

3. **Token budget**: A2A tools should participate in the same token budget
   enforcement as MCP tools. Large agent responses get the same size-guard
   treatment.

4. **Session limits on server**: How many concurrent contextId sessions should
   the server allow? Need a configurable cap with LRU eviction. The TTL handles
   idle cleanup, but a hard cap prevents memory exhaustion under load.

5. **Auth storage**: Where do A2A auth tokens live? Config file + environment
   variables (like MCP). Add keyring later if needed.

---

## Revision log

- v6: Addressed sixth round of review feedback:
  - Removed `GetExtendedAgentCard` from the server endpoint table (Phase 3/4),
    since the Agent Card advertises `extendedAgentCard: false` and the feature
    is deferred to Phase 5. Added explicit note that it's deferred.

- v5: Addressed fifth round of review feedback:
  - Split agent version from protocol version: `AgentCard.version` is now the
    agent's own version (e.g. "0.1.0"), while `A2A_VERSION` is only used for
    `supportedInterfaces[*].protocolVersion`
  - Added `securityRequirements` alongside `securitySchemes` in the Agent Card
    example, since schemes alone only define available mechanisms without
    declaring which are actually required
  - Normalized config key to `card_url` (snake_case) everywhere, matching
    Swival's config convention. Was `cardUrl` (camelCase) in the lifecycle section

- v4: Addressed fourth round of review feedback:
  - Fixed compaction function name: `compact_tool_result()` (agent.py:370), not
    the nonexistent `_compact_tool_content()`. Updated all references including
    the integration points table.
  - Fixed REST paths to match v1.0 proto definition: `/message:send` (not
    `/messages`), `/message:stream` (not `/messages:stream`), `/tasks/{id=*}`
    (not `/tasks/{taskId}`), `/extendedAgentCard`. Added multi-tenant note.
  - Changed client flow from polling-first to blocking-first: `SendMessage` with
    `returnImmediately=false` (the spec default) blocks until terminal/interrupted
    state, so a single round-trip is the normal case. `GetTask` polling is now
    explicitly the fallback for servers that ignore the flag or when
    `returnImmediately=true` is explicitly set.

- v3: Addressed third round of review feedback:
  - Added `task_id` parameter to tool schema so the LLM can actually pass back a
    non-terminal taskId for input-required resumption (was missing from schema,
    making the continuation path unimplementable)
  - Replaced hard-coded `A2A.SendMessage` / `A2A.GetTask` method names with
    unnamespaced `SendMessage` / `GetTask` per the v1.0 spec and Python SDK;
    centralized all method name constants in `a2a_types.py`
  - Added compaction section (1.4) with dedicated `a2a__` branch in
    `compact_tool_result()` that preserves contextId/taskId for input-required
    results; acknowledged orphaned-task leakage as explicit tradeoff under
    aggressive compaction

- v2: Rewrote based on review feedback. Key changes:
  - Server uses `session.ask()` instead of `session.run()`, with one Session per
    contextId, to preserve conversation state across A2A multi-turn interactions
  - Replaced task ID caching with contextId-based conversation model that matches
    the A2A task lifecycle (terminal tasks stay terminal, continuity via contextId)
  - Changed tool shape from typed per-skill schemas to generic message-oriented
    tools, since A2A skills carry metadata but not argument schemas
  - Fixed discovery path to `/.well-known/agent-card.json` (RFC 8615) and all
    wire format examples to camelCase per A2A v1.0 spec
  - Added CLI bootstrap section documenting the TOML stash-and-merge pattern
    needed to match how MCP config is handled in agent.py:main()
