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
15 changes: 12 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,5 +1,14 @@
# opencode-a2a

```text
___ ____ _ _ ____ _
/ _ \ _ __ ___ _ __ / ___|___ __| | ___ / \ |___ \ / \
| | | | '_ \ / _ \ '_ \| | / _ \ / _` |/ _ \_____ / _ \ __) | / _ \
| |_| | |_) | __/ | | | |__| (_) | (_| | __/_____/ ___ \ / __/ / ___ \
\___/| .__/ \___|_| |_|\____\___/ \__,_|\___| /_/ \_\_____/_/ \_\
|_|
```

> Expose OpenCode through A2A.

`opencode-a2a` adds an A2A runtime layer to `opencode serve`, with auth, streaming, session continuity, interrupt handling, and a clear deployment boundary.
Expand Down Expand Up @@ -71,7 +80,7 @@ A2A_HOST=127.0.0.1 \
A2A_PORT=8000 \
A2A_PUBLIC_URL=http://127.0.0.1:8000 \
OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \
opencode-a2a
opencode-a2a serve
```

Verify that the service is up:
Expand Down Expand Up @@ -109,12 +118,12 @@ Interact with other A2A agents directly from the command line:
```bash
# Using the target peer agent's Bearer token via environment injection
A2A_CLIENT_BEARER_TOKEN=your-outbound-token \
opencode-a2a call http://other-agent:8000 "How are you?"
opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?"

# Using the target peer agent's Basic auth via environment injection
# Accepts raw user:pass or its base64-encoded value
A2A_CLIENT_BASIC_AUTH="user:pass" \
opencode-a2a call http://other-agent:8000 "How are you?"
opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?"
```

### Outbound Agent Calls (Tools)
Expand Down
15 changes: 12 additions & 3 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -99,6 +99,15 @@ Current client facade API:

Server-side outbound peer calls read outbound credentials from environment variables. Configure `A2A_CLIENT_BEARER_TOKEN` or `A2A_CLIENT_BASIC_AUTH` when the remote agent protects its runtime surface. CLI outbound calls follow the same environment-only model.

CLI outbound example:

```bash
A2A_CLIENT_BEARER_TOKEN=peer-token \
opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?"
```

Service base URLs also work, but this guide prefers Agent Card URLs in CLI examples because they make the A2A discovery target explicit.

`A2AClient.send()` returns the latest response event and keeps the default stream-first behavior. If a peer returns a non-terminal task snapshot and expects follow-up `GetTask` polling, enable the optional facade fallback with:

- `A2A_CLIENT_POLLING_FALLBACK_ENABLED=true`
Expand Down Expand Up @@ -144,7 +153,7 @@ A2A_HOST=127.0.0.1 \
A2A_PORT=8000 \
A2A_PUBLIC_URL=http://127.0.0.1:8000 \
OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \
opencode-a2a
opencode-a2a serve
```

By default, the service uses a SQLite-backed durable state store:
Expand All @@ -154,7 +163,7 @@ DEMO_BEARER_TOKEN="$(python3 -c 'import secrets; print(secrets.token_hex(24))')"
OPENCODE_BASE_URL=http://127.0.0.1:4096 \
A2A_STATIC_AUTH_CREDENTIALS='[{"scheme":"bearer","token":"'"${DEMO_BEARER_TOKEN}"'","principal":"automation"}]' \
A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:///./opencode-a2a.db \
opencode-a2a
opencode-a2a serve
```

With the default `database` backend, the unified lightweight persistence layer persists:
Expand All @@ -166,7 +175,7 @@ With the default `database` backend, the unified lightweight persistence layer p

This project is SQLite-first for local single-instance deployments. The runtime configures local durability-oriented SQLite connection settings (`WAL`, `busy_timeout`, `synchronous=NORMAL`) and creates missing parent directories for file-backed database paths.

The runtime automatically applies lightweight schema migrations for its custom state tables and records the applied version in `a2a_schema_version`. Schema-version writes are idempotent across concurrent first-start races, pending preferred-session claims now persist absolute `expires_at` timestamps while remaining backward-compatible with legacy `updated_at` rows, and the built-in path currently targets the local SQLite deployment profile without requiring Alembic.
The runtime automatically applies lightweight schema migrations for its custom state tables and records the applied version in `a2a_schema_version`. Schema-version writes are idempotent across concurrent first-start races, pending preferred-session claims now persist absolute `expires_at` timestamps, legacy rows without `expires_at` are pruned during migration instead of being reconstructed from historical TTL assumptions, and the built-in path currently targets the local SQLite deployment profile without requiring Alembic.

Database-backed task persistence also keeps the existing first-terminal-state-wins contract while tightening the SQLite path with an atomic terminal-write guard instead of relying only on process-local read-before-write checks. Any wider SQLAlchemy dialect compatibility should be treated as incidental implementation latitude rather than a documented deployment target.

Expand Down
2 changes: 1 addition & 1 deletion scripts/smoke_test_built_cli.sh
Original file line number Diff line number Diff line change
Expand Up @@ -104,7 +104,7 @@ bearer_token="smoke-test-token"
A2A_STATIC_AUTH_CREDENTIALS="[{\"scheme\":\"bearer\",\"token\":\"${bearer_token}\",\"principal\":\"automation\"}]" \
A2A_PORT="${port}" \
A2A_HOST="127.0.0.1" \
"${tool_bin_dir}/opencode-a2a" >"${server_log}" 2>&1 &
"${tool_bin_dir}/opencode-a2a" serve >"${server_log}" 2>&1 &
server_pid="$!"

health_url="http://127.0.0.1:${port}/health"
Expand Down
211 changes: 202 additions & 9 deletions src/opencode_a2a/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -7,11 +7,166 @@
from collections.abc import Sequence

from a2a.types import TaskState
from pydantic import ValidationError

from . import __version__
from .client import A2AClient, load_settings
from .config import Settings
from .server.application import main as serve_main

CLI_BRAND_BANNER = (
" ___ ____ _ _ ____ _ \n"
" / _ \\ _ __ ___ _ __ / ___|___ __| | ___ / \\ |___ \\ / \\ \n"
"| | | | '_ \\ / _ \\ '_ \\| | / _ \\ / _` |/ _ \\_____ / _ \\ __) | / _ \\ \n"
"| |_| | |_) | __/ | | | |__| (_) | (_| | __/_____/ ___ \\ / __/ / ___ \\ \n"
" \\___/| .__/ \\___|_| |_|\\____\\___/ \\__,_|\\___| /_/ \\_\\_____/_/ \\_\\\n"
" |_| "
)
PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a"
HELP_FLAGS = frozenset({"-h", "--help"})

ROOT_DESCRIPTION = (
"OpenCode A2A runtime for explicit service startup and peer calls. "
"A2A Protocol 1.0 only.\n"
" opencode-a2a <command> [arguments] [options]"
)

OPENCODE_SETUP_HELP = (
"OpenCode upstream quick start:\n"
" opencode auth login\n"
" opencode models\n"
" opencode serve --hostname 127.0.0.1 --port 4096\n"
"\n"
"OpenCode note:\n"
" Configure provider auth and a default model before starting opencode serve.\n"
" If provider auth comes from environment variables, export them before launch."
)

SERVE_ENVIRONMENT_HELP = (
"Serve required environment:\n"
" A2A_STATIC_AUTH_CREDENTIALS\n"
" JSON array with at least one enabled bearer/basic credential.\n"
"\n"
"Serve common environment:\n"
" OPENCODE_BASE_URL\n"
" Upstream opencode serve URL. Default: http://127.0.0.1:4096\n"
" A2A_HOST\n"
" Bind host. Default: 127.0.0.1\n"
" A2A_PORT\n"
" Bind port. Default: 8000\n"
" A2A_PUBLIC_URL\n"
" Public base URL advertised in the agent card. Default: http://127.0.0.1:8000\n"
" A2A_TASK_STORE_BACKEND\n"
" database or memory. Default: database\n"
" A2A_TASK_STORE_DATABASE_URL\n"
" SQLAlchemy database URL. Default: sqlite+aiosqlite:///./opencode-a2a.db\n"
" OPENCODE_WORKSPACE_ROOT\n"
" Workspace root exposed to OpenCode tool execution.\n"
"\n"
"Serve minimal example:\n"
" DEMO_BEARER_TOKEN=\"$(python3 -c 'import secrets; print(secrets.token_hex(24))')\"\n"
" A2A_STATIC_AUTH_CREDENTIALS="
'\'[{"scheme":"bearer","token":"\'"${DEMO_BEARER_TOKEN}"\'","principal":"automation"}]\''
" \\\n"
" OPENCODE_BASE_URL=http://127.0.0.1:4096 \\\n"
" opencode-a2a serve\n"
"\n"
"Serve durable SQLite example:\n"
" DEMO_BEARER_TOKEN=\"$(python3 -c 'import secrets; print(secrets.token_hex(24))')\"\n"
" A2A_STATIC_AUTH_CREDENTIALS="
'\'[{"scheme":"bearer","token":"\'"${DEMO_BEARER_TOKEN}"\'","principal":"automation"}]\''
" \\\n"
" OPENCODE_BASE_URL=http://127.0.0.1:4096 \\\n"
" A2A_TASK_STORE_DATABASE_URL=sqlite+aiosqlite:///./opencode-a2a.db \\\n"
" OPENCODE_WORKSPACE_ROOT=/abs/path/to/workspace \\\n"
" opencode-a2a serve"
)

CALL_HELP = (
"Call examples:\n"
" A2A_CLIENT_BEARER_TOKEN=peer-token \\\n"
' opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?"\n'
"\n"
' A2A_CLIENT_BASIC_AUTH="user:pass" \\\n'
' opencode-a2a call http://other-agent:8000/.well-known/agent-card.json "How are you?"\n'
"\n"
"Call note:\n"
" Outbound peer credentials are read from environment variables only.\n"
" Service base URLs also work, but card URLs are the preferred example form."
)

ROOT_HELP_EPILOG = f"{OPENCODE_SETUP_HELP}\n\n{SERVE_ENVIRONMENT_HELP}\n\n{CALL_HELP}"
SERVE_HELP_EPILOG = f"{OPENCODE_SETUP_HELP}\n\n{SERVE_ENVIRONMENT_HELP}"
CALL_HELP_EPILOG = CALL_HELP


class RootHelpFormatter(
argparse.RawDescriptionHelpFormatter,
argparse.ArgumentDefaultsHelpFormatter,
):
"""Preserve banner formatting while keeping argparse defaults."""


class TopLevelArgumentParser(argparse.ArgumentParser):
"""Drop the generated usage line from the top-level help output only."""

def format_help(self) -> str:
help_text = super().format_help()
lines = help_text.splitlines(keepends=True)
if lines and lines[0].startswith("usage:"):
help_text = "".join(lines[1:]).lstrip("\n")
return help_text.replace("\ncommands:\n command\n", "\ncommands:\n", 1)


def _find_subparser(
parser: argparse.ArgumentParser,
name: str,
) -> argparse.ArgumentParser | None:
for action in parser._actions:
if isinstance(action, argparse._SubParsersAction):
return action.choices.get(name)
return None


def _format_settings_errors(exc: ValidationError) -> list[str]:
errors: list[str] = []
for error in exc.errors(include_url=False):
message = str(error.get("msg", "Invalid configuration"))
if message.startswith("Value error, "):
message = message.removeprefix("Value error, ")
location = ".".join(str(part) for part in error.get("loc", ()) if str(part) != "__root__")
errors.append(f"{location}: {message}" if location else message)
return errors or [str(exc)]


def validate_serve_configuration() -> list[str]:
try:
Settings()
except ValidationError as exc:
return _format_settings_errors(exc)
return []


def print_help_with_details(
parser: argparse.ArgumentParser,
*,
errors: Sequence[str] = (),
) -> None:
parser.print_help()
if errors:
print("\nconfiguration errors:")
for error in errors:
print(f" - {error}")


def should_show_call_help(args: Sequence[str]) -> bool:
if not args or args[0] != "call":
return False
if any(token in HELP_FLAGS for token in args[1:]):
return False
positional_count = sum(1 for token in args[1:] if not token.startswith("-"))
return positional_count < 2


async def run_call(agent_url: str, text: str) -> int:
settings = load_settings(os.environ)
Expand Down Expand Up @@ -47,28 +202,52 @@ async def run_call(agent_url: str, text: str) -> int:


def build_parser() -> argparse.ArgumentParser:
parser = argparse.ArgumentParser(
parser = TopLevelArgumentParser(
prog="opencode-a2a",
description=(
"OpenCode A2A runtime. Run without a subcommand to start the service."
" Deployment supervision is intentionally left to the operator."
CLI_BRAND_BANNER
+ "\n\n"
+ f"repo: {PROJECT_REPOSITORY_URL}\n"
+ "uv tool install --upgrade opencode-a2a\n"
+ ROOT_DESCRIPTION
),
formatter_class=argparse.ArgumentDefaultsHelpFormatter,
formatter_class=RootHelpFormatter,
epilog=ROOT_HELP_EPILOG,
)
parser.add_argument(
"-v",
"--version",
action="version",
version=f"%(prog)s {__version__}",
help="show program's version number and exit",
)

subparsers = parser.add_subparsers(
dest="command",
title="commands",
metavar="command",
parser_class=argparse.ArgumentParser,
)

subparsers = parser.add_subparsers(dest="command")
subparsers.add_parser(
"serve",
help="Run the A2A service.",
description="Run the OpenCode A2A service. A2A Protocol 1.0 only.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=SERVE_HELP_EPILOG,
)

call_parser = subparsers.add_parser(
"call",
help="Call an A2A agent.",
description="Call an A2A agent using the A2A protocol.",
description="Call an A2A agent using the A2A protocol. A2A Protocol 1.0 only.",
formatter_class=argparse.RawDescriptionHelpFormatter,
epilog=CALL_HELP_EPILOG,
)
call_parser.add_argument(
"agent_url",
help="Agent card URL or service base URL of the agent to call.",
)
call_parser.add_argument("agent_url", help="URL of the agent to call.")
call_parser.add_argument("text", help="Text message to send.")

return parser
Expand All @@ -77,17 +256,31 @@ def build_parser() -> argparse.ArgumentParser:
def main(argv: Sequence[str] | None = None) -> int:
args = list(sys.argv[1:] if argv is None else argv)
parser = build_parser()
call_parser = _find_subparser(parser, "call")
serve_parser = _find_subparser(parser, "serve")

if not args:
serve_main()
print_help_with_details(parser)
return 0

if should_show_call_help(args) and call_parser is not None:
print_help_with_details(call_parser)
return 0

namespace = parser.parse_args(args)
if namespace.command == "serve":
configuration_errors = validate_serve_configuration()
if configuration_errors and serve_parser is not None:
print_help_with_details(serve_parser, errors=configuration_errors)
return 0
serve_main()
return 0

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

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

parser.error(f"Unknown command: {namespace.command}")
Expand Down
18 changes: 17 additions & 1 deletion src/opencode_a2a/server/migrations.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,7 +22,7 @@
from sqlalchemy.engine import Connection

STATE_STORE_SCHEMA_NAME = "state_store"
CURRENT_STATE_STORE_SCHEMA_VERSION = 4
CURRENT_STATE_STORE_SCHEMA_VERSION = 5

_SCHEMA_VERSION_METADATA = MetaData()

Expand Down Expand Up @@ -124,6 +124,18 @@ def _migration_4_add_interrupt_credential_id(
)


def _migration_5_drop_legacy_pending_claim_rows(
connection: Connection,
*,
pending_session_claims_table: Table,
) -> None:
connection.execute(
pending_session_claims_table.delete().where(
pending_session_claims_table.c.expires_at.is_(None)
)
)


def _read_schema_version(
connection: Connection,
*,
Expand Down Expand Up @@ -230,6 +242,10 @@ def migrate_state_store_schema(
conn,
interrupt_requests_table=interrupt_requests_table,
),
5: lambda conn: _migration_5_drop_legacy_pending_claim_rows(
conn,
pending_session_claims_table=pending_session_claims_table,
),
}
return _apply_schema_migrations(
connection,
Expand Down
Loading
Loading