diff --git a/docs/extension-specifications.md b/docs/extension-specifications.md index 862009a..ac50a58 100644 --- a/docs/extension-specifications.md +++ b/docs/extension-specifications.md @@ -43,8 +43,10 @@ URI: `https://github.com/Intelligent-Internet/opencode-a2a/blob/main/docs/extens - Scope: provider-private OpenCode session lifecycle, history, and low-risk control methods - Public Agent Card: capability declaration only -- Authenticated extended card: full method matrix, pagination rules, errors, and context semantics +- Authenticated extended card: full method matrix, pagination rules, errors, context semantics, and existing `opencode.sessions.prompt_async` input-part contracts - Transport: A2A JSON-RPC extension methods +- `opencode.sessions.prompt_async` includes a provider-private `request.parts[]` compatibility surface for upstream OpenCode part types `text`, `file`, `agent`, and `subtask` +- `subtask` support is declared as passthrough-compatible only: subagent selection and task-tool execution remain upstream OpenCode runtime behavior, not a separate `opencode-a2a` orchestration API ## OpenCode Provider Discovery v1 diff --git a/docs/guide.md b/docs/guide.md index d16c112..07e3e38 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -347,6 +347,20 @@ Retention guidance: - Treat `opencode.sessions.shell` as deployment-conditional and discover it from the declared profile and current wire contract before calling it. - Treat `protocol_compatibility` as the runtime truth for which protocol line is fully supported versus only partially adapted. +Extension boundary principles: + +- Expose OpenCode-specific capabilities through A2A only when they fit the adapter boundary: the adapter may document, validate, route, and normalize stable upstream-facing behavior, but it should not become a general replacement for upstream private runtime internals or host-level control planes. +- Default new `opencode.*` methods to provider-private extension status. Do not present them as portable A2A baseline capabilities unless they truly align with shared protocol semantics. +- Prefer read-only discovery, stable compatibility surfaces, and low-risk control methods before introducing stronger mutating or destructive operations. +- Map results to A2A core objects only when the upstream payload is a stable, low-ambiguity read projection such as session-to-`Task` or message-to-`Message`. Otherwise prefer provider-private summary/result envelopes. +- Treat upstream internal execution mechanisms, including subtask/subagent fan-out and task-tool internals, as provider-private runtime behavior. The adapter may expose passthrough compatibility and observable output metadata, but should not promote those internals into a first-class A2A orchestration API by default. +- For any new extension proposal, require an explicit answer to all of the following before implementation: + - What client value is added beyond the existing chat/session flow? + - Is the upstream behavior stable enough to document as a maintained contract? + - Should the surface remain provider-private, deployment-conditional, or not be exposed at all? + - Are authorization, workspace/session ownership, and destructive-side-effect boundaries clear enough to enforce? + - Can the result shape be expressed without overfitting OpenCode internals into fake A2A core semantics? + ## Multipart Input Example Minimal JSON-RPC example with text + file input: @@ -669,6 +683,14 @@ curl -sS http://127.0.0.1:8000/ \ ### Session Prompt Async (`opencode.sessions.prompt_async`) +Topology note: + +- `A2A Task` remains the protocol-level execution object exposed by the adapter. +- `opencode.sessions.prompt_async` is a provider-private extension method, not part of the A2A core baseline. +- `request.parts[].type=subtask` is an upstream-compatible OpenCode input shape carried through that extension method. +- Downstream execution may fan out into upstream OpenCode task-tool / subagent runtime behavior, but that internal orchestration remains provider-private. +- The adapter documents passthrough compatibility and observable `tool_call` output blocks; it does not promote subtask/subagent execution into a first-class A2A orchestration API. + ```bash curl -sS http://127.0.0.1:8000/ \ -H 'content-type: application/json' \ @@ -713,8 +735,38 @@ Validation notes: - `metadata.opencode.directory` follows the same normalization and boundary rules as message send (`realpath` + workspace boundary check). - `metadata.opencode.workspace.id` is a provider-private routing hint. When it is present, the adapter routes the request to that workspace and does not apply directory override resolution for the same call. - `request.model` uses the same shape as `metadata.shared.model` and is scoped only to the current session-control request. +- `request.parts[]` currently accepts upstream-compatible provider-private part types `text`, `file`, `agent`, and `subtask`. +- `subtask` parts require `prompt`, `description`, and `agent`; they may also include optional `model` and `command`. +- For `subtask` parts, `request.parts[].agent` is the upstream subagent selector. `opencode-a2a` validates and forwards the shape but does not define a separate subagent discovery or orchestration API. - Control methods enforce session owner guard based on request identity. +Example (`opencode.sessions.prompt_async` with a provider-private `subtask` part): + +```bash +curl -sS http://127.0.0.1:8000/ \ + -H 'content-type: application/json' \ + -H 'Authorization: Bearer ' \ + -d '{ + "jsonrpc": "2.0", + "id": 211, + "method": "opencode.sessions.prompt_async", + "params": { + "session_id": "", + "request": { + "parts": [ + { + "type": "subtask", + "prompt": "Inspect the auth middleware and list the highest-risk gaps.", + "description": "Security-focused pass over request auth flow", + "agent": "explore", + "command": "review" + } + ] + } + } + }' +``` + ### Session Command (`opencode.sessions.command`) ```bash diff --git a/src/opencode_a2a/contracts/extensions.py b/src/opencode_a2a/contracts/extensions.py index 32a4f03..2627a60 100644 --- a/src/opencode_a2a/contracts/extensions.py +++ b/src/opencode_a2a/contracts/extensions.py @@ -114,6 +114,22 @@ class WorkspaceControlMethodContract: *PROMPT_ASYNC_REQUEST_REQUIRED_FIELDS, *PROMPT_ASYNC_REQUEST_OPTIONAL_FIELDS, ) +PROMPT_ASYNC_SUPPORTED_PART_TYPES: tuple[str, ...] = ("text", "file", "agent", "subtask") +PROMPT_ASYNC_PART_CONTRACTS: dict[str, dict[str, Any]] = { + "text": { + "required": ("type", "text"), + }, + "file": { + "required": ("type", "mime", "url"), + }, + "agent": { + "required": ("type", "name"), + }, + "subtask": { + "required": ("type", "prompt", "description", "agent"), + "optional": ("model", "command"), + }, +} COMMAND_REQUEST_REQUIRED_FIELDS: tuple[str, ...] = ("command", "arguments") COMMAND_REQUEST_OPTIONAL_FIELDS: tuple[str, ...] = ( "messageID", @@ -777,6 +793,48 @@ def _build_method_contract_params( return params +def _build_prompt_async_part_contracts() -> dict[str, Any]: + part_contracts: dict[str, Any] = {} + for part_type, contract in PROMPT_ASYNC_PART_CONTRACTS.items(): + part_contract_doc: dict[str, Any] = { + "required": list(contract["required"]), + } + optional = contract.get("optional") + if optional: + part_contract_doc["optional"] = list(optional) + part_contracts[part_type] = part_contract_doc + return { + "items_type": "PromptAsyncPart[]", + "type_field": "type", + "accepted_types": list(PROMPT_ASYNC_SUPPORTED_PART_TYPES), + "part_contracts": part_contracts, + } + + +def _build_prompt_async_subtask_support() -> dict[str, Any]: + return { + "support_level": "passthrough-compatible", + "invocation_path": "request.parts[]", + "part_type": "subtask", + "subagent_selector_field": "request.parts[].agent", + "execution_model": "upstream-provider-private-subagent-runtime", + "notes": [ + ( + "opencode-a2a validates and forwards provider-private subtask parts to " + "the upstream OpenCode session runtime." + ), + ( + "The adapter does not define a separate subagent discovery or " + "orchestration JSON-RPC method surface." + ), + ( + "Subtask execution semantics, available subagent names, and any task-tool " + "fan-out remain upstream OpenCode behavior." + ), + ], + } + + def build_session_binding_extension_params( *, runtime_profile: RuntimeProfile, @@ -946,6 +1004,9 @@ def build_session_query_extension_params( "params": params_contract, "result": result_contract, } + if method_contract.method == SESSION_QUERY_METHODS["prompt_async"]: + contract_doc["request_parts"] = _build_prompt_async_part_contracts() + contract_doc["subtask_support"] = _build_prompt_async_subtask_support() if method_contract.notification_response_status is not None: contract_doc["notification_response_status"] = ( method_contract.notification_response_status diff --git a/src/opencode_a2a/server/agent_card.py b/src/opencode_a2a/server/agent_card.py index 7b4ad86..79ecca4 100644 --- a/src/opencode_a2a/server/agent_card.py +++ b/src/opencode_a2a/server/agent_card.py @@ -165,7 +165,10 @@ def _build_session_query_skill_examples( "Read one session diff (method opencode.sessions.diff).", ("List messages with cursor pagination (method opencode.sessions.messages.list)."), "Get one session message (method opencode.sessions.messages.get).", - "Send async prompt to a session (method opencode.sessions.prompt_async).", + ( + "Send async prompt to a session, including provider-private agent/subtask " + "parts (method opencode.sessions.prompt_async)." + ), "Send command to a session (method opencode.sessions.command).", "Fork a session at a message boundary (method opencode.sessions.fork).", "Share or unshare a session (methods opencode.sessions.share / opencode.sessions.unshare).", diff --git a/src/opencode_a2a/server/openapi.py b/src/opencode_a2a/server/openapi.py index a9a944a..87ff2c6 100644 --- a/src/opencode_a2a/server/openapi.py +++ b/src/opencode_a2a/server/openapi.py @@ -44,6 +44,10 @@ def _build_jsonrpc_extension_openapi_description( "plus shared model-selection metadata, OpenCode session/provider extensions, " "interrupt recovery extensions, and shared interrupt callback methods.\n\n" f"OpenCode session lifecycle/query/control methods: {', '.join(session_methods)}.\n" + "The existing prompt_async extension also accepts provider-private OpenCode " + "request.parts[] item types such as text, file, agent, and subtask; subtask " + "execution remains upstream runtime behavior rather than a separate A2A " + "orchestration API.\n" f"OpenCode provider/model discovery methods: {provider_methods}.\n" f"OpenCode project/workspace/worktree control methods: {workspace_methods}.\n" f"OpenCode interrupt recovery methods: {interrupt_recovery_methods}.\n" @@ -238,6 +242,28 @@ def _build_jsonrpc_extension_openapi_examples( }, }, }, + "session_prompt_async_subtask": { + "summary": "Send a provider-private subtask part to an existing session", + "value": { + "jsonrpc": "2.0", + "id": 211, + "method": SESSION_QUERY_METHODS["prompt_async"], + "params": { + "session_id": "s-1", + "request": { + "parts": [ + { + "type": "subtask", + "prompt": "Inspect the auth middleware and list gaps.", + "description": "Security-focused pass over request auth flow", + "agent": "explore", + "command": "review", + } + ] + }, + }, + }, + }, "session_command": { "summary": "Send command to an existing session", "value": { diff --git a/tests/jsonrpc/test_opencode_session_extension_prompt_async.py b/tests/jsonrpc/test_opencode_session_extension_prompt_async.py index c576768..df7c6c6 100644 --- a/tests/jsonrpc/test_opencode_session_extension_prompt_async.py +++ b/tests/jsonrpc/test_opencode_session_extension_prompt_async.py @@ -66,6 +66,77 @@ async def test_session_prompt_async_extension_success(monkeypatch): assert dummy.prompt_async_calls[0]["request"]["parts"][0]["text"] == "Continue the task" +@pytest.mark.asyncio +async def test_session_prompt_async_extension_accepts_subtask_parts(monkeypatch): + import opencode_a2a.server.application as app_module + + dummy = DummyOpencodeUpstreamClient( + make_settings( + a2a_bearer_token="t-1", + a2a_log_payloads=False, + opencode_workspace_root="/workspace", + **_BASE_SETTINGS, + ) + ) + monkeypatch.setattr(app_module, "OpencodeUpstreamClient", lambda _settings: dummy) + app = app_module.create_app( + make_settings( + a2a_bearer_token="t-1", + a2a_log_payloads=False, + opencode_workspace_root="/workspace", + **_BASE_SETTINGS, + ) + ) + + transport = httpx.ASGITransport(app=app) + async with httpx.AsyncClient(transport=transport, base_url="http://test") as client: + response = await client.post( + "/", + headers={"Authorization": "Bearer t-1"}, + json={ + "jsonrpc": "2.0", + "id": 3012, + "method": "opencode.sessions.prompt_async", + "params": { + "session_id": "s-1", + "request": { + "parts": [ + { + "type": "subtask", + "prompt": "Inspect auth middleware", + "description": "Security pass", + "agent": "explore", + "command": "review", + "model": { + "providerID": "openai", + "modelID": "gpt-5", + }, + } + ] + }, + "metadata": {"opencode": {"directory": "/workspace"}}, + }, + }, + ) + + assert response.status_code == 200 + assert response.json()["result"] == {"ok": True, "session_id": "s-1"} + request = dummy.prompt_async_calls[0]["request"] + assert request["parts"] == [ + { + "type": "subtask", + "prompt": "Inspect auth middleware", + "description": "Security pass", + "agent": "explore", + "command": "review", + "model": { + "providerID": "openai", + "modelID": "gpt-5", + }, + } + ] + + @pytest.mark.asyncio async def test_session_prompt_async_extension_prefers_workspace_metadata(monkeypatch): import opencode_a2a.server.application as app_module diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index 0b149a5..9c3992d 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -388,6 +388,41 @@ def test_agent_card_injects_profile_into_extensions() -> None: assert revert_contract["result"]["items_type"] == "SessionSummary" assert unrevert_contract["result"]["fields"] == ["item"] assert prompt_contract["params"]["required"] == ["session_id", "request.parts"] + assert prompt_contract["request_parts"] == { + "items_type": "PromptAsyncPart[]", + "type_field": "type", + "accepted_types": ["text", "file", "agent", "subtask"], + "part_contracts": { + "text": {"required": ["type", "text"]}, + "file": {"required": ["type", "mime", "url"]}, + "agent": {"required": ["type", "name"]}, + "subtask": { + "required": ["type", "prompt", "description", "agent"], + "optional": ["model", "command"], + }, + }, + } + assert prompt_contract["subtask_support"] == { + "support_level": "passthrough-compatible", + "invocation_path": "request.parts[]", + "part_type": "subtask", + "subagent_selector_field": "request.parts[].agent", + "execution_model": "upstream-provider-private-subagent-runtime", + "notes": [ + ( + "opencode-a2a validates and forwards provider-private subtask parts to " + "the upstream OpenCode session runtime." + ), + ( + "The adapter does not define a separate subagent discovery or " + "orchestration JSON-RPC method surface." + ), + ( + "Subtask execution semantics, available subagent names, and any task-tool " + "fan-out remain upstream OpenCode behavior." + ), + ], + } assert prompt_contract["result"]["fields"] == ["ok", "session_id"] assert command_contract["params"]["required"] == [ "session_id",