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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -75,12 +75,37 @@ Default local address: `http://127.0.0.1:8000`
- A2A HTTP+JSON endpoints such as `/v1/message:send` and
`/v1/message:stream`
- A2A JSON-RPC support on `POST /`
- Peering capabilities: can act as a client via `opencode-a2a call`
- Autonomous tool execution: supports `a2a_call` tool for outbound agent-to-agent communication
- SSE streaming with normalized `text`, `reasoning`, and `tool_call` blocks
- Explicit REST SSE keepalive configurable through `A2A_STREAM_SSE_PING_SECONDS`
- Session continuity through `metadata.shared.session.id`
- Request-scoped model selection through `metadata.shared.model`
- OpenCode-oriented JSON-RPC extensions for session and model/provider queries

## Peering Node

`opencode-a2a` supports a "Peering Node" architecture where a single process handles both inbound (Server) and outbound (Client) A2A traffic.

### CLI Client
Interact with other A2A agents directly from the command line:

```bash
opencode-a2a call http://other-agent:8000 "How are you?" --token your-outbound-token
```

### Outbound Agent Calls (Tools)
The server can autonomously execute `a2a_call(url, message)` tool calls emitted by the OpenCode runtime. Results are fetched via A2A and returned to the model as tool results, enabling multi-agent orchestration.

When the target peer requires bearer auth, configure `A2A_CLIENT_BEARER_TOKEN`
for server-side outbound calls. CLI calls can continue using `--token` or
`A2A_CLIENT_BEARER_TOKEN`.

Server-side outbound client settings are fully wired through runtime config:
`A2A_CLIENT_TIMEOUT_SECONDS`, `A2A_CLIENT_CARD_FETCH_TIMEOUT_SECONDS`,
`A2A_CLIENT_USE_CLIENT_PREFERENCE`, `A2A_CLIENT_BEARER_TOKEN`, and
`A2A_CLIENT_SUPPORTED_TRANSPORTS`.

Detailed protocol contracts, examples, and extension docs live in
[`docs/guide.md`](docs/guide.md).

Expand All @@ -107,6 +132,8 @@ This repository improves the service boundary around OpenCode, but it does not
turn OpenCode into a hardened multi-tenant platform.

- `A2A_BEARER_TOKEN` protects the A2A surface.
- `A2A_CLIENT_BEARER_TOKEN` is used for outbound peer calls initiated by the
server-side `a2a_call` tool.
- Provider auth and default model configuration remain on the OpenCode side.
- Deployment supervision is intentionally BYO. Use `systemd`, Docker,
Kubernetes, or another supervisor if you need long-running operation.
Expand Down
40 changes: 40 additions & 0 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -69,7 +69,47 @@ Key variables to understand protocol behavior:
`session.abort` in cancel flow.
- `OPENCODE_TIMEOUT` / `OPENCODE_TIMEOUT_STREAM`: upstream request timeout and
optional stream timeout override.
- `A2A_CLIENT_TIMEOUT_SECONDS`: outbound client timeout. Default: `30` seconds.
- `A2A_CLIENT_CARD_FETCH_TIMEOUT_SECONDS`: outbound Agent Card fetch timeout.
Default: `5` seconds.
- `A2A_CLIENT_USE_CLIENT_PREFERENCE`: whether the outbound client prefers its own transport choices.
- `A2A_CLIENT_BEARER_TOKEN`: optional bearer token attached to outbound peer
calls made by the embedded A2A client and `a2a_call` tool path.
- `A2A_CLIENT_SUPPORTED_TRANSPORTS`: ordered outbound transport preference list.
- Runtime authentication is bearer-token only via `A2A_BEARER_TOKEN`.
- The same outbound client flags are also honored by the server-side embedded
A2A client used for peer calls and `a2a_call` tool execution:
- `A2A_CLIENT_TIMEOUT_SECONDS`
- `A2A_CLIENT_CARD_FETCH_TIMEOUT_SECONDS`
- `A2A_CLIENT_USE_CLIENT_PREFERENCE`
- `A2A_CLIENT_BEARER_TOKEN`
- `A2A_CLIENT_SUPPORTED_TRANSPORTS`

## Client Initialization Facade (Preview)

`opencode-a2a` now includes a minimal client bootstrap module in
`src/opencode_a2a/client/` to support downstream consumer usage while keeping
server and client concerns separate.

Boundary separation:

