diff --git a/docs/extension-specifications.md b/docs/extension-specifications.md index 5e5589c..1100ced 100644 --- a/docs/extension-specifications.md +++ b/docs/extension-specifications.md @@ -69,8 +69,15 @@ Extension URI: `urn:opencode-a2a:extension:shared:stream-hints:v1` - Scope: shared response/task/stream metadata for block identity, progress, and usage hints - Disclosure: public Agent Card and authenticated extended Agent Card - Activation: client requests the URI via `A2A-Extensions`; runtime emits shared metadata on streamed events and final task payloads -- Runtime fields: `metadata.shared.stream`, `metadata.shared.progress`, `metadata.shared.usage` -- Detailed-only correlation fields: `metadata.shared.stream.message_id` and `metadata.shared.stream.event_id` may be emitted for timeline stitching and diagnostics, but are not part of the minimum public consumer contract +- Declared field map: + - `metadata.shared.stream.block_type` + - `metadata.shared.stream.sequence` + - `metadata.shared.progress.type` + - `metadata.shared.progress.status` + - `metadata.shared.usage.input_tokens` + - `metadata.shared.usage.output_tokens` + - `metadata.shared.usage.total_tokens` +- Clients must not treat undeclared metadata under this namespace as part of the shared v1 contract - Dependencies: none declared by this version - Security boundary: this extension is observational metadata only; callback methods are defined by the separate shared interactive interrupt extension - Versioning: breaking changes require a new versioned URI diff --git a/docs/guide.md b/docs/guide.md index 28b0483..cd17576 100644 --- a/docs/guide.md +++ b/docs/guide.md @@ -229,9 +229,7 @@ If one deployment works while another fails against the same upstream provider, - text chunks use a stable text artifact ID - reasoning chunks use a stable reasoning artifact ID - tool-call updates use a stable per-tool-part artifact ID when the upstream part is identifiable -- `artifact.metadata.shared.stream.event_id` preserves the original cross-artifact stream timeline, even when different logical lanes use different artifact IDs. -- `artifact.metadata.shared.stream.message_id` remains best-effort metadata: when upstream omits `message_id`, the service falls back to a stable request-scoped message identity. -- `message_id` and `event_id` are advanced correlation fields: they help timeline stitching, deduplication, diagnostics, and advanced UI linking, but generic A2A consumers must not require them. +- The shared stream-hints v1 contract only declares `block_type` and `sequence` under `artifact.metadata.shared.stream`. - `artifact.metadata.shared.stream.sequence` carries the canonical per-request stream sequence. - A final complete text snapshot is emitted only when streaming chunks did not already produce the same final text. - That final complete text snapshot uses `append=false` on the text artifact so clients and the task store can treat it as the canonical replace-on-finish version rather than another fragment. @@ -242,7 +240,7 @@ If one deployment works while another fails against the same upstream provider, - If `application/json` is not accepted but `text/plain` is still accepted, those `tool_call` blocks are downgraded to stable compact JSON text so text-only clients retain the same observable state transitions. - When a request restricts `acceptedOutputModes`, the stream applies the same output filtering before persistence so later task snapshots do not re-expose filtered structured blocks. - Persistence is canonicalized separately from transport: stream subscribers still receive incremental artifact updates, while task-store persistence rewrites those updates into compact per-artifact snapshots so `GetTask` and terminal replay do not accumulate token-level fragments. -- Final status event metadata may include normalized token usage at `metadata.shared.usage` with fields such as `input_tokens`, `output_tokens`, `total_tokens`, optional `reasoning_tokens`, optional `cache_tokens.read_tokens` / `cache_tokens.write_tokens`, and optional `cost`. +- The shared stream-hints v1 contract declares normalized usage fields `input_tokens`, `output_tokens`, and `total_tokens` at `metadata.shared.usage`. - Progress metadata at `metadata.shared.progress` is emitted only when the client negotiated `urn:opencode-a2a:extension:shared:stream-hints:v1`; baseline streams do not emit duplicate generic `working` status updates just to carry progress hints. - Usage is extracted from documented info payloads and supported usage parts such as `step-finish`; non-usage parts with similar fields are ignored. - Interrupt events (`permission.asked` / `question.asked`) are mapped to `TaskStatusUpdateEvent(final=false, state=input-required)` with details at `metadata.shared.interrupt` when the client negotiated `urn:opencode-a2a:extension:shared:interactive-interrupt:v1`. @@ -572,24 +570,25 @@ Runtime payload: Shared runtime fields: - `metadata.shared.stream` - - block-level stream metadata such as `block_type`, `message_id`, `event_id`, and `sequence` + - declared shared v1 fields: `block_type` and `sequence` - `metadata.shared.usage` - - normalized usage data such as `input_tokens`, `output_tokens`, `total_tokens`, optional `reasoning_tokens`, optional `cache_tokens.read_tokens` / `cache_tokens.write_tokens`, and optional `cost` + - declared shared v1 fields: `input_tokens`, `output_tokens`, and `total_tokens` +- clients must ignore undeclared fields when interpreting the shared v1 contract Consumer guidance: - Use the extension declaration to know the server emits canonical shared stream hints. - Use runtime metadata to render block timelines, progress states, and token usage. -- Treat `message_id` and `event_id` as optional advanced correlation fields rather than baseline consumer requirements. +- Do not rely on undeclared fields under `metadata.shared.stream` or `metadata.shared.usage` as stable contract surface. - Do not infer capability support only from seeing one runtime field on one response; rely on Agent Card discovery first when possible. Minimal stream semantics summary: - `text`, `reasoning`, and `tool_call` are emitted as canonical block types - `text` and `reasoning` blocks use text parts, while `tool_call` uses structured v1 part payloads -- `message_id` and `event_id` preserve stable timeline identity where possible and are emitted only as optional advanced correlation hints +- only `block_type` and `sequence` are part of the declared shared stream field map - `sequence` is the per-request canonical stream sequence -- final task/status metadata may repeat normalized usage after the streaming phase ends +- final task/status metadata may repeat declared usage totals ## OpenCode Session Management A2A Extension diff --git a/src/opencode_a2a/contracts/extensions/public_params.py b/src/opencode_a2a/contracts/extensions/public_params.py index e0cf26f..633e98a 100644 --- a/src/opencode_a2a/contracts/extensions/public_params.py +++ b/src/opencode_a2a/contracts/extensions/public_params.py @@ -53,26 +53,42 @@ def select_public_extension_params( return {key: params[key] for key in keys if key in params} +_DECLARED_SHARED_STREAM_FIELD_KEYS = ("block_type", "sequence") +_DECLARED_SHARED_PROGRESS_FIELD_KEYS = ("type", "status") +_DECLARED_SHARED_USAGE_FIELD_KEYS = ("input_tokens", "output_tokens", "total_tokens") + + +def _build_declared_streaming_metadata_fields( + params: dict[str, Any], +) -> tuple[dict[str, Any], dict[str, Any], dict[str, Any]]: + return ( + select_public_extension_params( + params["stream_fields"], + keys=_DECLARED_SHARED_STREAM_FIELD_KEYS, + ), + select_public_extension_params( + params["progress_fields"], + keys=_DECLARED_SHARED_PROGRESS_FIELD_KEYS, + ), + select_public_extension_params( + params["usage_fields"], + keys=_DECLARED_SHARED_USAGE_FIELD_KEYS, + ), + ) + + def build_public_streaming_extension_params( params: dict[str, Any], ) -> dict[str, Any]: + stream_fields, progress_fields, usage_fields = _build_declared_streaming_metadata_fields(params) return { "artifact_metadata_field": params["artifact_metadata_field"], "progress_metadata_field": params["progress_metadata_field"], "usage_metadata_field": params["usage_metadata_field"], "block_types": params["block_types"], - "stream_fields": select_public_extension_params( - params["stream_fields"], - keys=("block_type", "sequence"), - ), - "progress_fields": select_public_extension_params( - params["progress_fields"], - keys=("type", "status"), - ), - "usage_fields": select_public_extension_params( - params["usage_fields"], - keys=("input_tokens", "output_tokens", "total_tokens"), - ), + "stream_fields": stream_fields, + "progress_fields": progress_fields, + "usage_fields": usage_fields, } @@ -177,6 +193,28 @@ def build_model_selection_extension_params( def build_streaming_extension_params() -> dict[str, Any]: + stream_fields = { + "block_type": f"{identifiers.SHARED_STREAM_METADATA_FIELD}.block_type", + "sequence": f"{identifiers.SHARED_STREAM_METADATA_FIELD}.sequence", + } + progress_fields = { + "type": f"{identifiers.SHARED_PROGRESS_METADATA_FIELD}.type", + "status": f"{identifiers.SHARED_PROGRESS_METADATA_FIELD}.status", + } + usage_fields = { + "input_tokens": f"{identifiers.SHARED_USAGE_METADATA_FIELD}.input_tokens", + "output_tokens": f"{identifiers.SHARED_USAGE_METADATA_FIELD}.output_tokens", + "total_tokens": f"{identifiers.SHARED_USAGE_METADATA_FIELD}.total_tokens", + } + declared_stream_fields, declared_progress_fields, declared_usage_fields = ( + _build_declared_streaming_metadata_fields( + { + "stream_fields": stream_fields, + "progress_fields": progress_fields, + "usage_fields": usage_fields, + } + ) + ) return { "artifact_metadata_field": identifiers.SHARED_STREAM_METADATA_FIELD, "status_metadata_field": identifiers.SHARED_STREAM_METADATA_FIELD, @@ -207,31 +245,9 @@ def build_streaming_extension_params() -> dict[str, Any]: }, }, }, - "stream_fields": { - "block_type": f"{identifiers.SHARED_STREAM_METADATA_FIELD}.block_type", - "message_id": f"{identifiers.SHARED_STREAM_METADATA_FIELD}.message_id", - "event_id": f"{identifiers.SHARED_STREAM_METADATA_FIELD}.event_id", - "sequence": f"{identifiers.SHARED_STREAM_METADATA_FIELD}.sequence", - }, - "progress_fields": { - "type": f"{identifiers.SHARED_PROGRESS_METADATA_FIELD}.type", - "status": f"{identifiers.SHARED_PROGRESS_METADATA_FIELD}.status", - }, - "usage_fields": { - "input_tokens": f"{identifiers.SHARED_USAGE_METADATA_FIELD}.input_tokens", - "output_tokens": f"{identifiers.SHARED_USAGE_METADATA_FIELD}.output_tokens", - "total_tokens": f"{identifiers.SHARED_USAGE_METADATA_FIELD}.total_tokens", - "reasoning_tokens": (f"{identifiers.SHARED_USAGE_METADATA_FIELD}.reasoning_tokens"), - "cost": f"{identifiers.SHARED_USAGE_METADATA_FIELD}.cost", - "cache_tokens": { - "read_tokens": ( - f"{identifiers.SHARED_USAGE_METADATA_FIELD}.cache_tokens.read_tokens" - ), - "write_tokens": ( - f"{identifiers.SHARED_USAGE_METADATA_FIELD}.cache_tokens.write_tokens" - ), - }, - }, + "stream_fields": declared_stream_fields, + "progress_fields": declared_progress_fields, + "usage_fields": declared_usage_fields, } diff --git a/tests/server/test_agent_card.py b/tests/server/test_agent_card.py index 4c858f2..e16275f 100644 --- a/tests/server/test_agent_card.py +++ b/tests/server/test_agent_card.py @@ -172,6 +172,14 @@ def test_public_agent_card_is_slimmed_but_keeps_core_shared_contract_hints() -> "phase": "metadata.shared.interrupt.phase", }, } + assert ( + extended_ext_by_uri[STREAMING_EXTENSION_URI].params["stream_fields"] + == ext_by_uri[STREAMING_EXTENSION_URI].params["stream_fields"] + ) + assert ( + extended_ext_by_uri[STREAMING_EXTENSION_URI].params["usage_fields"] + == ext_by_uri[STREAMING_EXTENSION_URI].params["usage_fields"] + ) for uri in AUTHENTICATED_ONLY_EXTENSION_URIS: assert uri not in ext_by_uri @@ -320,12 +328,6 @@ def test_agent_card_injects_profile_into_extensions() -> None: "input_tokens": "metadata.shared.usage.input_tokens", "output_tokens": "metadata.shared.usage.output_tokens", "total_tokens": "metadata.shared.usage.total_tokens", - "reasoning_tokens": "metadata.shared.usage.reasoning_tokens", - "cost": "metadata.shared.usage.cost", - "cache_tokens": { - "read_tokens": "metadata.shared.usage.cache_tokens.read_tokens", - "write_tokens": "metadata.shared.usage.cache_tokens.write_tokens", - }, } session_management = ext_by_uri[SESSION_MANAGEMENT_EXTENSION_URI]