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
11 changes: 9 additions & 2 deletions docs/extension-specifications.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
17 changes: 8 additions & 9 deletions docs/guide.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand All @@ -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`.
Expand Down Expand Up @@ -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

Expand Down
90 changes: 53 additions & 37 deletions src/opencode_a2a/contracts/extensions/public_params.py
Original file line number Diff line number Diff line change
Expand Up @@ -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,
}


Expand Down Expand Up @@ -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,
Expand Down Expand Up @@ -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,
}


Expand Down
14 changes: 8 additions & 6 deletions tests/server/test_agent_card.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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]
Expand Down
Loading