- Server code owns runtime request handling, transport orchestration, stream
behavior, and public compatibility profile exposure.
- Client code owns peer card discovery, SDK client construction, operation call
helpers, and protocol error normalization.

Current client facade API:

- `A2AClient.get_agent_card()`
- `A2AClient.send()` / `A2AClient.send_message()`
- `A2AClient.get_task()`
- `A2AClient.cancel_task()`
- `A2AClient.resubscribe_task()`

Server-side outbound peer calls use bearer auth only for now. Configure
`A2A_CLIENT_BEARER_TOKEN` when the remote agent protects its runtime surface.
CLI outbound calls may pass `--token` explicitly or use
`A2A_CLIENT_BEARER_TOKEN`.

Execution-boundary metadata is intentionally declarative deployment metadata:
it is published through `RuntimeProfile`, Agent Card, OpenAPI, and `/health`,
Expand Down
64 changes: 63 additions & 1 deletion src/opencode_a2a/cli.py
Original file line number Diff line number Diff line change
@@ -1,13 +1,54 @@
from __future__ import annotations

import argparse
import asyncio
import os
import sys
from collections.abc import Sequence

from . import __version__
from .server.application import main as serve_main


async def run_call(agent_url: str, text: str, token: str | None = None) -> int:
from a2a.types import Message, TaskArtifactUpdateEvent, TaskStatusUpdateEvent

from .client import A2AClient

client = A2AClient(agent_url)
metadata = {}
if token:
# Use Authorization header for bearer auth.
metadata["authorization"] = f"Bearer {token}"

try:
async for event in client.send_message(text, metadata=metadata):
if isinstance(event, tuple):
_, update = event
if isinstance(update, TaskArtifactUpdateEvent):
artifact = update.artifact
if artifact and artifact.parts:
for part in artifact.parts:
text_val = getattr(part.root, "text", None)
if isinstance(text_val, str):
print(text_val, end="", flush=True)
elif isinstance(update, TaskStatusUpdateEvent):
if update.status and update.status.state == "failed":
print(f"\n[Failed] {update.status.message or ''}")
elif isinstance(event, Message):
for part in event.parts:
text_val = getattr(part.root, "text", None)
if isinstance(text_val, str):
print(text_val, end="", flush=True)
print() # New line after completion
except Exception as exc:
print(f"\n[Error] {exc}", file=sys.stderr)
return 1
finally:
await client.close()
return 0


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
prog="opencode-a2a",
Expand All @@ -28,6 +69,20 @@ def build_parser() -> argparse.ArgumentParser:
help="Start the OpenCode A2A runtime using environment-based settings.",
description="Start the OpenCode A2A runtime using environment-based settings.",
)

call_parser = subparsers.add_parser(
"call",
help="Call an A2A agent.",
description="Call an A2A agent using the A2A protocol.",
)
call_parser.add_argument("agent_url", help="URL of the agent to call.")
call_parser.add_argument("text", help="Text message to send.")
call_parser.add_argument(
"--token",
help="Bearer token for authentication (can also use A2A_CLIENT_BEARER_TOKEN env).",
default=os.environ.get("A2A_CLIENT_BEARER_TOKEN"),
)

return parser


Expand All @@ -40,7 +95,14 @@ def main(argv: Sequence[str] | None = None) -> int:
return 0

namespace = parser.parse_args(args)
if namespace.command in {None, "serve"}:
if namespace.command == "serve":
serve_main()
return 0

if namespace.command == "call":
return asyncio.run(run_call(namespace.agent_url, namespace.text, namespace.token))

if namespace.command is None:
serve_main()
return 0

Expand Down
28 changes: 28 additions & 0 deletions src/opencode_a2a/client/__init__.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,28 @@
"""Reusable A2A client utilities and facade types."""

from .client import A2AClient
from .config import A2AClientSettings, load_settings
from .errors import (
A2AAgentUnavailableError,
A2AClientError,
A2AClientResetRequiredError,
A2APeerProtocolError,
A2AUnsupportedBindingError,
A2AUnsupportedOperationError,
)
from .types import A2AClientEvent, A2AClientEventStream, A2AClientMetadata

__all__ = [
"A2AClient",
"A2AClientError",
"A2AAgentUnavailableError",
"A2AClientResetRequiredError",
"A2APeerProtocolError",
"A2AUnsupportedBindingError",
"A2AUnsupportedOperationError",
"A2AClientSettings",
"A2AClientEvent",
"A2AClientEventStream",
"A2AClientMetadata",
"load_settings",
]
Loading
Loading