Skip to content
Merged
2 changes: 2 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ The following rules apply to coding agent collaboration in this repository. Thes

- Follow the Git, Issue, and PR workflow defined in [CONTRIBUTING.md](CONTRIBUTING.md).
- Use `gh` CLI for all issue/PR operations (reading, writing, and comments). Do not edit through the web UI.
- Do not use the Codex GitHub connector for any repository operation. Use `gh` CLI exclusively for issue, PR, review, comment, and metadata workflows.
- If `gh` CLI cannot complete a required GitHub operation because of permissions, authentication, or environment limits, stop and ask the human to perform or unblock the operation. Do not fall back to the Codex GitHub connector.
- Create a new tracking issue for any development task that does not already have one.
- Link the issue explicitly in PR descriptions (e.g., `Closes #xx`).
- Keep status updates synchronized to the relevant issue/PR to avoid duplicate manual work.
Expand Down
12 changes: 6 additions & 6 deletions README.md
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
# opencode-a2a

```text
___ ____ _ _ ____ _
/ _ \ _ __ ___ _ __ / ___|___ __| | ___ / \ |___ \ / \
| | | | '_ \ / _ \ '_ \| | / _ \ / _` |/ _ \_____ / _ \ __) | / _ \
| |_| | |_) | __/ | | | |__| (_) | (_| | __/_____/ ___ \ / __/ / ___ \
\___/| .__/ \___|_| |_|\____\___/ \__,_|\___| /_/ \_\_____/_/ \_\
|_|
██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗ ██████╗ ██████╗ ███████╗ █████╗ ██████╗ █████╗
██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔═══██╗██╔══██╗██╔════╝ ██╔══██╗╚════██╗██╔══██╗
██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ██║██║ ██║█████╗█████╗███████║ █████╔╝███████║
██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██║██║ ██║██╔══╝╚════╝██╔══██║██╔═══╝ ██╔══██║
╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗╚██████╔╝██████╔╝███████╗ ██║ ██║███████╗██║ ██║
╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝ ╚═════╝ ╚══════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝
```

> Expose OpenCode through A2A.
Expand Down
4 changes: 0 additions & 4 deletions src/opencode_a2a/a2a_utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,10 +16,6 @@ def clone_proto(message: ProtoT) -> ProtoT:
return cloned


def proto_equals(left: ProtoMessage, right: ProtoMessage) -> bool:
return bool(left == right)


def _to_proto_value(value: Any) -> Value:
proto_value = Value()
if value is None:
Expand Down
22 changes: 14 additions & 8 deletions src/opencode_a2a/cli.py
Original file line number Diff line number Diff line change
Expand Up @@ -15,12 +15,18 @@
from .server.application import main as serve_main

CLI_BRAND_BANNER = (
" ___ ____ _ _ ____ _ \n"
" / _ \\ _ __ ___ _ __ / ___|___ __| | ___ / \\ |___ \\ / \\ \n"
"| | | | '_ \\ / _ \\ '_ \\| | / _ \\ / _` |/ _ \\_____ / _ \\ __) | / _ \\ \n"
"| |_| | |_) | __/ | | | |__| (_) | (_| | __/_____/ ___ \\ / __/ / ___ \\ \n"
" \\___/| .__/ \\___|_| |_|\\____\\___/ \\__,_|\\___| /_/ \\_\\_____/_/ \\_\\\n"
" |_| "
" ██████╗ ██████╗ ███████╗███╗ ██╗ ██████╗ ██████╗"
" ██████╗ ███████╗ █████╗ ██████╗ █████╗ \n"
"██╔═══██╗██╔══██╗██╔════╝████╗ ██║██╔════╝██╔═══██╗"
"██╔══██╗██╔════╝ ██╔══██╗╚════██╗██╔══██╗\n"
"██║ ██║██████╔╝█████╗ ██╔██╗ ██║██║ ██║ ██║"
"██║ ██║█████╗█████╗███████║ █████╔╝███████║\n"
"██║ ██║██╔═══╝ ██╔══╝ ██║╚██╗██║██║ ██║ ██║"
"██║ ██║██╔══╝╚════╝██╔══██║██╔═══╝ ██╔══██║\n"
"╚██████╔╝██║ ███████╗██║ ╚████║╚██████╗╚██████╔╝"
"██████╔╝███████╗ ██║ ██║███████╗██║ ██║\n"
" ╚═════╝ ╚═╝ ╚══════╝╚═╝ ╚═══╝ ╚═════╝ ╚═════╝"
" ╚═════╝ ╚══════╝ ╚═╝ ╚═╝╚══════╝╚═╝ ╚═╝"
)
PROJECT_REPOSITORY_URL = "https://github.com/Intelligent-Internet/opencode-a2a"
HELP_FLAGS = frozenset({"-h", "--help"})
Expand Down Expand Up @@ -95,7 +101,7 @@
" 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}"
ROOT_HELP_EPILOG = "Command-specific help:\n opencode-a2a serve --help\n opencode-a2a call --help"
SERVE_HELP_EPILOG = f"{OPENCODE_SETUP_HELP}\n\n{SERVE_ENVIRONMENT_HELP}"
CALL_HELP_EPILOG = CALL_HELP

Expand Down Expand Up @@ -224,7 +230,7 @@ def build_parser() -> argparse.ArgumentParser:
CLI_BRAND_BANNER
+ "\n\n"
+ f"repo: {PROJECT_REPOSITORY_URL}\n"
+ "uv tool install --upgrade opencode-a2a\n"
+ "uv tool install --upgrade opencode-a2a\n\n"
+ ROOT_DESCRIPTION
),
formatter_class=RootHelpFormatter,
Expand Down
27 changes: 14 additions & 13 deletions src/opencode_a2a/jsonrpc/handlers/interrupt_queries.py
Original file line number Diff line number Diff line change
Expand Up @@ -10,18 +10,6 @@
from .common import build_internal_error_response, build_success_response, reject_unknown_fields


def _binding_to_result_item(binding: Any) -> dict[str, Any]:
return {
"request_id": binding.request_id,
"session_id": binding.session_id,
"interrupt_type": binding.interrupt_type,
"task_id": binding.task_id,
"context_id": binding.context_id,
"details": dict(binding.details) if isinstance(binding.details, dict) else None,
"expires_at": binding.expires_at,
}


async def handle_interrupt_query_request(
context: ExtensionHandlerContext,
base_request: JSONRPCRequest,
Expand Down Expand Up @@ -58,5 +46,18 @@ async def handle_interrupt_query_request(
return build_success_response(
context,
base_request.id,
{"items": [_binding_to_result_item(item) for item in items]},
{
"items": [
{
"request_id": item.request_id,
"session_id": item.session_id,
"interrupt_type": item.interrupt_type,
"task_id": item.task_id,
"context_id": item.context_id,
"details": dict(item.details) if isinstance(item.details, dict) else None,
"expires_at": item.expires_at,
}
for item in items
]
},
)
55 changes: 17 additions & 38 deletions src/opencode_a2a/output_modes.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,30 +37,19 @@
_STREAM_METADATA_BLOCK_TYPE_KEY = "block_type"


def _accepted_output_modes_source(source: Any) -> Iterable[str] | None:
def normalize_accepted_output_modes(source: Any) -> tuple[str, ...] | None:
if source is None:
return None

accepted = getattr(source, "accepted_output_modes", None) or getattr(
source, "acceptedOutputModes", None
)
if accepted is not None:
source = accepted

if isinstance(source, str | bytes | bytearray | dict):
return None
if not isinstance(source, Iterable):
return None
return cast(Iterable[str], source)


def normalize_accepted_output_modes(source: Any) -> tuple[str, ...] | None:
accepted = _accepted_output_modes_source(source)
if accepted is None:
if isinstance(source, str | bytes | bytearray | dict) or not isinstance(source, Iterable):
return None

normalized: list[str] = []
for value in accepted:
for value in cast(Iterable[str], source):
if not isinstance(value, str):
continue
mode = value.strip().lower()
Expand Down Expand Up @@ -194,9 +183,11 @@ def apply_accepted_output_modes(
return replace_artifact_event_artifact(payload, artifact)

if isinstance(payload, TaskStatusUpdateEvent):
message = None
if payload.status.HasField("message"):
message = _filter_optional_message(payload.status.message, normalized)
message = (
_filter_message(payload.status.message, normalized)
if payload.status.HasField("message")
else None
)
return replace_status_event_message(payload, message)

if isinstance(payload, Task):
Expand Down Expand Up @@ -375,7 +366,7 @@ async def _continue_consuming(
def _filter_task(task: Task, accepted_output_modes: Collection[str]) -> Task:
updated = clone_proto(task)
if updated.status.HasField("message"):
filtered_message = _filter_optional_message(updated.status.message, accepted_output_modes)
filtered_message = _filter_message(updated.status.message, accepted_output_modes)
if filtered_message is None:
updated.status.ClearField("message")
else:
Expand Down Expand Up @@ -404,15 +395,6 @@ def _filter_task(task: Task, accepted_output_modes: Collection[str]) -> Task:
return updated


def _filter_optional_message(
message: Message | None,
accepted_output_modes: Collection[str],
) -> Message | None:
if message is None:
return None
return _filter_message(message, accepted_output_modes)


def _filter_message(
message: Message,
accepted_output_modes: Collection[str],
Expand All @@ -439,7 +421,14 @@ def _filter_parts(
) -> list[Part]:
filtered: list[Part] = []
for part in parts:
media_type = _part_media_type(part)
if part.HasField("text"):
media_type = _TEXT_PLAIN_MEDIA_TYPE
elif part.HasField("data"):
media_type = _APPLICATION_JSON_MEDIA_TYPE
elif part.HasField("raw") or part.HasField("url"):
media_type = part.media_type or "application/octet-stream"
else:
media_type = None
if media_type is None or accepts_output_mode(accepted_output_modes, media_type):
filtered.append(part)
continue
Expand All @@ -459,13 +448,3 @@ def _filter_parts(
)
)
return filtered


def _part_media_type(part: Part) -> str | None:
if part.HasField("text"):
return _TEXT_PLAIN_MEDIA_TYPE
if part.HasField("data"):
return _APPLICATION_JSON_MEDIA_TYPE
if part.HasField("raw") or part.HasField("url"):
return part.media_type or "application/octet-stream"
return None
29 changes: 16 additions & 13 deletions src/opencode_a2a/server/application.py
Original file line number Diff line number Diff line change
Expand Up @@ -259,17 +259,6 @@ def _task_store_failure_message(operation: str) -> str:
return "Task store unavailable while deleting task state."
return "Task store unavailable."

@classmethod
def _task_store_failure_metadata(cls, operation: str) -> dict[str, dict[str, dict[str, str]]]:
return {
"opencode": {
"error": {
"type": TASK_STORE_ERROR_TYPE,
"operation": operation,
}
}
}

@classmethod
def _task_store_server_error(cls, exc: TaskStoreOperationError) -> InternalError:
return InternalError(message=cls._task_store_failure_message(exc.operation))
Expand All @@ -295,7 +284,14 @@ def _task_store_failure_task(
context_id=context_id,
status=TaskStatus(state=TaskState.TASK_STATE_FAILED, message=error_message),
history=[error_message],
metadata=cls._task_store_failure_metadata(operation),
metadata={
"opencode": {
"error": {
"type": TASK_STORE_ERROR_TYPE,
"operation": operation,
}
}
},
)

@classmethod
Expand All @@ -322,7 +318,14 @@ def _task_store_failure_events(
task_id=task_id,
context_id=context_id,
status=TaskStatus(state=TaskState.TASK_STATE_FAILED),
metadata=cls._task_store_failure_metadata(operation),
metadata={
"opencode": {
"error": {
"type": TASK_STORE_ERROR_TYPE,
"operation": operation,
}
}
},
),
)

Expand Down
87 changes: 33 additions & 54 deletions src/opencode_a2a/server/context_helpers.py
Original file line number Diff line number Diff line change
@@ -1,7 +1,6 @@
from __future__ import annotations

from collections.abc import Mapping, MutableMapping
from typing import Any

from a2a.auth.user import UnauthenticatedUser, User
from a2a.server.context import ServerCallContext
Expand All @@ -24,20 +23,41 @@ def normalize_server_call_context(context: ServerCallContext | None) -> ServerCa
if context is None:
return ServerCallContext()

raw_state = _read_context_attribute(context, "state", default=None)
raw_requested_extensions = _read_context_attribute(
context,
"requested_extensions",
default=None,
)
raw_tenant = _read_context_attribute(context, "tenant", default="")
raw_user = _read_context_attribute(context, "user", default=None)
raw_state = getattr(context, "state", None)
raw_requested_extensions = getattr(context, "requested_extensions", None)
raw_tenant = getattr(context, "tenant", "")
raw_user = getattr(context, "user", None)

state = _normalize_context_state(raw_state)
requested_extensions = _normalize_requested_extensions(raw_requested_extensions)
tenant = _normalize_tenant(raw_tenant)
if isinstance(raw_state, MutableMapping):
state = raw_state
elif isinstance(raw_state, Mapping):
state = dict(raw_state)
else:
state = {}

if raw_requested_extensions is None:
requested_extensions: set[str] = set()
elif isinstance(raw_requested_extensions, set):
requested_extensions = raw_requested_extensions
elif isinstance(raw_requested_extensions, str):
requested_extensions = {raw_requested_extensions}
else:
try:
requested_extensions = {str(value) for value in raw_requested_extensions}
except TypeError:
requested_extensions = set()

tenant = raw_tenant if isinstance(raw_tenant, str) else ""
identity = state.get("identity")
user = _normalize_user(raw_user, identity=identity if isinstance(identity, str) else None)
normalized_identity = identity if isinstance(identity, str) else None
if normalized_identity and (
not isinstance(raw_user, User) or isinstance(raw_user, UnauthenticatedUser)
):
user: User = AuthenticatedIdentityUser(normalized_identity)
elif isinstance(raw_user, User):
user = raw_user
else:
user = UnauthenticatedUser()

if (
isinstance(context, ServerCallContext)
Expand All @@ -54,44 +74,3 @@ def normalize_server_call_context(context: ServerCallContext | None) -> ServerCa
tenant=tenant,
requested_extensions=requested_extensions,
)


def _read_context_attribute(context: Any, name: str, *, default: Any) -> Any:
try:
return getattr(context, name)
except AttributeError:
return default


def _normalize_context_state(raw_state: Any) -> MutableMapping[str, Any]:
if isinstance(raw_state, MutableMapping):
return raw_state
if isinstance(raw_state, Mapping):
return dict(raw_state)
return {}


def _normalize_requested_extensions(raw_extensions: Any) -> set[str]:
if raw_extensions is None:
return set()
if isinstance(raw_extensions, set):
return raw_extensions
if isinstance(raw_extensions, str):
return {raw_extensions}
try:
return {str(value) for value in raw_extensions}
except TypeError:
return set()


def _normalize_tenant(raw_tenant: Any) -> str:
return raw_tenant if isinstance(raw_tenant, str) else ""


def _normalize_user(raw_user: Any, *, identity: str | None) -> User:
if identity:
if not isinstance(raw_user, User) or isinstance(raw_user, UnauthenticatedUser):
return AuthenticatedIdentityUser(identity)
if isinstance(raw_user, User):
return raw_user
return UnauthenticatedUser()
Loading
Loading